diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2b50a4a9d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,69 @@ +# Git +.git/ +.gitignore +.github/ + +# Node.js +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__/ + +# Temporary files +tmp/ +temp/ +.tmp/ + +# Go workspaces (local only) +go.work +go.work.sum \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..7223b342a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = false +end_of_line = lf +charset = utf-8 + +[*.{js,jsx,ts,tsx,mjs,json,md,css,scss,html}] +insert_final_newline = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..251600502 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 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 + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..42db6746b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,131 @@ +name: Bug report +description: Report a problem or regression in Bifrost +title: "[Bug]: " +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 (Next.js) + - 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: | + + 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 + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..99d680b0a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false + diff --git a/.github/ISSUE_TEMPLATE/docs_issue.yml b/.github/ISSUE_TEMPLATE/docs_issue.yml new file mode 100644 index 000000000..ee3ce3dbb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_issue.yml @@ -0,0 +1,45 @@ +name: Documentation issue +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +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: What’s 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 + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..c138cf2a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,69 @@ +name: Feature request +description: Suggest an idea or enhancement for Bifrost +title: "[Feature]: " +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 (Next.js) + - Docs + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add any other context, sketches, or references here. + validations: + required: false + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0f339107f --- /dev/null +++ b/.github/pull_request_template.md @@ -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 (Next.js) +- [ ] 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 + + diff --git a/.github/workflows/configs/default/.gitkeep b/.github/workflows/configs/default/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/configs/docker-compose.yml b/.github/workflows/configs/docker-compose.yml new file mode 100644 index 000000000..3e8e693c9 --- /dev/null +++ b/.github/workflows/configs/docker-compose.yml @@ -0,0 +1,83 @@ +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 + 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: 30s + timeout: 10s + retries: 3 + +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 + diff --git a/.github/workflows/configs/emptystate/.gitkeep b/.github/workflows/configs/emptystate/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/configs/noconfigstorenologstore/config.json b/.github/workflows/configs/noconfigstorenologstore/config.json new file mode 100644 index 000000000..9d7dc5391 --- /dev/null +++ b/.github/workflows/configs/noconfigstorenologstore/config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "config_store": { + "enabled": false + }, + "logs_store": { + "enabled": false + } +} \ No newline at end of file diff --git a/.github/workflows/configs/witconfigstorelogstorepostgres/config.json b/.github/workflows/configs/witconfigstorelogstorepostgres/config.json new file mode 100644 index 000000000..5d0eef756 --- /dev/null +++ b/.github/workflows/configs/witconfigstorelogstorepostgres/config.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.github/workflows/configs/withconfigstore/config.json b/.github/workflows/configs/withconfigstore/config.json new file mode 100644 index 000000000..849299610 --- /dev/null +++ b/.github/workflows/configs/withconfigstore/config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "config_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../.github/workflows/configs/withconfigstore/config.db" + } + } +} \ No newline at end of file diff --git a/.github/workflows/configs/withconfigstorelogsstorepostgres/config.json b/.github/workflows/configs/withconfigstorelogsstorepostgres/config.json new file mode 100644 index 000000000..58da401ac --- /dev/null +++ b/.github/workflows/configs/withconfigstorelogsstorepostgres/config.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.github/workflows/configs/withconfigstorelogsstoresqlite/config.json b/.github/workflows/configs/withconfigstorelogsstoresqlite/config.json new file mode 100644 index 000000000..101987c92 --- /dev/null +++ b/.github/workflows/configs/withconfigstorelogsstoresqlite/config.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.github/workflows/configs/withdynamicplugin/config.json b/.github/workflows/configs/withdynamicplugin/config.json new file mode 100644 index 000000000..6f68a57c3 --- /dev/null +++ b/.github/workflows/configs/withdynamicplugin/config.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/.github/workflows/configs/withobservability/config.json b/.github/workflows/configs/withobservability/config.json new file mode 100644 index 000000000..2a5f5a7ea --- /dev/null +++ b/.github/workflows/configs/withobservability/config.json @@ -0,0 +1,28 @@ +{ + "$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": { + "collector_url": "http://localhost:4318/v1/traces", + "trace_type": "otel", + "protocol": "http" + } + } + ] + } \ No newline at end of file diff --git a/.github/workflows/configs/withsemanticcache/config.json b/.github/workflows/configs/withsemanticcache/config.json new file mode 100644 index 000000000..68375029b --- /dev/null +++ b/.github/workflows/configs/withsemanticcache/config.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "vector_store": { + "enabled": true, + "type": "weaviate", + "config": { + "scheme": "http", + "host": "localhost:9000" + } + }, + "plugins": [ + { + "enabled": true, + "name": "semanticcache", + "config": { + "vector_store_namespace": "test" + } + } + ] +} \ No newline at end of file diff --git a/.github/workflows/npx-publish.yml b/.github/workflows/npx-publish.yml new file mode 100644 index 000000000..820e6d67c --- /dev/null +++ b/.github/workflows/npx-publish.yml @@ -0,0 +1,106 @@ +name: NPX Package Publish + +# Triggers when npx package is tagged +on: + push: + tags: + - "npx/v*" + +# Prevent concurrent runs for the same trigger +concurrency: + group: npx-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # Required for npm provenance + steps: + # Checkout the repository + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up Node.js environment + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + cache: "npm" + cache-dependency-path: | + npx/package-lock.json + + # Extract and validate version from tag + - name: Extract version from tag + id: extract-version + run: ./.github/workflows/scripts/extract-npx-version.sh + + # Update package.json with the tagged version + - name: Update package version + working-directory: npx + run: | + VERSION="${{ steps.extract-version.outputs.version }}" + echo "πŸ“ Updating package.json version to $VERSION" + npm version "$VERSION" --no-git-tag-version + + # Install dependencies (if any) + - name: Install dependencies + working-directory: npx + run: npm ci + + # Run tests (if any exist) + - name: Run tests + working-directory: npx + run: | + if [ -f "package.json" ] && npm run | grep -q "test"; then + echo "πŸ§ͺ Running tests..." + npm test + else + echo "⏭️ No tests found, skipping..." + fi + + # Publish to npm + - name: Publish to npm + working-directory: npx + env: + NODE_AUTH_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." + else + npm publish --provenance --access public + fi + + # Create GitHub release + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: bash .github/workflows/scripts/create-npx-release.sh "${{ steps.extract-version.outputs.version }}" "${{ steps.extract-version.outputs.full-tag }}" + + # Discord notification + - name: Discord Notification + if: always() + 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" diff --git a/.github/workflows/pr-test-notifier.yml b/.github/workflows/pr-test-notifier.yml new file mode 100644 index 000000000..c91c62fb8 --- /dev/null +++ b/.github/workflows/pr-test-notifier.yml @@ -0,0 +1,27 @@ +name: PR Test Notifier + +on: + pull_request: + types: [opened, reopened] + branches: + - main + +permissions: + pull-requests: write + +jobs: + notify: + name: Post Test Instructions + runs-on: ubuntu-latest + steps: + - 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)" \ No newline at end of file diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 000000000..4aa8edce5 --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,116 @@ +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 + +jobs: + # This job shows up immediately and waits for approval + run-tests: + 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: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.1" + + - name: Set up Python + uses: actions/setup-python@v5 + 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 + - πŸ”§ 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 }} + AZURE_EMB_API_KEY: ${{ secrets.AZURE_EMB_API_KEY }} + AZURE_EMB_ENDPOINT: ${{ secrets.AZURE_EMB_ENDPOINT }} + 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 }} + SGL_API_KEY: ${{ secrets.SGL_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} + VERTEX_PROJECT_ID: ${{ secrets.VERTEX_PROJECT_ID }} + 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 diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml new file mode 100644 index 000000000..b7922a734 --- /dev/null +++ b/.github/workflows/release-pipeline.yml @@ -0,0 +1,570 @@ +name: Release Pipeline + +# Triggers automatically on push to main when any version file changes +on: + push: + branches: ["main"] + +# Prevent concurrent runs +concurrency: + group: release-pipeline + cancel-in-progress: false + +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: Check if pipeline should be skipped + id: check + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -n 1) + if [[ "$FIRST_LINE" == *"--skip-pipeline"* ]]; then + echo "should-skip=true" >> $GITHUB_OUTPUT + else + echo "should-skip=false" >> $GITHUB_OUTPUT + fi + + # Detect what needs to be released + detect-changes: + needs: [check-skip] + runs-on: ubuntu-latest + # Skip if first line of commit message contains --skip-pipeline + if: needs.check-skip.outputs.should-skip != 'true' + outputs: + core-needs-release: ${{ steps.detect.outputs.core-needs-release }} + framework-needs-release: ${{ steps.detect.outputs.framework-needs-release }} + plugins-need-release: ${{ steps.detect.outputs.plugins-need-release }} + bifrost-http-needs-release: ${{ steps.detect.outputs.bifrost-http-needs-release }} + docker-needs-release: ${{ steps.detect.outputs.docker-needs-release }} + changed-plugins: ${{ steps.detect.outputs.changed-plugins }} + core-version: ${{ steps.detect.outputs.core-version }} + framework-version: ${{ steps.detect.outputs.framework-version }} + transport-version: ${{ steps.detect.outputs.transport-version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install jq + run: | + sudo apt-get update + sudo apt-get install -y jq + + - name: Detect what needs release + id: detect + run: ./.github/workflows/scripts/detect-all-changes.sh "auto" + + core-release: + needs: [check-skip, detect-changes] + if: needs.check-skip.outputs.should-skip != 'true' && needs.detect-changes.outputs.core-needs-release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + success: ${{ steps.release.outputs.success }} + version: ${{ needs.detect-changes.outputs.core-version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GH_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.1" + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Release core + id: release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + 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 }} + AZURE_EMB_API_KEY: ${{ secrets.AZURE_EMB_API_KEY }} + AZURE_EMB_ENDPOINT: ${{ secrets.AZURE_EMB_ENDPOINT }} + 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 }} + SGL_API_KEY: ${{ secrets.SGL_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} + VERTEX_PROJECT_ID: ${{ secrets.VERTEX_PROJECT_ID }} + run: ./.github/workflows/scripts/release-core.sh "${{ needs.detect-changes.outputs.core-version }}" + + framework-release: + needs: [check-skip, detect-changes, core-release] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && needs.detect-changes.outputs.framework-needs-release == 'true' && (needs.detect-changes.outputs.core-needs-release == 'false' || needs.core-release.result == 'success' || needs.core-release.result == 'skipped')" + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + success: ${{ steps.release.outputs.success }} + version: ${{ needs.detect-changes.outputs.framework-version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GH_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.1" + + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Set up Docker Compose + run: | + # Verify Docker is available + docker --version + # Install Docker Compose if not available as plugin + if ! docker compose version >/dev/null 2>&1; then + echo "Installing Docker Compose..." + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + else + echo "Docker Compose plugin is available" + docker compose version + fi + + - name: Release framework + id: release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + 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 }} + AZURE_EMB_API_KEY: ${{ secrets.AZURE_EMB_API_KEY }} + AZURE_EMB_ENDPOINT: ${{ secrets.AZURE_EMB_ENDPOINT }} + 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 }} + SGL_API_KEY: ${{ secrets.SGL_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} + VERTEX_PROJECT_ID: ${{ secrets.VERTEX_PROJECT_ID }} + run: ./.github/workflows/scripts/release-framework.sh "${{ needs.detect-changes.outputs.framework-version }}" + + plugins-release: + needs: [check-skip, detect-changes, core-release, framework-release] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && needs.detect-changes.outputs.plugins-need-release == 'true' && (needs.detect-changes.outputs.core-needs-release == 'false' || needs.core-release.result == 'success' || needs.core-release.result == 'skipped') && (needs.detect-changes.outputs.framework-needs-release == 'false' || needs.framework-release.result == 'success' || needs.framework-release.result == 'skipped')" + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + success: ${{ steps.release.outputs.success }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GH_TOKEN }} + + - name: Install jq + run: | + sudo apt-get update + sudo apt-get install -y jq + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.1" + + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Set up Docker Compose + run: | + # Verify Docker is available + docker --version + # Install Docker Compose if not available as plugin + if ! docker compose version >/dev/null 2>&1; then + echo "Installing Docker Compose..." + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + else + echo "Docker Compose plugin is available" + docker compose version + fi + + - name: Release all changed plugins + id: release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + 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 }} + AZURE_EMB_API_KEY: ${{ secrets.AZURE_EMB_API_KEY }} + AZURE_EMB_ENDPOINT: ${{ secrets.AZURE_EMB_ENDPOINT }} + 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 }} + SGL_API_KEY: ${{ secrets.SGL_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} + VERTEX_PROJECT_ID: ${{ secrets.VERTEX_PROJECT_ID }} + run: ./.github/workflows/scripts/release-all-plugins.sh '${{ needs.detect-changes.outputs.changed-plugins }}' + + bifrost-http-release: + needs: + [ + check-skip, + detect-changes, + core-release, + framework-release, + plugins-release, + ] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && needs.detect-changes.outputs.bifrost-http-needs-release == 'true' && (needs.detect-changes.outputs.core-needs-release == 'false' || needs.core-release.result == 'success' || needs.core-release.result == 'skipped') && (needs.detect-changes.outputs.framework-needs-release == 'false' || needs.framework-release.result == 'success' || needs.framework-release.result == 'skipped') && (needs.detect-changes.outputs.plugins-need-release == 'false' || needs.plugins-release.result == 'success' || needs.plugins-release.result == 'skipped')" + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + success: ${{ steps.release.outputs.success }} + version: ${{ needs.detect-changes.outputs.transport-version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GH_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.1" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Set up Docker Compose + run: | + # Verify Docker is available + docker --version + # Install Docker Compose if not available as plugin + if ! docker compose version >/dev/null 2>&1; then + echo "Installing Docker Compose..." + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + else + echo "Docker Compose plugin is available" + docker compose version + fi + + - name: Release bifrost-http + id: release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + MAXIM_API_KEY: ${{ secrets.MAXIM_API_KEY }} + MAXIM_LOGGER_ID: ${{ secrets.MAXIM_LOG_REPO_ID }} + 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 }} + 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 }} + AZURE_EMB_API_KEY: ${{ secrets.AZURE_EMB_API_KEY }} + AZURE_EMB_ENDPOINT: ${{ secrets.AZURE_EMB_ENDPOINT }} + 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 }} + SGL_API_KEY: ${{ secrets.SGL_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} + VERTEX_PROJECT_ID: ${{ secrets.VERTEX_PROJECT_ID }} + run: ./.github/workflows/scripts/release-bifrost-http.sh "${{ needs.detect-changes.outputs.transport-version }}" + + # Docker build amd64 + docker-build-amd64: + needs: [check-skip, detect-changes, bifrost-http-release] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && needs.detect-changes.outputs.docker-needs-release == 'true' && (needs.detect-changes.outputs.bifrost-http-needs-release == 'false' || needs.bifrost-http-release.result == 'success' || needs.bifrost-http-release.result == 'skipped')" + runs-on: ubuntu-latest + permissions: + contents: write + env: + REGISTRY: docker.io + ACCOUNT: maximhq + IMAGE_NAME: bifrost + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Verify bifrost-http release + id: verify + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + ./.github/workflows/scripts/verify-bifrost-http-release.sh "${{ needs.detect-changes.outputs.transport-version }}" "${{ needs.detect-changes.outputs.bifrost-http-needs-release }}" + echo "verified=true" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Determine Docker tags + id: tags + run: | + git pull origin ${{ github.ref_name }} + VERSION="${{ needs.detect-changes.outputs.transport-version }}" + BASE_TAG="${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ env.IMAGE_NAME }}:v${VERSION}-amd64" + echo "tags=${BASE_TAG}" >> $GITHUB_OUTPUT + + - name: Build and push AMD64 Docker image + uses: docker/build-push-action@v5 + with: + context: . + build-args: | + VERSION=${{ needs.detect-changes.outputs.transport-version }} + file: ./transports/Dockerfile + push: true + tags: ${{ steps.tags.outputs.tags }} + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + + # Docker build arm64 + docker-build-arm64: + needs: [check-skip, detect-changes, bifrost-http-release] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && needs.detect-changes.outputs.docker-needs-release == 'true' && (needs.detect-changes.outputs.bifrost-http-needs-release == 'false' || needs.bifrost-http-release.result == 'success' || needs.bifrost-http-release.result == 'skipped')" + runs-on: ubuntu-24.04-arm + permissions: + contents: write + env: + REGISTRY: docker.io + ACCOUNT: maximhq + IMAGE_NAME: bifrost + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Verify bifrost-http release + id: verify + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + ./.github/workflows/scripts/verify-bifrost-http-release.sh "${{ needs.detect-changes.outputs.transport-version }}" "${{ needs.detect-changes.outputs.bifrost-http-needs-release }}" + echo "verified=true" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Determine Docker tags + id: tags + run: | + git pull origin ${{ github.ref_name }} + VERSION="${{ needs.detect-changes.outputs.transport-version }}" + BASE_TAG="${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ env.IMAGE_NAME }}:v${VERSION}-arm64" + echo "tags=${BASE_TAG}" >> $GITHUB_OUTPUT + + - name: Build and push ARM64 Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./transports/Dockerfile + push: true + build-args: | + VERSION=${{ needs.detect-changes.outputs.transport-version }} + tags: ${{ steps.tags.outputs.tags }} + platforms: linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + # Docker manifest + docker-manifest: + needs: [check-skip, detect-changes, docker-build-amd64, docker-build-arm64] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && needs.docker-build-amd64.result == 'success' && needs.docker-build-arm64.result == 'success'" + runs-on: ubuntu-latest + env: + REGISTRY: docker.io + ACCOUNT: maximhq + IMAGE_NAME: bifrost + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Create and push multi-arch manifest + run: | + ./.github/workflows/scripts/create-docker-manifest.sh "${{ needs.detect-changes.outputs.transport-version }}" + + # Push Mintlify changelog + push-mintlify-changelog: + needs: [check-skip, detect-changes, bifrost-http-release] + if: "always() && needs.check-skip.outputs.should-skip != 'true' && (needs.detect-changes.outputs.bifrost-http-needs-release == 'false' || needs.bifrost-http-release.result == 'success' || needs.bifrost-http-release.result == 'skipped')" + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GH_TOKEN }} + + - name: Push Mintlify changelog + run: | + ./.github/workflows/scripts/push-mintlify-changelog.sh "${{ needs.detect-changes.outputs.transport-version }}" + + # Notification + notify: + needs: + [ + check-skip, + detect-changes, + core-release, + framework-release, + plugins-release, + bifrost-http-release, + docker-manifest, + ] + if: "always() && needs.check-skip.outputs.should-skip != 'true'" + runs-on: ubuntu-latest + steps: + - name: Install jq + run: | + sudo apt-get update + sudo apt-get install -y jq + + - name: Discord Notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + run: | + # Build status summary + CORE_STATUS="⏭️ Skipped" + FRAMEWORK_STATUS="⏭️ Skipped" + PLUGINS_STATUS="⏭️ Skipped" + BIFROST_STATUS="⏭️ Skipped" + + if [ "${{ needs.core-release.result }}" = "success" ]; then + CORE_STATUS="βœ… Released v${{ needs.detect-changes.outputs.core-version }}" + elif [ "${{ needs.core-release.result }}" = "failure" ]; then + CORE_STATUS="❌ Failed" + fi + + if [ "${{ needs.framework-release.result }}" = "success" ]; then + FRAMEWORK_STATUS="βœ… Released v${{ needs.detect-changes.outputs.framework-version }}" + elif [ "${{ needs.framework-release.result }}" = "failure" ]; then + FRAMEWORK_STATUS="❌ Failed" + fi + + if [ "${{ needs.plugins-release.result }}" = "success" ]; then + PLUGINS_STATUS="βœ… Released plugins" + elif [ "${{ needs.plugins-release.result }}" = "failure" ]; then + PLUGINS_STATUS="❌ Failed" + fi + + if [ "${{ needs.bifrost-http-release.result }}" = "success" ]; then + BIFROST_STATUS="βœ… Released v${{ needs.detect-changes.outputs.transport-version }}" + elif [ "${{ needs.bifrost-http-release.result }}" = "failure" ]; then + BIFROST_STATUS="❌ Failed" + fi + + # Build the message with proper formatting + MESSAGE=$(printf "πŸš€ **Release Pipeline Complete**\n\n**Components:**\nβ€’ Core: %s\nβ€’ Framework: %s\nβ€’ Plugins: %s\nβ€’ Bifrost HTTP: %s\n\n**Details:**\nβ€’ Branch: \`main\`\nβ€’ Commit: \`%.8s\`\nβ€’ Author: %s\n\n[View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" "$CORE_STATUS" "$FRAMEWORK_STATUS" "$PLUGINS_STATUS" "$BIFROST_STATUS" "${{ github.sha }}" "${{ github.actor }}") + + payload="$(jq -n --arg content "$MESSAGE" '{content:$content}')" + curl -sS -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK" diff --git a/.github/workflows/scripts/build-executables.sh b/.github/workflows/scripts/build-executables.sh new file mode 100755 index 000000000..8b3d12b36 --- /dev/null +++ b/.github/workflows/scripts/build-executables.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cross-compile Go binaries for multiple platforms +# Usage: ./build-executables.sh + +# Require version argument (matches usage) +if [[ -z "${1:-}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +VERSION="$1" + +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 +platforms=( + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + "windows/amd64" +) + +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 + if [[ "$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" diff --git a/.github/workflows/scripts/changelog-utils.sh b/.github/workflows/scripts/changelog-utils.sh new file mode 100644 index 000000000..00d3825d9 --- /dev/null +++ b/.github/workflows/scripts/changelog-utils.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Function to extract content from a file +# Usage: get_file_content +# 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 '^') + # 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 +} \ No newline at end of file diff --git a/.github/workflows/scripts/check-core-version-increment.sh b/.github/workflows/scripts/check-core-version-increment.sh new file mode 100755 index 000000000..492c4901d --- /dev/null +++ b/.github/workflows/scripts/check-core-version-increment.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Check if core version has been incremented and needs release +# Usage: ./check-core-version-increment.sh + +CURRENT_VERSION=$(cat core/version) +TAG_NAME="core/v${CURRENT_VERSION}" + +echo "πŸ“‹ Current core version: $CURRENT_VERSION" +echo "🏷️ Expected tag: $TAG_NAME" + +# Check if tag already exists +if git rev-parse --verify "$TAG_NAME" >/dev/null 2>&1; then + echo "⚠️ Tag $TAG_NAME already exists" + { + echo "should-release=false" + echo "new-version=$CURRENT_VERSION" + echo "tag-exists=true" + } >> "$GITHUB_OUTPUT" + exit 0 +fi + +# Get previous version from git tags +LATEST_CORE_TAG=$(git tag -l "core/v*" | sort -V | tail -1) + +if [ -z "$LATEST_CORE_TAG" ]; then + echo "πŸ“¦ No existing core tags found, this will be the first release" + { + echo "should-release=true" + echo "new-version=$CURRENT_VERSION" + echo "tag-exists=false" + } >> "$GITHUB_OUTPUT" + exit 0 +fi + +PREVIOUS_VERSION=${LATEST_CORE_TAG#core/v} +echo "πŸ“‹ Previous core version: $PREVIOUS_VERSION" + +# Compare versions using sort -V (version sort) +if [ "$(printf '%s\n' "$PREVIOUS_VERSION" "$CURRENT_VERSION" | sort -V | tail -1)" = "$CURRENT_VERSION" ] && [ "$PREVIOUS_VERSION" != "$CURRENT_VERSION" ]; then + echo "βœ… Version incremented from $PREVIOUS_VERSION to $CURRENT_VERSION" + echo "πŸš€ Core release needed" + { + echo "should-release=true" + echo "new-version=$CURRENT_VERSION" + echo "tag-exists=false" + } >> "$GITHUB_OUTPUT" +else + echo "⏭️ No version increment detected (current: $CURRENT_VERSION, latest: $PREVIOUS_VERSION)" + { + echo "should-release=false" + echo "new-version=$CURRENT_VERSION" + echo "tag-exists=false" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.github/workflows/scripts/check-dependency-flow.sh b/.github/workflows/scripts/check-dependency-flow.sh new file mode 100755 index 000000000..57c34a85b --- /dev/null +++ b/.github/workflows/scripts/check-dependency-flow.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Check the dependency flow and suggest next steps +# Usage: ./check-dependency-flow.sh [version] +# stage: core|framework|plugins +# version: required for core/framework; optional for plugins +usage() { + echo "Usage: $0 [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 diff --git a/.github/workflows/scripts/configure-r2.sh b/.github/workflows/scripts/configure-r2.sh new file mode 100755 index 000000000..36085e624 --- /dev/null +++ b/.github/workflows/scripts/configure-r2.sh @@ -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" diff --git a/.github/workflows/scripts/create-docker-manifest.sh b/.github/workflows/scripts/create-docker-manifest.sh new file mode 100755 index 000000000..a594507fd --- /dev/null +++ b/.github/workflows/scripts/create-docker-manifest.sh @@ -0,0 +1,36 @@ +# Validate input argument +if [ "${1:-}" = "" ]; then + echo "Usage: $0 " >&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 \ No newline at end of file diff --git a/.github/workflows/scripts/create-npx-release.sh b/.github/workflows/scripts/create-npx-release.sh new file mode 100755 index 000000000..db33d5ed8 --- /dev/null +++ b/.github/workflows/scripts/create-npx-release.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create GitHub release for NPX package +# Usage: ./create-npx-release.sh + +VERSION="$1" +FULL_TAG="$2" + +if [[ -z "$VERSION" || -z "$FULL_TAG" ]]; then + echo "❌ Usage: $0 " + 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\`_" + +# Create release +echo "πŸŽ‰ Creating GitHub release for $TITLE..." +if gh release view "$FULL_TAG" >/dev/null 2>&1; then + echo "ℹ️ Release $FULL_TAG already exists. Skipping creation." + exit 0 +fi +gh release create "$FULL_TAG" \ + --title "$TITLE" \ + --notes "$BODY" \ + --latest=false \ + --verify-tag \ + ${PRERELEASE_FLAG} diff --git a/.github/workflows/scripts/detect-all-changes.sh b/.github/workflows/scripts/detect-all-changes.sh new file mode 100755 index 000000000..ad7200654 --- /dev/null +++ b/.github/workflows/scripts/detect-all-changes.sh @@ -0,0 +1,353 @@ +#!/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 + # Get previous version + LATEST_CORE_TAG=$(git tag -l "core/v*" | sort -V | tail -1) + echo "🏷️ Latest core tag $LATEST_CORE_TAG" + if [ -z "$LATEST_CORE_TAG" ]; then + echo " βœ… First core release: $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*" | sort -V) + STABLE_TAGS=$(echo "$ALL_TAGS" | grep -v '\-') + 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 + LATEST_CORE_TAG=$(git tag -l "core/v*" | grep -v '\-' | sort -V | tail -1) + 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 + ALL_TAGS=$(git tag -l "framework/v*" | sort -V) + STABLE_TAGS=$(echo "$ALL_TAGS" | grep -v '\-') + PRERELEASE_TAGS=$(echo "$ALL_TAGS" | grep '\-' || true) + LATEST_FRAMEWORK_TAG="" + if [ -n "$STABLE_TAGS" ]; then + LATEST_FRAMEWORK_TAG=$(echo "$STABLE_TAGS" | tail -1) + echo "latest framework tag (stable preferred): $LATEST_FRAMEWORK_TAG" + else + LATEST_FRAMEWORK_TAG=$(echo "$PRERELEASE_TAGS" | tail -1) + echo "latest framework tag (prerelease only): $LATEST_FRAMEWORK_TAG" + fi + if [ -z "$LATEST_FRAMEWORK_TAG" ]; then + echo " βœ… First framework release: $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 + + if [[ "$current_version" == *"-"* ]]; then + # current_version has prerelease, so include all versions but prefer stable + ALL_TAGS=$(git tag -l "plugins/${plugin_name}/v*" | 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 + LATEST_PLUGIN_TAG=$(git tag -l "plugins/${plugin_name}/v*" | 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" + 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 + # Get all transport tags, prioritize stable over prerelease for same base version + ALL_TRANSPORT_TAGS=$(git tag -l "transports/v*" | 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: $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 \ No newline at end of file diff --git a/.github/workflows/scripts/extract-npx-version.sh b/.github/workflows/scripts/extract-npx-version.sh new file mode 100755 index 000000000..c6c89b516 --- /dev/null +++ b/.github/workflows/scripts/extract-npx-version.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extract NPX version from tag +# Usage: ./extract-npx-version.sh + +# Extract tag name from ref (prefer GITHUB_REF_NAME, fallback to GITHUB_REF) +# Use an intermediate to avoid set -u errors when both are unset in local runs +RAW_REF="${GITHUB_REF_NAME:-${GITHUB_REF:-}}" +TAG_NAME="${RAW_REF#refs/tags/}" +if [[ -z "${TAG_NAME}" ]]; then + echo "❌ TAG_NAME is empty. Ensure this runs on a tag ref or set GITHUB_REF_NAME." + exit 1 +fi + +echo "πŸ“‹ Processing tag: ${TAG_NAME}" + +# Validate tag format (npx/vX.Y.Z or prerelease like npx/vX.Y.Z-rc.1) +if [[ ! "${TAG_NAME}" =~ ^npx/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "❌ Invalid tag format '${TAG_NAME}'. Expected format: npx/vMAJOR.MINOR.PATCH" + exit 1 +fi + +# Extract version (remove 'npx/v' prefix to get just the version number) +VERSION="${TAG_NAME#npx/v}" +echo "πŸ“¦ Extracted NPX version: ${VERSION}" +echo "🏷️ Full tag: ${TAG_NAME}" +# Set outputs (only when running in GitHub Actions) +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "version=${VERSION}" + echo "full-tag=${TAG_NAME}" + } >> "$GITHUB_OUTPUT" +else + echo "::notice::GITHUB_OUTPUT not set; skipping outputs (local run?)" +fi \ No newline at end of file diff --git a/.github/workflows/scripts/get_curls.sh b/.github/workflows/scripts/get_curls.sh new file mode 100755 index 000000000..a67136567 --- /dev/null +++ b/.github/workflows/scripts/get_curls.sh @@ -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" diff --git a/.github/workflows/scripts/go-utils.sh b/.github/workflows/scripts/go-utils.sh new file mode 100755 index 000000000..1a2290385 --- /dev/null +++ b/.github/workflows/scripts/go-utils.sh @@ -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 +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 +} diff --git a/.github/workflows/scripts/install-cross-compilers.sh b/.github/workflows/scripts/install-cross-compilers.sh new file mode 100755 index 000000000..65051171b --- /dev/null +++ b/.github/workflows/scripts/install-cross-compilers.sh @@ -0,0 +1,76 @@ +#!/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/MacOSX11.3.sdk" +SDK_URL="https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX11.3.sdk.tar.xz" + +# Download and extract macOS SDK if not already installed +if [ ! -d "$SDK_DIR" ]; then + echo "πŸ“¦ Downloading macOS SDK..." + curl -L "$SDK_URL" -o /tmp/MacOSX11.3.sdk.tar.xz + sudo mkdir -p /opt + sudo tar -xf /tmp/MacOSX11.3.sdk.tar.xz -C /opt + rm -f /tmp/MacOSX11.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/MacOSX11.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/MacOSX11.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/MacOSX11.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/MacOSX11.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++" \ No newline at end of file diff --git a/.github/workflows/scripts/push-mintlify-changelog.sh b/.github/workflows/scripts/push-mintlify-changelog.sh new file mode 100755 index 000000000..58c66b146 --- /dev/null +++ b/.github/workflows/scripts/push-mintlify-changelog.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +VERSION=$1 + +if [ -z "$VERSION" ]; then + echo "Usage: $0 " + 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\" +---" + +# 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'""$'\n'"$content"$'\n\n'"" + # 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 +# Handles both non-empty and empty array forms +route="changelogs/$VERSION" +if ! grep -q "\"$route\"" docs/docs.json; then + awk -v route="$route" ' + function indent(line){ + x = line + sub(/[^[:space:]].*$/, "", x) + return x + } + $0 ~ /"tab": "Changelogs"/ { in_tab=1 } + in_tab && $0 ~ /"pages": \[\]/ { + ind = indent($0) + print ind "\"pages\": [" + print ind " \"" route "\"" + print ind "]" + fixing_empty=1 + in_tab=0 + next + } + in_tab && $0 ~ /"pages": \[/ { + print + ind = indent($0) + print ind " \"" route "\"," + in_tab=0 + next + } + fixing_empty && $0 ~ /^[[:space:]]*"changelogs\/[^"]+",?$/ { + fixing_empty=0 + next + } + { print } + ' docs/docs.json > docs/docs.json.tmp && mv docs/docs.json.tmp docs/docs.json +fi + +# Pulling again before committing +git pull origin main +# 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-pipeline" +git push origin main diff --git a/.github/workflows/scripts/release-all-plugins.sh b/.github/workflows/scripts/release-all-plugins.sh new file mode 100755 index 000000000..16a21d0a1 --- /dev/null +++ b/.github/workflows/scripts/release-all-plugins.sh @@ -0,0 +1,136 @@ +#!/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 ''" + 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 + + +# 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 + +echo "πŸ”Œ Processing plugin releases..." +echo "πŸ“‹ Changed plugins JSON: $CHANGED_PLUGINS_JSON" + +# No work early‐exit 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 + + +# Shutting down dependencies +echo "πŸ”§ Shutting down 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 down +elif docker compose version >/dev/null 2>&1; then + docker compose -f tests/docker-compose.yml down +else + echo "❌ Neither docker-compose nor docker compose is available" + exit 1 +fi + +# 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 diff --git a/.github/workflows/scripts/release-bifrost-http.sh b/.github/workflows/scripts/release-bifrost-http.sh new file mode 100755 index 000000000..d2e0778f2 --- /dev/null +++ b/.github/workflows/scripts/release-bifrost-http.sh @@ -0,0 +1,445 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Release bifrost-http component +# Usage: ./release-bifrost-http.sh + +# 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 " >&2 + exit 1 +fi + +VERSION="$1" +TAG_NAME="transports/v${VERSION}" + +echo "πŸš€ Releasing bifrost-http v$VERSION..." + +# Ensure tags are available (CI often does shallow clones) +git fetch --tags --force >/dev/null 2>&1 || true +LATEST_CORE_TAG=$(git tag -l "core/v*" | sort -V | tail -1) +LATEST_FRAMEWORK_TAG=$(git tag -l "framework/v*" | sort -V | tail -1) + +if [ -z "$LATEST_CORE_TAG" ]; then + CORE_VERSION="v$(tr -d '\n\r' < core/version)" +else + CORE_VERSION=${LATEST_CORE_TAG#core/} +fi + +if [ -z "$LATEST_FRAMEWORK_TAG" ]; then + FRAMEWORK_VERSION="v$(tr -d '\n\r' < framework/version)" +else + FRAMEWORK_VERSION=${LATEST_FRAMEWORK_TAG#framework/} +fi + +echo "πŸ” DEBUG: LATEST_CORE_TAG: $LATEST_CORE_TAG" +echo "πŸ” DEBUG: CORE_VERSION: $CORE_VERSION" +echo "πŸ” DEBUG: LATEST_FRAMEWORK_TAG: $LATEST_FRAMEWORK_TAG" +echo "πŸ” DEBUG: FRAMEWORK_VERSION: $FRAMEWORK_VERSION" + + +# Get latest plugin versions +echo "πŸ”Œ Getting latest plugin release versions..." +declare -A PLUGIN_VERSIONS + +# First, 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") + + # Check if VERSION parameter contains prerelease suffix + if [[ "$VERSION" == *"-"* ]]; then + # VERSION has prerelease, so include all versions but prefer stable + ALL_TAGS=$(git tag -l "plugins/${plugin_name}/v*" | 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 + LATEST_PLUGIN_TAG=$(git tag -l "plugins/${plugin_name}/v*" | grep -v '\-' | sort -V | tail -1 || true) + echo "latest plugin tag (stable only): $LATEST_PLUGIN_TAG" + fi + + if [ -z "$LATEST_PLUGIN_TAG" ]; then + # No matching release found, use version from file + PLUGIN_VERSION="v$(tr -d '\n\r' < "${plugin_dir}version")" + echo " πŸ“¦ $plugin_name: $PLUGIN_VERSION (from version file - not yet released)" + else + PLUGIN_VERSION=${LATEST_PLUGIN_TAG#plugins/${plugin_name}/} + echo " πŸ“¦ $plugin_name: $PLUGIN_VERSION (latest release)" + fi + + PLUGIN_VERSIONS["$plugin_name"]="$PLUGIN_VERSION" + 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 latest plugin releases +echo "πŸ”§ Using latest plugin release versions for transport..." +PLUGINS_USED=() + +# Track which plugins are actually used by the transport +cd transports +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" + go_get_with_backoff "github.com/maximhq/bifrost/plugins/$plugin_name@$plugin_version" + PLUGINS_USED+=("$plugin_name:$plugin_version") + fi +done + +# Also ensure core and framework are up to date + +echo " πŸ”§ Updating core to $CORE_VERSION" +go_get_with_backoff "github.com/maximhq/bifrost/core@$CORE_VERSION" + +echo " πŸ“¦ Updating framework to $FRAMEWORK_VERSION" +go_get_with_backoff "github.com/maximhq/bifrost/framework@$FRAMEWORK_VERSION" + +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 + +# Run unit tests +echo "πŸ§ͺ Running unit tests..." +go test ./... + +# 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" +) + +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 +echo "⏳ Waiting for Docker services to be ready..." +sleep 10 + +# Clean up SQLite database files from all config directories +echo "🧹 Cleaning up SQLite database files from config directories..." +find "$CONFIGS_DIR" -type f \( -name "*.db" -o -name "*.db-shm" -o -name "*.db-wal" \) -delete +echo "βœ… Cleanup complete" + +for config in "${CONFIGS_TO_TEST[@]}"; do + echo " πŸ” Testing with config: $config" + config_path="$CONFIGS_DIR/$config" + + 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 30s $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..." + echo " πŸ” DEBUG: SCRIPT_DIR=$SCRIPT_DIR" + echo " πŸ” DEBUG: PWD=$(pwd)" + GET_CURLS_SCRIPT="$SCRIPT_DIR/get_curls.sh" + echo " πŸ” DEBUG: GET_CURLS_SCRIPT=$GET_CURLS_SCRIPT" + echo " πŸ” DEBUG: File exists check: $([ -f "$GET_CURLS_SCRIPT" ] && echo 'YES' || echo 'NO')" + + 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 "βœ… Transport build validation successful" + +# Commit and push changes if any +# First, stage any changes made to transports/ +git add transports/ +if ! git diff --cached --quiet; then + git pull origin main + 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-pipeline" + git push -u origin HEAD +else + echo "ℹ️ No staged changes to commit" +fi + +# Install cross-compilation toolchains +echo "πŸ“¦ Installing cross-compilation toolchains..." +bash ./.github/workflows/scripts/install-cross-compilers.sh + +# Build Go executables +echo "πŸ”¨ Building executables..." +bash ./.github/workflows/scripts/build-executables.sh $VERSION + +# Configure and upload to R2 +echo "πŸ“€ Uploading binaries..." +bash ./.github/workflows/scripts/configure-r2.sh +bash ./.github/workflows/scripts/upload-to-r2.sh "$TAG_NAME" + +# Capturing changelog +CHANGELOG_BODY=$(cat transports/changelog.md) +# Skip comments from changelog +CHANGELOG_BODY=$(echo "$CHANGELOG_BODY" | 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 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" + +# 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" diff --git a/.github/workflows/scripts/release-core.sh b/.github/workflows/scripts/release-core.sh new file mode 100755 index 000000000..158ab9b9c --- /dev/null +++ b/.github/workflows/scripts/release-core.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Release core component +# Usage: ./release-core.sh + +if [[ "${1:-}" == "" ]]; then + echo "Usage: $0 " + 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 + +# Building core +go mod download +go build ./... +cd .. +echo "βœ… Core build validation successful" + +# Run core provider tests +echo "πŸ”§ Running core tests..." +cd core +# go test -v ./... +cd .. +echo "πŸ”§ Running core provider tests..." +cd tests/core-providers +go test -v -run . +cd ../.. + +# Capturing changelog +CHANGELOG_BODY=$(cat core/changelog.md) +# Skip comments from changelog +CHANGELOG_BODY=$(echo "$CHANGELOG_BODY" | 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" diff --git a/.github/workflows/scripts/release-framework.sh b/.github/workflows/scripts/release-framework.sh new file mode 100755 index 000000000..0e6bb04e9 --- /dev/null +++ b/.github/workflows/scripts/release-framework.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Release framework component +# Usage: ./release-framework.sh + +# Source Go utilities for exponential backoff +source "$(dirname "$0")/go-utils.sh" + +# Making sure version is provided +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&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 +git pull origin +# Fetching all tags +git fetch --tags >/dev/null 2>&1 || true + +# Get latest core version +LATEST_CORE_TAG=$(git tag -l "core/v*" | sort -V | tail -1) +if [ -z "$LATEST_CORE_TAG" ]; then + CORE_VERSION="v$(tr -d '\n\r' < core/version)" +else + CORE_VERSION=${LATEST_CORE_TAG#core/} +fi + + +# 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 ./... +# 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 +go test ./... +# Shutting down dependencies +echo "πŸ”§ Shutting down 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 down +elif docker compose version >/dev/null 2>&1; then + docker compose -f ../tests/docker-compose.yml down +else + echo "❌ Neither docker-compose nor docker compose is available" + exit 1 +fi +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-pipeline" + # Push the bump so go.mod/go.sum changes are recorded on the branch + CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + 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 '^') +# 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" diff --git a/.github/workflows/scripts/release-single-plugin.sh b/.github/workflows/scripts/release-single-plugin.sh new file mode 100755 index 000000000..c2a47912d --- /dev/null +++ b/.github/workflows/scripts/release-single-plugin.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Release a single plugin +# Usage: ./release-single-plugin.sh [core-version] [framework-version] + +# Source Go utilities for exponential backoff +source "$(dirname "$0")/go-utils.sh" +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [core-version] [framework-version]" + exit 1 +fi + +PLUGIN_NAME="$1" + +# Get core version from parameter or latest tag +if [ -n "${2:-}" ]; then + CORE_VERSION="$2" +else + # Get latest core version from git tags + LATEST_CORE_TAG=$(git tag -l "core/v*" | sort -V | tail -1) + if [ -z "$LATEST_CORE_TAG" ]; then + echo "❌ No core tags found, using version from file" + CORE_VERSION="v$(tr -d '\n\r' < core/version)" + else + CORE_VERSION=${LATEST_CORE_TAG#core/} + fi +fi + +# Get framework version from parameter or latest tag +if [ -n "${3:-}" ]; then + FRAMEWORK_VERSION="$3" +else + # Get latest framework version from git tags + LATEST_FRAMEWORK_TAG=$(git tag -l "framework/v*" | sort -V | tail -1) + if [ -z "$LATEST_FRAMEWORK_TAG" ]; then + echo "❌ No framework tags found, using version from file" + FRAMEWORK_VERSION="v$(tr -d '\n\r' < framework/version)" + else + FRAMEWORK_VERSION=${LATEST_FRAMEWORK_TAG#framework/} + fi +fi + +# Ensure we have the latest version +git pull origin + +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 ./... + + # Run tests if any exist + if go list ./... | grep -q .; then + echo "πŸ§ͺ Running plugin tests..." + # go test -v -run . + fi + + 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-pipeline" + 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 '^' || 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}" diff --git a/.github/workflows/scripts/revert-latest.sh b/.github/workflows/scripts/revert-latest.sh new file mode 100755 index 000000000..0f6d3c33b --- /dev/null +++ b/.github/workflows/scripts/revert-latest.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Overwrite latest with a specific version from R2 +# Usage: ./revert-latest.sh + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 (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" diff --git a/.github/workflows/scripts/run-tests.sh b/.github/workflows/scripts/run-tests.sh new file mode 100755 index 000000000..430280417 --- /dev/null +++ b/.github/workflows/scripts/run-tests.sh @@ -0,0 +1,140 @@ +#!/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/4 - 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. Core Provider Tests +echo "" +echo "πŸ”§ 2/4 - Running Core Provider Tests..." +echo "-----------------------------------" +cd tests/core-providers +if go test -v -run .; then + report_result "Core Provider Tests" 0 +else + report_result "Core Provider Tests" 1 +fi +cd ../.. + +# 3. Governance Tests +echo "" +echo "πŸ›‘οΈ 3/4 - 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 + +# 4. Integration Tests +echo "" +echo "πŸ”— 4/4 - 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 + diff --git a/.github/workflows/scripts/upload-to-r2.sh b/.github/workflows/scripts/upload-to-r2.sh new file mode 100755 index 000000000..b89fed3c8 --- /dev/null +++ b/.github/workflows/scripts/upload-to-r2.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Upload builds to R2 with retry logic +# Usage: ./upload-to-r2.sh + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 (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 +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_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/latest/"; then + exit 1 + fi +fi + +echo "πŸŽ‰ All binaries uploaded successfully to R2" diff --git a/.github/workflows/scripts/verify-bifrost-http-release.sh b/.github/workflows/scripts/verify-bifrost-http-release.sh new file mode 100755 index 000000000..63c9a1a10 --- /dev/null +++ b/.github/workflows/scripts/verify-bifrost-http-release.sh @@ -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..." + diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml new file mode 100644 index 000000000..db16d0aa9 --- /dev/null +++ b/.github/workflows/snyk.yml @@ -0,0 +1,103 @@ +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: Checkout + uses: actions/checkout@v4 + + - name: Setup Node (for UI) + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Python (for tests tooling) + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Install Snyk CLI + uses: snyk/actions/setup@master + + - name: Snyk test (all projects) + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --all-projects --detection-depth=4 --sarif-file-output=snyk.sarif || true + + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: snyk.sarif + + snyk-code: + name: Snyk Code (SAST) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node (for UI) + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Python (for tests tooling) + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup Python (for tests tooling) + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: | + tests/integrations/requirements.txt + tests/governance/requirements.txt + + - name: Install Python dependencies (tests tooling) + run: | + python -m pip install --disable-pip-version-check \ + -r tests/integrations/requirements.txt \ + -r tests/governance/requirements.txt + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build + run: make build + + - name: Install Snyk CLI + uses: snyk/actions/setup@master + + - 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() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: snyk-code.sarif diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml new file mode 100644 index 000000000..0ca072c0b --- /dev/null +++ b/.github/workflows/test-coverage.yml @@ -0,0 +1,112 @@ +name: Run tests and upload coverage + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +jobs: + test: + name: Run tests and collect coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'ui/package-lock.json' + + - name: Install dependencies + run: | + for dir in core framework transports tests/core-chatbot; do + if [ -f "$dir/go.mod" ]; then + echo "Installing dependencies for $dir..." + (cd "$dir" && go mod download) + fi + done + # Install dependencies for core test modules (adds coverage to core) + if [ -f "tests/core-providers/go.mod" ]; then + echo "Installing dependencies for tests/core-providers (core test coverage)..." + (cd tests/core-providers && go mod download) + fi + + - name: Fix core-chatbot dependencies + run: | + echo "Running go mod tidy for core-chatbot..." + (cd tests/core-chatbot && go mod tidy) + + - name: Build UI + run: | + echo "Building UI for embedding in transport..." + cd ui + npm ci + npm run build + npm run copy-build + + - name: Start services for integration tests + run: | + echo "Starting Redis and Weaviate for vector store tests..." + cd framework + docker-compose up -d + # Wait for services to be healthy + echo "Waiting for services to be ready..." + timeout 60 bash -c 'until docker-compose ps | grep -q "healthy"; do sleep 2; done' || true + sleep 5 + + - name: Rebuild plugins + run: | + echo "Rebuilding example plugins..." + if [ -d "examples/plugins/hello-world" ]; then + cd examples/plugins/hello-world + # Clean old build + rm -rf build + mkdir -p build + # Rebuild plugin with current dependencies + go build -buildmode=plugin -o build/hello-world.so main.go || echo "Plugin build failed, tests will skip" + fi + + - name: Run tests + run: | + # Run tests for each module and combine coverage + for dir in core framework transports tests/core-chatbot; do + if [ -f "$dir/go.mod" ]; then + echo "Running tests for $dir..." + dirname=$(echo $dir | sed 's/\//-/g') + (cd "$dir" && go test -coverprofile=../coverage-$dirname.txt -coverpkg=github.com/maximhq/bifrost/core/...,github.com/maximhq/bifrost/framework/...,github.com/maximhq/bifrost/transports/... ./... || true) + fi + done + + # Run core test modules (adds coverage to core, not separate modules) + echo "Running tests/core-providers (adds coverage to core)..." + (cd tests/core-providers && GOWORK=off go test -coverprofile=../../coverage-core-providers.txt -coverpkg=github.com/maximhq/bifrost/core/...,github.com/maximhq/bifrost/framework/...,github.com/maximhq/bifrost/transports/... . || true) + + # Combine coverage files + echo "mode: atomic" > coverage.txt + grep -h -v "^mode:" coverage-*.txt >> coverage.txt 2>/dev/null || true + + - name: Stop services + if: always() + run: | + echo "Stopping docker services..." + cd framework + docker-compose down || true + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: maximhq/bifrost + diff --git a/.gitignore b/.gitignore index 48303bc0a..558bd838e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,43 @@ .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 +**/tmp/ +temp*/ +tmp/ +tmp-* +private + +# Go workspaces (local only) +go.work +go.work.sum + +# Sqlite DBs +*.db +*.db-shm +*.db-wal + +# Test reports +test-reports + +.claude \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..4da40ee34 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,25 @@ +{ + "root": true, + "printWidth": 140, + "singleQuote": false, + "bracketSpacing": true, + "semi": true, + "bracketSameLine": false, + "useTabs": true, + "tabWidth": 2, + "trailingComma": "all", + "plugins": [ + "prettier-plugin-tailwindcss" + ], + "pluginSearchDirs": [ + "./ui" + ], + "tailwindAttributes": [ + "buttonClassname" + ], + "tailwindFunctions": [ + "cn", + "classNames" + ], + "endOfLine": "lf" +} \ No newline at end of file diff --git a/.snyk b/.snyk new file mode 100644 index 000000000..96a414bcc --- /dev/null +++ b/.snyk @@ -0,0 +1,5 @@ +# Snyk (https://snyk.io) policy file +# Manages vulnerability ignores and patches for this repository. +version: v1.25.0 +ignore: {} +patch: {} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..182c6513e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..ccea51bb6 --- /dev/null +++ b/Makefile @@ -0,0 +1,478 @@ +# Makefile for Bifrost + +# Variables +HOST ?= localhost +PORT ?= 8080 +APP_DIR ?= +PROMETHEUS_LABELS ?= +LOG_STYLE ?= json +LOG_LEVEL ?= info +TEST_REPORTS_DIR ?= test-reports +GOTESTSUM_FORMAT ?= testname +LOCAL ?= + +# Colors for output +RED=\033[0;31m +GREEN=\033[0;32m +YELLOW=\033[1;33m +BLUE=\033[0;34m +CYAN=\033[0;36m +NC=\033[0m # No Color + +# Include deployment recipes +include recipes/fly.mk +include recipes/ecs.mk + +.PHONY: all help dev build-ui build run install-air clean test install-ui setup-workspace work-init work-clean docs build-docker-image cleanup-enterprise + +all: help + +# Default target +help: ## Show this help message + @echo "$(BLUE)Bifrost Development - Available Commands:$(NC)" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}' + @echo "" + @echo "$(YELLOW)Environment Variables:$(NC)" + @echo " HOST Server host (default: localhost)" + @echo " PORT Server port (default: 8080)" + @echo " PROMETHEUS_LABELS Labels for Prometheus metrics" + @echo " LOG_STYLE Logger output format: json|pretty (default: json)" + @echo " LOG_LEVEL Logger level: debug|info|warn|error (default: info)" + @echo " APP_DIR App data directory inside container (default: /app/data)" + @echo " LOCAL Use local go.work for builds (e.g., make build LOCAL=1)" + @echo "" + @echo "$(YELLOW)Test Configuration:$(NC)" + @echo " TEST_REPORTS_DIR Directory for HTML test reports (default: test-reports)" + @echo " GOTESTSUM_FORMAT Test output format: testname|dots|pkgname|standard-verbose (default: testname)" + +cleanup-enterprise: ## Clean up enterprise directories if present + @echo "$(GREEN)Cleaning up enterprise...$(NC)" + @if [ -d "ui/app/enterprise" ]; then rm -rf ui/app/enterprise; fi + @echo "$(GREEN)Enterprise cleaned up$(NC)" + +install-ui: cleanup-enterprise + @which node > /dev/null || (echo "$(RED)Error: Node.js is not installed. Please install Node.js first.$(NC)" && exit 1) + @which npm > /dev/null || (echo "$(RED)Error: npm is not installed. Please install npm first.$(NC)" && exit 1) + @echo "$(GREEN)Node.js and npm are installed$(NC)" + @cd ui && npm install + @which next > /dev/null || (echo "$(YELLOW)Installing nextjs...$(NC)" && npm install -g next) + @echo "$(GREEN)UI deps are in sync$(NC)" + +install-air: ## Install air for hot reloading (if not already installed) + @which air > /dev/null || (echo "$(YELLOW)Installing air for hot reloading...$(NC)" && go install github.com/air-verse/air@latest) + @echo "$(GREEN)Air is ready$(NC)" + +install-gotestsum: ## Install gotestsum for test reporting (if not already installed) + @which gotestsum > /dev/null || (echo "$(YELLOW)Installing gotestsum for test reporting...$(NC)" && go install gotest.tools/gotestsum@latest) + @echo "$(GREEN)gotestsum is ready$(NC)" + +install-junit-viewer: ## Install junit-viewer for HTML report generation (if not already installed) + @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + if which junit-viewer > /dev/null 2>&1; then \ + echo "$(GREEN)junit-viewer is already installed$(NC)"; \ + else \ + echo "$(YELLOW)Installing junit-viewer for HTML reports...$(NC)"; \ + if npm install -g junit-viewer 2>&1; then \ + echo "$(GREEN)junit-viewer installed successfully$(NC)"; \ + else \ + echo "$(RED)Failed to install junit-viewer. HTML reports will be skipped.$(NC)"; \ + echo "$(YELLOW)You can install it manually: npm install -g junit-viewer$(NC)"; \ + exit 0; \ + fi; \ + fi \ + else \ + echo "$(YELLOW)CI environment detected, skipping junit-viewer installation$(NC)"; \ + fi + +dev: install-ui install-air setup-workspace ## Start complete development environment (UI + API with proxy) + @echo "$(GREEN)Starting Bifrost complete development environment...$(NC)" + @echo "$(YELLOW)This will start:$(NC)" + @echo " 1. UI development server (localhost:3000)" + @echo " 2. API server with UI proxy (localhost:$(PORT))" + @echo "$(CYAN)Access everything at: http://localhost:$(PORT)$(NC)" + @echo "" + @echo "$(YELLOW)Starting UI development server...$(NC)" + @cd ui && npm run dev & + @sleep 3 + @echo "$(YELLOW)Starting API server with UI proxy...$(NC)" + @$(MAKE) setup-workspace >/dev/null + @cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.toml -- \ + -host "$(HOST)" \ + -port "$(PORT)" \ + -log-style "$(LOG_STYLE)" \ + -log-level "$(LOG_LEVEL)" \ + $(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") \ + $(if $(APP_DIR),-app-dir "$(APP_DIR)") + +build-ui: install-ui ## Build ui + @echo "$(GREEN)Building ui...$(NC)" + @rm -rf ui/.next + @cd ui && npm run build && npm run copy-build + +build: ## Build bifrost-http binary + @if [ -n "$(LOCAL)" ]; then \ + echo "$(GREEN)╔═══════════════════════════════════════════════╗$(NC)"; \ + echo "$(GREEN)β•‘ Building bifrost-http with local go.work... β•‘$(NC)"; \ + echo "$(GREEN)β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•$(NC)"; \ + else \ + echo "$(GREEN)╔═══════════════════════════════════════╗$(NC)"; \ + echo "$(GREEN)β•‘ Building bifrost-http... β•‘$(NC)"; \ + echo "$(GREEN)β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•$(NC)"; \ + fi + @$(MAKE) build-ui + @cd transports/bifrost-http && $(if $(LOCAL),,GOWORK=off) go build -o ../../tmp/bifrost-http . + @echo "$(GREEN)Built: tmp/bifrost-http$(NC)" + +build-docker-image: build-ui ## Build Docker image + @echo "$(GREEN)Building Docker image...$(NC)" + $(eval GIT_SHA=$(shell git rev-parse --short HEAD)) + @docker build -f transports/Dockerfile -t bifrost -t bifrost:$(GIT_SHA) -t bifrost:latest . + @echo "$(GREEN)Docker image built: bifrost, bifrost:$(GIT_SHA), bifrost:latest$(NC)" + +docker-run: ## Run Docker container + @echo "$(GREEN)Running Docker container...$(NC)" + @docker run -e APP_PORT=$(PORT) -e APP_HOST=0.0.0.0 -p $(PORT):$(PORT) -e LOG_LEVEL=$(LOG_LEVEL) -e LOG_STYLE=$(LOG_STYLE) -v $(shell pwd):/app/data bifrost + +docs: ## Prepare local docs + @echo "$(GREEN)Preparing local docs...$(NC)" + @cd docs && npx --yes mintlify@latest dev + +run: build ## Build and run bifrost-http (no hot reload) + @echo "$(GREEN)Running bifrost-http...$(NC)" + @./tmp/bifrost-http \ + -host "$(HOST)" \ + -port "$(PORT)" \ + -log-style "$(LOG_STYLE)" \ + -log-level "$(LOG_LEVEL)" \ + $(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") + $(if $(APP_DIR),-app-dir "$(APP_DIR)") + +clean: ## Clean build artifacts and temporary files + @echo "$(YELLOW)Cleaning build artifacts...$(NC)" + @rm -rf tmp/ + @rm -f transports/bifrost-http/build-errors.log + @rm -rf transports/bifrost-http/tmp/ + @rm -rf $(TEST_REPORTS_DIR)/ + @echo "$(GREEN)Clean complete$(NC)" + +clean-test-reports: ## Clean test reports only + @echo "$(YELLOW)Cleaning test reports...$(NC)" + @rm -rf $(TEST_REPORTS_DIR)/ + @echo "$(GREEN)Test reports cleaned$(NC)" + +generate-html-reports: ## Convert existing XML reports to HTML + @if ! which junit-viewer > /dev/null 2>&1; then \ + echo "$(RED)Error: junit-viewer not installed$(NC)"; \ + echo "$(YELLOW)Install with: make install-junit-viewer$(NC)"; \ + exit 1; \ + fi + @echo "$(GREEN)Converting XML reports to HTML...$(NC)" + @if [ ! -d "$(TEST_REPORTS_DIR)" ] || [ -z "$$(ls -A $(TEST_REPORTS_DIR)/*.xml 2>/dev/null)" ]; then \ + echo "$(YELLOW)No XML reports found in $(TEST_REPORTS_DIR)$(NC)"; \ + echo "$(YELLOW)Run tests first: make test-all$(NC)"; \ + exit 0; \ + fi + @for xml in $(TEST_REPORTS_DIR)/*.xml; do \ + html=$${xml%.xml}.html; \ + echo " Converting $$(basename $$xml) β†’ $$(basename $$html)"; \ + junit-viewer --results=$$xml --save=$$html 2>/dev/null || true; \ + done + @echo "" + @echo "$(GREEN)βœ“ HTML reports generated$(NC)" + @echo "$(CYAN)View reports:$(NC)" + @ls -1 $(TEST_REPORTS_DIR)/*.html 2>/dev/null | sed 's|$(TEST_REPORTS_DIR)/| open $(TEST_REPORTS_DIR)/|' || true + +test: install-gotestsum ## Run tests for bifrost-http + @echo "$(GREEN)Running bifrost-http tests...$(NC)" + @mkdir -p $(TEST_REPORTS_DIR) + @cd transports/bifrost-http && GOWORK=off gotestsum \ + --format=$(GOTESTSUM_FORMAT) \ + --junitfile=../../$(TEST_REPORTS_DIR)/bifrost-http.xml \ + -- -v ./... + @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + if which junit-viewer > /dev/null 2>&1; then \ + echo "$(YELLOW)Generating HTML report...$(NC)"; \ + if junit-viewer --results=$(TEST_REPORTS_DIR)/bifrost-http.xml --save=$(TEST_REPORTS_DIR)/bifrost-http.html 2>/dev/null; then \ + echo ""; \ + echo "$(CYAN)HTML report: $(TEST_REPORTS_DIR)/bifrost-http.html$(NC)"; \ + echo "$(CYAN)Open with: open $(TEST_REPORTS_DIR)/bifrost-http.html$(NC)"; \ + else \ + echo "$(YELLOW)HTML generation failed. JUnit XML report available.$(NC)"; \ + echo "$(CYAN)JUnit XML report: $(TEST_REPORTS_DIR)/bifrost-http.xml$(NC)"; \ + fi; \ + else \ + echo ""; \ + echo "$(YELLOW)junit-viewer not installed. Install with: make install-junit-viewer$(NC)"; \ + echo "$(CYAN)JUnit XML report: $(TEST_REPORTS_DIR)/bifrost-http.xml$(NC)"; \ + fi \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $(TEST_REPORTS_DIR)/bifrost-http.xml$(NC)"; \ + fi + +test-core: install-gotestsum ## Run core tests (Usage: make test-core PROVIDER=openai TESTCASE=SpeechSynthesisStreamAdvanced/MultipleVoices_Streaming/StreamingVoice_echo) + @echo "$(GREEN)Running core tests...$(NC)" + @mkdir -p $(TEST_REPORTS_DIR) + @TEST_FAILED=0; \ + REPORT_FILE=""; \ + if [ -n "$(PROVIDER)" ]; then \ + echo "$(CYAN)Running tests for provider: $(PROVIDER)$(NC)"; \ + if [ ! -f "tests/core-providers/$(PROVIDER)_test.go" ]; then \ + echo "$(RED)Error: Provider test file '$(PROVIDER)_test.go' not found$(NC)"; \ + echo "$(YELLOW)Available providers:$(NC)"; \ + ls tests/core-providers/*_test.go 2>/dev/null | grep -v cross_provider | xargs -n 1 basename | sed 's/_test\.go//' | sed 's/^/ - /'; \ + exit 1; \ + fi; \ + fi; \ + if [ -f .env ]; then \ + echo "$(YELLOW)Loading environment variables from .env...$(NC)"; \ + set -a; . ./.env; set +a; \ + fi; \ + if [ -n "$(PROVIDER)" ]; then \ + PROVIDER_TEST_NAME=$$(echo "$(PROVIDER)" | awk '{print toupper(substr($$0,1,1)) tolower(substr($$0,2))}' | sed 's/openai/OpenAI/i; s/sgl/SGL/i'); \ + if [ -n "$(TESTCASE)" ]; then \ + CLEAN_TESTCASE="$(TESTCASE)"; \ + CLEAN_TESTCASE=$${CLEAN_TESTCASE#Test$${PROVIDER_TEST_NAME}/}; \ + CLEAN_TESTCASE=$${CLEAN_TESTCASE#$${PROVIDER_TEST_NAME}Tests/}; \ + CLEAN_TESTCASE=$$(echo "$$CLEAN_TESTCASE" | sed 's|^Test[A-Z][A-Za-z]*/[A-Z][A-Za-z]*Tests/||'); \ + echo "$(CYAN)Running Test$${PROVIDER_TEST_NAME}/$${PROVIDER_TEST_NAME}Tests/$$CLEAN_TESTCASE...$(NC)"; \ + REPORT_FILE="$(TEST_REPORTS_DIR)/core-$(PROVIDER)-$$(echo $$CLEAN_TESTCASE | sed 's|/|_|g').xml"; \ + cd tests/core-providers && GOWORK=off gotestsum \ + --format=$(GOTESTSUM_FORMAT) \ + --junitfile=../../$$REPORT_FILE \ + -- -v -run "^Test$${PROVIDER_TEST_NAME}$$/.*Tests/$$CLEAN_TESTCASE$$" || TEST_FAILED=1; \ + cd ../..; \ + $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ + if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + if which junit-viewer > /dev/null 2>&1; then \ + echo "$(YELLOW)Generating HTML report...$(NC)"; \ + junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ + echo ""; \ + echo "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ + echo "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ + fi; \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ + fi; \ + else \ + echo "$(CYAN)Running Test$${PROVIDER_TEST_NAME}...$(NC)"; \ + REPORT_FILE="$(TEST_REPORTS_DIR)/core-$(PROVIDER).xml"; \ + cd tests/core-providers && GOWORK=off gotestsum \ + --format=$(GOTESTSUM_FORMAT) \ + --junitfile=../../$$REPORT_FILE \ + -- -v -run "^Test$${PROVIDER_TEST_NAME}$$" || TEST_FAILED=1; \ + cd ../..; \ + $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ + if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + if which junit-viewer > /dev/null 2>&1; then \ + echo "$(YELLOW)Generating HTML report...$(NC)"; \ + junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ + echo ""; \ + echo "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ + echo "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ + fi; \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ + fi; \ + fi \ + else \ + if [ -n "$(TESTCASE)" ]; then \ + echo "$(RED)Error: TESTCASE requires PROVIDER to be specified$(NC)"; \ + echo "$(YELLOW)Usage: make test-core PROVIDER=openai TESTCASE=SpeechSynthesisStreamAdvanced/MultipleVoices_Streaming/StreamingVoice_echo$(NC)"; \ + exit 1; \ + fi; \ + REPORT_FILE="$(TEST_REPORTS_DIR)/core-all.xml"; \ + cd tests/core-providers && GOWORK=off gotestsum \ + --format=$(GOTESTSUM_FORMAT) \ + --junitfile=../../$$REPORT_FILE \ + -- -v ./... || TEST_FAILED=1; \ + cd ../..; \ + $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ + if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + if which junit-viewer > /dev/null 2>&1; then \ + echo "$(YELLOW)Generating HTML report...$(NC)"; \ + junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ + echo ""; \ + echo "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ + echo "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ + fi; \ + else \ + echo ""; \ + echo "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ + fi; \ + fi; \ + if [ -f "$$REPORT_FILE" ]; then \ + ALL_FAILED=$$(grep -B 1 '/dev/null | \ + grep '/dev/null | \ + grep ']*name="'"$$ESCAPED"'"[^>]*>.*?//gs' "$(REPORT_FILE).tmp" 2>/dev/null || true; \ + fi; \ + done; \ + if [ -f "$(REPORT_FILE).tmp" ]; then \ + mv "$(REPORT_FILE).tmp" "$(REPORT_FILE)"; \ + fi; \ + fi; \ + fi + +test-plugins: install-gotestsum ## Run plugin tests + @echo "$(GREEN)Running plugin tests...$(NC)" + @mkdir -p $(TEST_REPORTS_DIR) + @cd plugins && find . -name "*.go" -path "*/tests/*" -o -name "*_test.go" | head -1 > /dev/null && \ + for dir in $$(find . -name "*_test.go" -exec dirname {} \; | sort -u); do \ + plugin_name=$$(echo $$dir | sed 's|^\./||' | sed 's|/|-|g'); \ + echo "Testing $$dir..."; \ + cd $$dir && gotestsum \ + --format=$(GOTESTSUM_FORMAT) \ + --junitfile=../../$(TEST_REPORTS_DIR)/plugin-$$plugin_name.xml \ + -- -v ./... && cd - > /dev/null; \ + if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + if which junit-viewer > /dev/null 2>&1; then \ + echo "$(YELLOW)Generating HTML report for $$plugin_name...$(NC)"; \ + junit-viewer --results=../$(TEST_REPORTS_DIR)/plugin-$$plugin_name.xml --save=../$(TEST_REPORTS_DIR)/plugin-$$plugin_name.html 2>/dev/null || true; \ + fi; \ + fi; \ + done || echo "No plugin tests found" + @echo "" + @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + echo "$(CYAN)HTML reports saved to $(TEST_REPORTS_DIR)/plugin-*.html$(NC)"; \ + else \ + echo "$(CYAN)JUnit XML reports saved to $(TEST_REPORTS_DIR)/plugin-*.xml$(NC)"; \ + fi + +test-all: test-core test-plugins test ## Run all tests + @echo "" + @echo "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" + @echo "$(GREEN) All Tests Complete - Summary $(NC)" + @echo "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" + @echo "" + @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ + echo "$(YELLOW)Generating combined HTML report...$(NC)"; \ + junit-viewer --results=$(TEST_REPORTS_DIR) --save=$(TEST_REPORTS_DIR)/index.html 2>/dev/null || true; \ + echo ""; \ + echo "$(CYAN)HTML reports available in $(TEST_REPORTS_DIR)/:$(NC)"; \ + ls -1 $(TEST_REPORTS_DIR)/*.html 2>/dev/null | sed 's/^/ βœ“ /' || echo " No reports found"; \ + echo ""; \ + echo "$(YELLOW)πŸ“Š View all test results:$(NC)"; \ + echo "$(CYAN) open $(TEST_REPORTS_DIR)/index.html$(NC)"; \ + echo ""; \ + echo "$(YELLOW)Or view individual reports:$(NC)"; \ + ls -1 $(TEST_REPORTS_DIR)/*.html 2>/dev/null | grep -v index.html | sed 's|$(TEST_REPORTS_DIR)/| open $(TEST_REPORTS_DIR)/|' || true; \ + echo ""; \ + else \ + echo "$(CYAN)JUnit XML reports available in $(TEST_REPORTS_DIR)/:$(NC)"; \ + ls -1 $(TEST_REPORTS_DIR)/*.xml 2>/dev/null | sed 's/^/ βœ“ /' || echo " No reports found"; \ + echo ""; \ + fi + +# Quick start with example config +quick-start: ## Quick start with example config and maxim plugin + @echo "$(GREEN)Quick starting Bifrost with example configuration...$(NC)" + @$(MAKE) dev + +# Linting and formatting +lint: ## Run linter for Go code + @echo "$(GREEN)Running golangci-lint...$(NC)" + @golangci-lint run ./... + +fmt: ## Format Go code + @echo "$(GREEN)Formatting Go code...$(NC)" + @gofmt -s -w . + @goimports -w . + +# Workspace helpers +setup-workspace: ## Set up Go workspace with all local modules for development + @echo "$(GREEN)Setting up Go workspace for local development...$(NC)" + @echo "$(YELLOW)Cleaning existing workspace...$(NC)" + @rm -f go.work go.work.sum || true + @echo "$(YELLOW)Initializing new workspace...$(NC)" + @go work init ./core ./framework ./transports + @echo "$(YELLOW)Adding plugin modules...$(NC)" + @for plugin_dir in ./plugins/*/; do \ + if [ -d "$$plugin_dir" ] && [ -f "$$plugin_dir/go.mod" ]; then \ + echo " Adding plugin: $$(basename $$plugin_dir)"; \ + go work use "$$plugin_dir"; \ + fi; \ + done + @echo "$(YELLOW)Syncing workspace...$(NC)" + @go work sync + @echo "$(GREEN)βœ“ Go workspace ready with all local modules$(NC)" + @echo "" + @echo "$(CYAN)Local modules in workspace:$(NC)" + @go list -m all | grep "github.com/maximhq/bifrost" | grep -v " v" | sed 's/^/ βœ“ /' + @echo "" + @echo "$(CYAN)Remote modules (no local version):$(NC)" + @go list -m all | grep "github.com/maximhq/bifrost" | grep " v" | sed 's/^/ β†’ /' + @echo "" + @echo "$(YELLOW)Note: go.work files are not committed to version control$(NC)" + +work-init: ## Create local go.work to use local modules for development (legacy) + @echo "$(YELLOW)⚠️ work-init is deprecated, use 'make setup-workspace' instead$(NC)" + @$(MAKE) setup-workspace + +work-clean: ## Remove local go.work + @rm -f go.work go.work.sum || true + @echo "$(GREEN)Removed local go.work files$(NC)" diff --git a/README.md b/README.md index e227739c0..dfefcbfcf 100644 --- a/README.md +++ b/README.md @@ -1,399 +1,264 @@ # Bifrost -Bifrost is an open-source middleware that serves as a unified gateway to various AI model providers, enabling seamless integration and fallback mechanisms for your AI-powered applications. - -## πŸ“‘ Table of Contents - -- [Bifrost](#bifrost) - - [πŸ“‘ Table of Contents](#-table-of-contents) - - [πŸ” Overview](#-overview) - - [✨ Features](#-features) - - [πŸ—οΈ Repository Structure](#️-repository-structure) - - [πŸ“Š Benchmarks](#-benchmarks) - - [Test Environment](#test-environment) - - [t3.medium Instance](#t3medium-instance) - - [t3.xlarge Instance](#t3xlarge-instance) - - [Performance Metrics](#performance-metrics) - - [Key Performance Highlights](#key-performance-highlights) - - [πŸš€ Getting Started](#-getting-started) - - [Package Structure](#package-structure) - - [Prerequisites](#prerequisites) - - [Setting up Bifrost](#setting-up-bifrost) - - [Additional Configurations](#additional-configurations) - - [🀝 Contributing](#-contributing) - - [πŸ“„ License](#-license) +[![Go Report Card](https://goreportcard.com/badge/github.com/maximhq/bifrost/core)](https://goreportcard.com/report/github.com/maximhq/bifrost/core) +[![Discord badge](https://dcbadge.limes.pink/api/server/https://discord.gg/exN5KAydbU?style=flat)](https://discord.gg/exN5KAydbU) +[![Known Vulnerabilities](https://snyk.io/test/github/maximhq/bifrost/badge.svg)](https://snyk.io/test/github/maximhq/bifrost) +[![codecov](https://codecov.io/gh/maximhq/bifrost/branch/main/graph/badge.svg)](https://codecov.io/gh/maximhq/bifrost) +![Docker Pulls](https://img.shields.io/docker/pulls/maximhq/bifrost) +[Run In Postman](https://app.getpostman.com/run-collection/31642484-2ba0e658-4dcd-49f4-845a-0c7ed745b916?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D31642484-2ba0e658-4dcd-49f4-845a-0c7ed745b916%26entityType%3Dcollection%26workspaceId%3D63e853c8-9aec-477f-909c-7f02f543150e) +[![License](https://img.shields.io/github/license/maximhq/bifrost)](LICENSE) ---- +## The fastest way to build AI applications that never go down -## πŸ” Overview +Bifrost is a high-performance AI gateway that unifies access to 15+ providers (OpenAI, Anthropic, AWS Bedrock, Google Vertex, and more) through a single OpenAI-compatible API. Deploy in seconds with zero configuration and get automatic failover, load balancing, semantic caching, and enterprise-grade features. -Bifrost acts as a bridge between your applications and multiple AI providers (OpenAI, Anthropic, Amazon Bedrock, etc.). It provides a consistent API interface while handling: +## Quick Start -- Authentication and key management -- Request routing and load balancing -- Fallback mechanisms for reliability -- Unified request and response formatting -- Connection pooling and concurrency control +![Get started](./docs/media/getting-started.png) -With Bifrost, you can focus on building your AI-powered applications without worrying about the underlying provider-specific implementations. It handles all the complexities of key and provider management, providing a fixed input and output format so you don't need to modify your codebase for different providers. +**Go from zero to production-ready AI gateway in under a minute.** ---- +**Step 1:** Start Bifrost Gateway -## ✨ Features +```bash +# Install and run locally +npx -y @maximhq/bifrost -- **Multi-Provider Support**: Integrate with OpenAI, Anthropic, Amazon Bedrock, and more through a single API -- **Fallback Mechanisms**: Automatically retry failed requests with alternative models or providers -- **Dynamic Key Management**: Rotate and manage API keys efficiently -- **Connection Pooling**: Optimize network resources for better performance -- **Concurrency Control**: Manage rate limits and parallel requests effectively -- **HTTP Transport**: RESTful API interface for easy integration -- **Custom Configuration**: Flexible JSON-based configuration +# Or use Docker +docker run -p 8080:8080 maximhq/bifrost +``` ---- +**Step 2:** Configure via Web UI -## πŸ—οΈ Repository Structure +```bash +# Open the built-in web interface +open http://localhost:8080 +``` -Bifrost is built with a modular architecture: +**Step 3:** Make your first API call -``` -bifrost/ -β”œβ”€β”€ core/ # Core functionality and shared components -β”‚ β”œβ”€β”€ providers/ # Provider-specific implementations -β”‚ β”œβ”€β”€ schemas/ # Interfaces and structs used in bifrost -β”‚ β”œβ”€β”€ tests/ # Tests to make sure everything is in place -β”‚ β”œβ”€β”€ bifrost.go # Main Bifrost implementation -β”‚ -β”œβ”€β”€ transports/ # Interface layers (HTTP, gRPC, etc.) -β”‚ β”œβ”€β”€ http/ # HTTP transport implementation -β”‚ └── ... -β”‚ -└── plugins/ # Plugin Implementations - β”œβ”€β”€ maxim-logger.go - └── ... +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello, Bifrost!"}] + }' ``` -The system uses a provider-agnostic approach with well-defined interfaces to easily extend to new AI providers. All interfaces are defined in `core/schemas/` and can be used as a reference for adding new plugins. +**That's it!** Your AI gateway is running with a web interface for visual configuration, real-time monitoring, and analytics. + +**Complete Setup Guides:** + +- [Gateway Setup](https://docs.getbifrost.ai/quickstart/gateway/setting-up) - HTTP API deployment +- [Go SDK Setup](https://docs.getbifrost.ai/quickstart/go-sdk/setting-up) - Direct integration --- -## πŸ“Š Benchmarks - -Bifrost has been tested under high load conditions to ensure optimal performance. The following results were obtained from benchmark tests running at 5000 requests per second (RPS) on different AWS EC2 instances, with Bifrost running inside Docker containers. - -### Test Environment - -#### t3.medium Instance -- **Instance**: AWS EC2 t3.medium -- **vCPUs**: 2 -- **Memory**: 4GB RAM -- **Container**: Docker container with resource limits matching instance specs -- **Bifrost Configurations**: - - Buffer Size: 15,000 - - Initial Pool Size: 10,000 - -#### t3.xlarge Instance -- **Instance**: AWS EC2 t3.xlarge -- **vCPUs**: 4 -- **Memory**: 16GB RAM -- **Container**: Docker container with resource limits matching instance specs -- **Bifrost Configurations**: - - Buffer Size: 20,000 - - Initial Pool Size: 15,000 - -### Performance Metrics - -| Metric | t3.medium | t3.xlarge | -|--------|-----------|-----------| -| Success Rate | 100.00% | 100.00% | -| Average Request Size | 0.13 KB | 0.13 KB | -| **Average Response Size** | **`1.37 KB`** | **`10.32 KB`** | -| Average Latency | 2.12s | 1.61s | -| Peak Memory Usage | 1312.79 MB | 3340.44 MB | -| Queue Wait Time | 47.13 Β΅s | 1.67 Β΅s | -| Key Selection Time | 16 ns | 10 ns | -| Message Formatting | 2.19 Β΅s | 2.11 Β΅s | -| Params Preparation | 436 ns | 417 ns | -| Request Body Preparation | 2.65 Β΅s | 2.36 Β΅s | -| JSON Marshaling | 63.47 Β΅s | 26.80 Β΅s | -| Request Setup | 6.59 Β΅s | 7.17 Β΅s | -| HTTP Request | 1.56s | 1.50s | -| Error Handling | 189 ns | 162 ns | -| Response Parsing | 11.30 ms | 2.11 ms | - -### Key Performance Highlights - -- **Perfect Success Rate**: 100% request success rate under high load on both instances -- **Efficient Queue Management**: Minimal queue wait time (1.67 Β΅s on t3.xlarge) -- **Fast Key Selection**: Near-instantaneous key selection (10 ns on t3.xlarge) -- **Optimized Memory Usage**: - - t3.medium: ~1.3GB at 5000 RPS - - t3.xlarge: ~3.3GB at 5000 RPS -- **Efficient Request Processing**: Most operations complete in microseconds -- **Network Efficiency**: - - Consistent small request sizes (0.13 KB) across instances - - Larger response sizes on t3.xlarge (10.32 KB vs 1.37 KB) due to more detailed responses -- **Improved Performance on t3.xlarge**: - - 24% faster average latency - - 81% faster response parsing - - 58% faster JSON marshaling - - Significantly reduced queue wait times - - Higher buffer and pool sizes enabled by increased resources - -These benchmarks demonstrate Bifrost's ability to handle high-throughput scenarios while maintaining reliability and performance, even when containerized. The t3.xlarge instance shows improved performance across most metrics, particularly in processing times and latency, while maintaining the same high reliability and success rate. The larger response sizes on t3.xlarge indicate its ability to handle more detailed responses without compromising performance. - -One of Bifrost's key strengths is its flexibility in configuration. You can freely decide the tradeoff between memory usage and processing speed by adjusting Bifrost's configurations: - -- **Memory vs Speed Tradeoff**: - - Higher buffer and pool sizes (like in t3.xlarge) improve speed but use more memory - - Lower configurations (like in t3.medium) use less memory but may have slightly higher latencies - - You can fine-tune these parameters based on your specific needs and available resources - -- **Customizable Parameters**: - - Buffer Size: Controls the maximum number of concurrent requests - - Initial Pool Size: Determines the initial allocation of resources - - Concurrency Settings: Adjustable per provider - - Retry and Timeout Configurations: Customizable based on your requirements - -This flexibility allows you to optimize Bifrost for your specific use case, whether you prioritize speed, memory efficiency, or a balance between the two. +## Key Features + +### Core Infrastructure + +- **[Unified Interface](https://docs.getbifrost.ai/features/unified-interface)** - Single OpenAI-compatible API for all providers +- **[Multi-Provider Support](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)** - OpenAI, Anthropic, AWS Bedrock, Google Vertex, Azure, Cerebras, Cohere, Mistral, Ollama, Groq, and more +- **[Automatic Fallbacks](https://docs.getbifrost.ai/features/fallbacks)** - Seamless failover between providers and models with zero downtime +- **[Load Balancing](https://docs.getbifrost.ai/features/fallbacks)** - Intelligent request distribution across multiple API keys and providers + +### Advanced Features + +- **[Model Context Protocol (MCP)](https://docs.getbifrost.ai/features/mcp)** - Enable AI models to use external tools (filesystem, web search, databases) +- **[Semantic Caching](https://docs.getbifrost.ai/features/semantic-caching)** - Intelligent response caching based on semantic similarity to reduce costs and latency +- **[Multimodal Support](https://docs.getbifrost.ai/quickstart/gateway/streaming)** - Support for text,images, audio, and streaming, all behind a common interface. +- **[Custom Plugins](https://docs.getbifrost.ai/enterprise/custom-plugins)** - Extensible middleware architecture for analytics, monitoring, and custom logic +- **[Governance](https://docs.getbifrost.ai/features/governance)** - Usage tracking, rate limiting, and fine-grained access control + +### Enterprise & Security + +- **[Budget Management](https://docs.getbifrost.ai/features/governance)** - Hierarchical cost control with virtual keys, teams, and customer budgets +- **[SSO Integration](https://docs.getbifrost.ai/features/sso-with-google-github)** - Google and GitHub authentication support +- **[Observability](https://docs.getbifrost.ai/features/observability)** - Native Prometheus metrics, distributed tracing, and comprehensive logging +- **[Vault Support](https://docs.getbifrost.ai/enterprise/vault-support)** - Secure API key management with HashiCorp Vault integration + +### Developer Experience + +- **[Zero-Config Startup](https://docs.getbifrost.ai/quickstart/gateway/setting-up)** - Start immediately with dynamic provider configuration +- **[Drop-in Replacement](https://docs.getbifrost.ai/features/drop-in-replacement)** - Replace OpenAI/Anthropic/GenAI APIs with one line of code +- **[SDK Integrations](https://docs.getbifrost.ai/integrations/what-is-an-integration)** - Native support for popular AI SDKs with zero code changes +- **[Configuration Flexibility](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)** - Web UI, API-driven, or file-based configuration options --- -## πŸš€ Getting Started - -If you want to **set up the Bifrost API quickly**, [check the transports documentation](https://github.com/maximhq/bifrost/tree/main/transports/README.md). - -### Package Structure - -Bifrost is divided into three Go packages: core, plugins, and transports. - -1. **core**: This package contains the core implementation of Bifrost as a Go package. - -2. **plugins**: This package serves as an extension to core. You can download this package using `go get github.com/maximhq/bifrost/plugins` and pass the plugins while initializing Bifrost. - - ```golang - plugin, err := plugins.NewMaximLoggerPlugin(os.Getenv("MAXIM_API_KEY"), os.Getenv("MAXIM_LOGGER_ID")) - if err != nil { - return nil, err - } - - // Initialize Bifrost - client, err := bifrost.Init(schemas.BifrostConfig{ - Account: &account, - Plugins: []schemas.Plugin{plugin}, - }) - ``` - -3. **transports**: This package contains transport clients like HTTP to expose your Bifrost client. You can either `go get` this package or directly use the independent Dockerfile to quickly spin up your Bifrost API interface ([Click here](https://github.com/maximhq/bifrost/tree/main/transports/README.md) to read more on this). - -### Prerequisites - -- Go 1.23 or higher -- Access to at least one AI model provider (OpenAI, Anthropic, etc.) -- API keys for the providers you wish to use - -### Setting up Bifrost - -1. Setting up your account: You first need to create your account which follows [Bifrost's account interface](https://github.com/maximhq/bifrost/blob/main/core/schemas/account.go). - -Example: - ```golang - type BaseAccount struct{} - - func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { - return []schemas.ModelProvider{schemas.OpenAI}, nil - } - - func (baseAccount *BaseAccount) GetKeysForProvider(providerKey schemas.ModelProvider) ([]schemas.Key, error) { - switch providerKey { - case schemas.OpenAI: - return []schemas.Key{ - { - Value: os.Getenv("OPENAI_API_KEY"), - Models: []string{"gpt-4o-mini"}, - }, - }, nil - default: - return nil, fmt.Errorf("unsupported provider: %s", providerKey) - } - } - - func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { - switch providerKey { - case schemas.OpenAI: - return &schemas.ProviderConfig{ - ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ - Concurrency: 3, - BufferSize: 10, - }, - }, nil - default: - return nil, fmt.Errorf("unsupported provider: %s", providerKey) - } - } - ``` - -Bifrost uses these methods to get all the keys and configurations it needs to call the providers. You can check the [Additional Configurations](#additional-configurations) section for further customizations. - -2. Get bifrost core package: Simply run `go get github.com/maximhq/bifrost/core` to download bifrost/core package. - -3. Initialising Bifrost: Initialise bifrost by providing your account implementation - -```golang -client, err := bifrost.Init(schemas.BifrostConfig{ - Account: &yourAccount, -}) -``` +## Repository Structure -4. Make your First LLM Call! - -```golang - msg = "What is a LLM gateway?" - messages := []schemas.Message{ - { Role: schemas.RoleUser, Content: &msg }, - } - - bifrostResult, bifrostErr := bifrost.ChatCompletionRequest( - schemas.OpenAI, &schemas.BifrostRequest{ - Model: "gpt-4o", // make sure you have configured gpt-4o in your account interface - Input: schemas.RequestInput{ - ChatCompletionInput: &messages, - }, - }, context.Background() - ) +Bifrost uses a modular architecture for maximum flexibility: + +```text +bifrost/ +β”œβ”€β”€ npx/ # NPX script for easy installation +β”œβ”€β”€ core/ # Core functionality and shared components +β”‚ β”œβ”€β”€ providers/ # Provider-specific implementations (OpenAI, Anthropic, etc.) +β”‚ β”œβ”€β”€ schemas/ # Interfaces and structs used throughout Bifrost +β”‚ └── bifrost.go # Main Bifrost implementation +β”œβ”€β”€ framework/ # Framework components for data persistence +β”‚ β”œβ”€β”€ configstore/ # Configuration storages +β”‚ β”œβ”€β”€ logstore/ # Request logging storages +β”‚ └── vectorstore/ # Vector storages +β”œβ”€β”€ transports/ # HTTP gateway and other interface layers +β”‚ └── bifrost-http/ # HTTP transport implementation +β”œβ”€β”€ ui/ # Web interface for HTTP gateway +β”œβ”€β”€ plugins/ # Extensible plugin system +β”‚ β”œβ”€β”€ governance/ # Budget management and access control +β”‚ β”œβ”€β”€ jsonparser/ # JSON parsing and manipulation utilities +β”‚ β”œβ”€β”€ logging/ # Request logging and analytics +β”‚ β”œβ”€β”€ maxim/ # Maxim's observability integration +β”‚ β”œβ”€β”€ mocker/ # Mock responses for testing and development +β”‚ β”œβ”€β”€ semanticcache/ # Intelligent response caching +β”‚ └── telemetry/ # Monitoring and observability +β”œβ”€β”€ docs/ # Documentation and guides +└── tests/ # Comprehensive test suites ``` -you can add model parameters by passing them in `Params:&schemas.ModelParameters{...yourParams}` ChatCompletionRequest. +--- + +## Getting Started Options + +Choose the deployment method that fits your needs: -### Additional Configurations +### 1. Gateway (HTTP API) -1. InitalPoolSize and DropExcessRequests: You can customise the initial pool size of the structs and channels bifrost creates on `bifrost.Init()`. A higher value would mean lesser run time allocations and lower latency but at the cost of more memory usage. Takes the defined default value if not provided. +**Best for:** Language-agnostic integration, microservices, and production deployments -```golang - client, err := bifrost.Init(schemas.BifrostConfig{ - Account: &yourAccount, - InitialPoolSize: 500, - DropExcessRequests: true, - }) +```bash +# NPX - Get started in 30 seconds +npx -y @maximhq/bifrost + +# Docker - Production ready +docker run -p 8080:8080 -v $(pwd)/data:/app/data maximhq/bifrost ``` -When `DropExcessRequests` is set to true, in cases where the queue is full, requests will not wait for the queue to be empty and will be dropped instead. By default it is set to false. +**Features:** Web UI, real-time monitoring, multi-provider management, zero-config startup -2. Logger: Like account interface, bifrost also allows you to pass your custom logger if it follows [bifrost's logger interface](https://github.com/maximhq/bifrost/blob/main/core/schemas/logger.go). Takes in the [default logger](https://github.com/maximhq/bifrost/blob/main/core/logger.go) if not provided. +**Learn More:** [Gateway Setup Guide](https://docs.getbifrost.ai/quickstart/gateway/setting-up) -```golang - client, err := bifrost.Init(schemas.BifrostConfig{ - Account: &yourAccount, - Logger: &yourLogger, - }) -``` +### 2. Go SDK -The default logger is set to level info by default. If you wish to use it but with a different log level, you can do it like this - +**Best for:** Direct Go integration with maximum performance and control -```golang - client, err := bifrost.Init(schemas.BifrostConfig{ - Account: &yourAccount, - Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), - }) +```bash +go get github.com/maximhq/bifrost/core ``` -3. Plugins: You can create and pass your custom pre-hook and post-hook plugins to bifrost as long as they follow [bifrost's plugin interface](https://github.com/maximhq/bifrost/blob/main/core/schemas/plugin.go). +**Features:** Native Go APIs, embedded deployment, custom middleware integration -```golang - client, err := bifrost.Init(schemas.BifrostConfig{ - Account: &yourAccount, - Plugins: []schemas.Plugin{yourPlugin1, yourPlugin2, ...}, - }) -``` +**Learn More:** [Go SDK Guide](https://docs.getbifrost.ai/quickstart/go-sdk/setting-up) -4. Customise your provider settings: You can customise proxy config, timeouts, retry settings, concurrency buffer sizes for each of your provider in your account interface's GetConfigForProvider() method. - -exmaple: -```golang - schemas.ProviderConfig{ - NetworkConfig: schemas.NetworkConfig{ - DefaultRequestTimeoutInSeconds: 30, - MaxRetries: 2, - RetryBackoffInitial: 100 * time.Millisecond, - RetryBackoffMax: 2 * time.Second, - }, - MetaConfig: &meta.BedrockMetaConfig{ - SecretAccessKey: os.Getenv("BEDROCK_ACCESS_KEY"), - Region: StrPtr("us-east-1"), - }, - ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ - Concurrency: 3, - BufferSize: 10, - }, - ProxyConfig: &schemas.ProxyConfig{ - Type: schemas.HttpProxy, - URL: yourProxyURL, - }, - } -``` +### 3. Drop-in Replacement -You can manage buffer size (maximum number of requests you want to hold in the system) concurrency (maximum number of requests you want to be made concurrently) for each provider. You can manage user usage and provider limits by providing these custom provider settings Default values are taken for network config, concurrecy and buffer sizes if not provided. - -Bifrost also supports multiple API keys per provider, enabling both load balancing and redundancy. You can assign weights to each key to control how frequently they are selected for requests. By default, all keys are treated with equal weight unless specified otherwise. - -```golang - []schemas.Key{ - { - Value: os.Getenv("OPEN_AI_API_KEY1"), - Models: []string{"gpt-4o-mini", "gpt-4-turbo"}, - Weight: 0.6, - }, - { - Value: os.Getenv("OPEN_AI_API_KEY2"), - Models: []string{"gpt-4-turbo"}, - Weight: 0.3, - }, - { - Value: os.Getenv("OPEN_AI_API_KEY3"), - Models: []string{"gpt-4o-mini"}, - Weight: 0.1, - }, - } -``` +**Best for:** Migrating existing applications with zero code changes -You can check [this](https://github.com/maximhq/bifrost/blob/main/core/tests/account.go) file to refer all the customisation settings. - -5. Fallbacks: You can define fallback providers for each request, which will be used if all retry attempts with your primary provider fail. These fallback providers are attempted in the order you specify, provided they are configured in your account at runtime. Once a fallback is triggered, its own retry settings will apply, rather than those of the original provider. - -```golang - result, err := bifrost.ChatCompletionRequest( - schemas.OpenAI, &schemas.BifrostRequest{ - Model: "gpt-4o", - Input: schemas.RequestInput{ - ChatCompletionInput: &messages, - }, - Fallbacks: []schemas.Fallback{ - { - Provider: schemas.Anthropic, - Model: "claude-3-5-sonnet-20240620", // make sure you have configured this - }, - }, - }, context.Background() - ) +```diff +# OpenAI SDK +- base_url = "https://api.openai.com" ++ base_url = "http://localhost:8080/openai" + +# Anthropic SDK +- base_url = "https://api.anthropic.com" ++ base_url = "http://localhost:8080/anthropic" + +# Google GenAI SDK +- api_endpoint = "https://generativelanguage.googleapis.com" ++ api_endpoint = "http://localhost:8080/genai" ``` +**Learn More:** [Integration Guides](https://docs.getbifrost.ai/integrations/what-is-an-integration) + +--- + +## Performance + +Bifrost adds virtually zero overhead to your AI requests. In sustained 5,000 RPS benchmarks, the gateway added only **11 Β΅s** of overhead per request. + +| Metric | t3.medium | t3.xlarge | Improvement | +|--------|-----------|-----------|-------------| +| Added latency (Bifrost overhead) | 59 Β΅s | **11 Β΅s** | **-81%** | +| Success rate @ 5k RPS | 100% | 100% | No failed requests | +| Avg. queue wait time | 47 Β΅s | **1.67 Β΅s** | **-96%** | +| Avg. request latency (incl. provider) | 2.12 s | **1.61 s** | **-24%** | + +**Key Performance Highlights:** + +- **Perfect Success Rate** - 100% request success rate even at 5k RPS +- **Minimal Overhead** - Less than 15 Β΅s additional latency per request +- **Efficient Queuing** - Sub-microsecond average wait times +- **Fast Key Selection** - ~10 ns to pick weighted API keys + +**Complete Benchmarks:** [Performance Analysis](https://docs.getbifrost.ai/benchmarking/getting-started) + +--- + +## Documentation + +**Complete Documentation:** [https://docs.getbifrost.ai](https://docs.getbifrost.ai) + +### Quick Start + +- [Gateway Setup](https://docs.getbifrost.ai/quickstart/gateway/setting-up) - HTTP API deployment in 30 seconds +- [Go SDK Setup](https://docs.getbifrost.ai/quickstart/go-sdk/setting-up) - Direct Go integration +- [Provider Configuration](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration) - Multi-provider setup + +### Features + +- [Multi-Provider Support](https://docs.getbifrost.ai/features/unified-interface) - Single API for all providers +- [MCP Integration](https://docs.getbifrost.ai/features/mcp) - External tool calling +- [Semantic Caching](https://docs.getbifrost.ai/features/semantic-caching) - Intelligent response caching +- [Fallbacks & Load Balancing](https://docs.getbifrost.ai/features/fallbacks) - Reliability features +- [Budget Management](https://docs.getbifrost.ai/features/governance) - Cost control and governance + +### Integrations + +- [OpenAI SDK](https://docs.getbifrost.ai/integrations/openai-sdk) - Drop-in OpenAI replacement +- [Anthropic SDK](https://docs.getbifrost.ai/integrations/anthropic-sdk) - Drop-in Anthropic replacement +- [Google GenAI SDK](https://docs.getbifrost.ai/integrations/genai-sdk) - Drop-in GenAI replacement +- [LiteLLM SDK](https://docs.getbifrost.ai/integrations/litellm-sdk) - LiteLLM integration +- [Langchain SDK](https://docs.getbifrost.ai/integrations/langchain-sdk) - Langchain integration + +### Enterprise + +- [Custom Plugins](https://docs.getbifrost.ai/enterprise/custom-plugins) - Extend functionality +- [Clustering](https://docs.getbifrost.ai/enterprise/clustering) - Multi-node deployment +- [Vault Support](https://docs.getbifrost.ai/enterprise/vault-support) - Secure key management +- [Production Deployment](https://docs.getbifrost.ai/deployment/docker-setup) - Scaling and monitoring + --- -## 🀝 Contributing +## Need Help? -Contributions are welcome! We welcome all kinds of contributions β€” bug fixes, features, docs, and ideas. Please feel free to submit a Pull Request. +**[Join our Discord](https://discord.gg/exN5KAydbU)** for community support and discussions. -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request and describe your changes +Get help with: + +- Quick setup assistance and troubleshooting +- Best practices and configuration tips +- Community discussions and support +- Real-time help with integrations --- -## πŸ“„ License +## Contributing + +We welcome contributions of all kinds! See our [Contributing Guide](https://docs.getbifrost.ai/contributing/setting-up-repo) for: -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +- Setting up the development environment +- Code conventions and best practices +- How to submit pull requests +- Building and testing locally + +For development requirements and build instructions, see our [Development Setup Guide](https://docs.getbifrost.ai/contributing/building-a-plugins). --- +## License + +This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. + Built with ❀️ by [Maxim](https://github.com/maximhq) diff --git a/bifrost-server b/bifrost-server new file mode 100755 index 000000000..1a00d15bb Binary files /dev/null and b/bifrost-server differ diff --git a/core/bifrost.go b/core/bifrost.go index a4ead1c27..a4f3ffd2f 100644 --- a/core/bifrost.go +++ b/core/bifrost.go @@ -7,119 +7,112 @@ import ( "context" "fmt" "math/rand" - "os" - "os/signal" "slices" + "sort" + "strings" "sync" - "syscall" + "sync/atomic" "time" + "github.com/google/uuid" "github.com/maximhq/bifrost/core/providers" + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/providers/azure" + "github.com/maximhq/bifrost/core/providers/bedrock" + "github.com/maximhq/bifrost/core/providers/cohere" + "github.com/maximhq/bifrost/core/providers/gemini" + "github.com/maximhq/bifrost/core/providers/mistral" + "github.com/maximhq/bifrost/core/providers/openai" + "github.com/maximhq/bifrost/core/providers/perplexity" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/providers/vertex" schemas "github.com/maximhq/bifrost/core/schemas" ) -// RequestType represents the type of request being made to a provider. -type RequestType string - -const ( - TextCompletionRequest RequestType = "text_completion" - ChatCompletionRequest RequestType = "chat_completion" -) - // ChannelMessage represents a message passed through the request channel. // It contains the request, response and error channels, and the request type. type ChannelMessage struct { schemas.BifrostRequest - Response chan *schemas.BifrostResponse - Err chan schemas.BifrostError - Type RequestType + Context context.Context + Response chan *schemas.BifrostResponse + ResponseStream chan chan *schemas.BifrostStream + Err chan schemas.BifrostError } -// Bifrost manages providers and maintains sepcified open channels for concurrent processing. +// Bifrost manages providers and maintains specified open channels for concurrent processing. // It handles request routing, provider management, and response processing. type Bifrost struct { - account schemas.Account // account interface - providers []schemas.Provider // list of processed providers - plugins []schemas.Plugin // list of plugins - requestQueues map[schemas.ModelProvider]chan ChannelMessage // provider request queues - waitGroups map[schemas.ModelProvider]*sync.WaitGroup // wait groups for each provider - channelMessagePool sync.Pool // Pool for ChannelMessage objects, initial pool size is set in Init - responseChannelPool sync.Pool // Pool for response channels, initial pool size is set in Init - errorChannelPool sync.Pool // Pool for error channels, initial pool size is set in Init - logger schemas.Logger // logger instance, default logger is used if not provided - dropExcessRequests bool // If true, in cases where the queue is full, requests will not wait for the queue to be empty and will be dropped instead. - backgroundCtx context.Context // Shared background context for nil context handling -} - -// createProviderFromProviderKey creates a new provider instance based on the provider key. -// It returns an error if the provider is not supported. -func (bifrost *Bifrost) createProviderFromProviderKey(providerKey schemas.ModelProvider, config *schemas.ProviderConfig) (schemas.Provider, error) { - switch providerKey { - case schemas.OpenAI: - return providers.NewOpenAIProvider(config, bifrost.logger), nil - case schemas.Anthropic: - return providers.NewAnthropicProvider(config, bifrost.logger), nil - case schemas.Bedrock: - return providers.NewBedrockProvider(config, bifrost.logger), nil - case schemas.Cohere: - return providers.NewCohereProvider(config, bifrost.logger), nil - case schemas.Azure: - return providers.NewAzureProvider(config, bifrost.logger), nil - default: - return nil, fmt.Errorf("unsupported provider: %s", providerKey) - } + ctx context.Context + cancel context.CancelFunc + account schemas.Account // account interface + plugins atomic.Pointer[[]schemas.Plugin] // list of plugins + providers atomic.Pointer[[]schemas.Provider] // list of providers + requestQueues sync.Map // provider request queues (thread-safe) + waitGroups sync.Map // wait groups for each provider (thread-safe) + providerMutexes sync.Map // mutexes for each provider to prevent concurrent updates (thread-safe) + channelMessagePool sync.Pool // Pool for ChannelMessage objects, initial pool size is set in Init + responseChannelPool sync.Pool // Pool for response channels, initial pool size is set in Init + errorChannelPool sync.Pool // Pool for error channels, initial pool size is set in Init + responseStreamPool sync.Pool // Pool for response stream channels, initial pool size is set in Init + pluginPipelinePool sync.Pool // Pool for PluginPipeline objects + bifrostRequestPool sync.Pool // Pool for BifrostRequest objects + logger schemas.Logger // logger instance, default logger is used if not provided + mcpManager *MCPManager // MCP integration manager (nil if MCP not configured) + dropExcessRequests atomic.Bool // If true, in cases where the queue is full, requests will not wait for the queue to be empty and will be dropped instead. + keySelector schemas.KeySelector // Custom key selector function } -// prepareProvider sets up a provider with its configuration, keys, and worker channels. -// It initializes the request queue and starts worker goroutines for processing requests. -func (bifrost *Bifrost) prepareProvider(providerKey schemas.ModelProvider, config *schemas.ProviderConfig) error { - providerConfig, err := bifrost.account.GetConfigForProvider(providerKey) - if err != nil { - return fmt.Errorf("failed to get config for provider: %v", err) - } - - // Check if the provider has any keys - keys, err := bifrost.account.GetKeysForProvider(providerKey) - if err != nil || len(keys) == 0 { - return fmt.Errorf("failed to get keys for provider: %v", err) - } - - queue := make(chan ChannelMessage, providerConfig.ConcurrencyAndBufferSize.BufferSize) // Buffered channel per provider +// PluginPipeline encapsulates the execution of plugin PreHooks and PostHooks, tracks how many plugins ran, and manages short-circuiting and error aggregation. +type PluginPipeline struct { + plugins []schemas.Plugin + logger schemas.Logger - bifrost.requestQueues[providerKey] = queue - - // Start specified number of workers - bifrost.waitGroups[providerKey] = &sync.WaitGroup{} - - provider, err := bifrost.createProviderFromProviderKey(providerKey, config) - if err != nil { - return fmt.Errorf("failed to get provider for the given key: %v", err) - } + // Number of PreHooks that were executed (used to determine which PostHooks to run in reverse order) + executedPreHooks int + // Errors from PreHooks and PostHooks + preHookErrors []error + postHookErrors []error +} - for range providerConfig.ConcurrencyAndBufferSize.Concurrency { - bifrost.waitGroups[providerKey].Add(1) - go bifrost.requestWorker(provider, queue) - } +// Global logger instance which is set in the Init function +var logger schemas.Logger - return nil -} +// INITIALIZATION // Init initializes a new Bifrost instance with the given configuration. // It sets up the account, plugins, object pools, and initializes providers. // Returns an error if initialization fails. // Initial Memory Allocations happens here as per the initial pool size. -func Init(config schemas.BifrostConfig) (*Bifrost, error) { +func Init(ctx context.Context, config schemas.BifrostConfig) (*Bifrost, error) { if config.Account == nil { return nil, fmt.Errorf("account is required to initialize Bifrost") } + if config.Logger == nil { + config.Logger = NewDefaultLogger(schemas.LogLevelInfo) + } + + providerUtils.SetLogger(config.Logger) + bifrostCtx, cancel := context.WithCancel(ctx) bifrost := &Bifrost{ - account: config.Account, - plugins: config.Plugins, - waitGroups: make(map[schemas.ModelProvider]*sync.WaitGroup), - requestQueues: make(map[schemas.ModelProvider]chan ChannelMessage), - dropExcessRequests: config.DropExcessRequests, - backgroundCtx: context.Background(), + ctx: bifrostCtx, + cancel: cancel, + account: config.Account, + plugins: atomic.Pointer[[]schemas.Plugin]{}, + requestQueues: sync.Map{}, + waitGroups: sync.Map{}, + keySelector: config.KeySelector, + logger: config.Logger, + } + bifrost.plugins.Store(&config.Plugins) + + // Initialize providers slice + bifrost.providers.Store(&[]schemas.Provider{}) + + bifrost.dropExcessRequests.Store(config.DropExcessRequests) + + if bifrost.keySelector == nil { + bifrost.keySelector = WeightedRandomKeySelector } // Initialize object pools @@ -138,13 +131,36 @@ func Init(config schemas.BifrostConfig) (*Bifrost, error) { return make(chan schemas.BifrostError, 1) }, } - + bifrost.responseStreamPool = sync.Pool{ + New: func() interface{} { + return make(chan chan *schemas.BifrostStream, 1) + }, + } + bifrost.pluginPipelinePool = sync.Pool{ + New: func() interface{} { + return &PluginPipeline{ + preHookErrors: make([]error, 0), + postHookErrors: make([]error, 0), + } + }, + } + bifrost.bifrostRequestPool = sync.Pool{ + New: func() interface{} { + return &schemas.BifrostRequest{} + }, + } // Prewarm pools with multiple objects for range config.InitialPoolSize { // Create and put new objects directly into pools bifrost.channelMessagePool.Put(&ChannelMessage{}) bifrost.responseChannelPool.Put(make(chan *schemas.BifrostResponse, 1)) bifrost.errorChannelPool.Put(make(chan schemas.BifrostError, 1)) + bifrost.responseStreamPool.Put(make(chan chan *schemas.BifrostStream, 1)) + bifrost.pluginPipelinePool.Put(&PluginPipeline{ + preHookErrors: make([]error, 0), + postHookErrors: make([]error, 0), + }) + bifrost.bifrostRequestPool.Put(&schemas.BifrostRequest{}) } providerKeys, err := bifrost.account.GetConfiguredProviders() @@ -152,623 +168,2467 @@ func Init(config schemas.BifrostConfig) (*Bifrost, error) { return nil, err } - if config.Logger == nil { - config.Logger = NewDefaultLogger(schemas.LogLevelInfo) + // Initialize MCP manager if configured + if config.MCPConfig != nil { + mcpManager, err := newMCPManager(bifrostCtx, *config.MCPConfig, bifrost.logger) + if err != nil { + bifrost.logger.Warn(fmt.Sprintf("failed to initialize MCP manager: %v", err)) + } else { + bifrost.mcpManager = mcpManager + bifrost.logger.Info("MCP integration initialized successfully") + } } - bifrost.logger = config.Logger // Create buffered channels for each provider and start workers for _, providerKey := range providerKeys { + if strings.TrimSpace(string(providerKey)) == "" { + bifrost.logger.Warn("provider key is empty, skipping init") + continue + } + config, err := bifrost.account.GetConfigForProvider(providerKey) if err != nil { bifrost.logger.Warn(fmt.Sprintf("failed to get config for provider, skipping init: %v", err)) continue } - - if err := bifrost.prepareProvider(providerKey, config); err != nil { - bifrost.logger.Warn(fmt.Sprintf("failed to prepare provider: %v", err)) + if config == nil { + bifrost.logger.Warn(fmt.Sprintf("config is nil for provider %s, skipping init", providerKey)) + continue } - } - return bifrost, nil -} - -// getChannelMessage gets a ChannelMessage from the pool and configures it with the request. -// It also gets response and error channels from their respective pools. -func (bifrost *Bifrost) getChannelMessage(req schemas.BifrostRequest, reqType RequestType) *ChannelMessage { - // Get channels from pool - responseChan := bifrost.responseChannelPool.Get().(chan *schemas.BifrostResponse) - errorChan := bifrost.errorChannelPool.Get().(chan schemas.BifrostError) + // Lock the provider mutex during initialization + providerMutex := bifrost.getProviderMutex(providerKey) + providerMutex.Lock() + err = bifrost.prepareProvider(providerKey, config) + providerMutex.Unlock() - // Clear any previous values to avoid leaking between requests - select { - case <-responseChan: - default: - } - select { - case <-errorChan: - default: + if err != nil { + bifrost.logger.Warn(fmt.Sprintf("failed to prepare provider %s: %v", providerKey, err)) + } } - // Get message from pool and configure it - msg := bifrost.channelMessagePool.Get().(*ChannelMessage) - msg.BifrostRequest = req - msg.Response = responseChan - msg.Err = errorChan - msg.Type = reqType + // Set logger + logger = bifrost.logger - return msg + return bifrost, nil } -// releaseChannelMessage returns a ChannelMessage and its channels to their respective pools. -func (bifrost *Bifrost) releaseChannelMessage(msg *ChannelMessage) { - // Put channels back in pools - bifrost.responseChannelPool.Put(msg.Response) - bifrost.errorChannelPool.Put(msg.Err) - - // Clear references and return to pool - msg.Response = nil - msg.Err = nil - bifrost.channelMessagePool.Put(msg) +// ReloadConfig reloads the config from DB +// Currently we only update account and drop excess requests +// We will keep on adding other aspects as required +func (bifrost *Bifrost) ReloadConfig(config schemas.BifrostConfig) error { + bifrost.dropExcessRequests.Store(config.DropExcessRequests) + return nil } -// SelectKeyFromProviderForModel selects an appropriate API key for a given provider and model. -// It uses weighted random selection if multiple keys are available. -func (bifrost *Bifrost) SelectKeyFromProviderForModel(providerKey schemas.ModelProvider, model string) (string, error) { - keys, err := bifrost.account.GetKeysForProvider(providerKey) - if err != nil { - return "", err - } +// PUBLIC API METHODS - if len(keys) == 0 { - return "", fmt.Errorf("no keys found for provider: %v", providerKey) +// ListModelsRequest sends a list models request to the specified provider. +func (bifrost *Bifrost) ListModelsRequest(ctx context.Context, req *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if req == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "list models request is nil", + }, + } } - - // filter out keys which dont support the model - var supportedKeys []schemas.Key - for _, key := range keys { - if slices.Contains(key.Models, model) { - supportedKeys = append(supportedKeys, key) + if req.Provider == "" { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "provider is required for list models request", + }, } } - - if len(supportedKeys) == 0 { - return "", fmt.Errorf("no keys found that support model: %s", model) + if ctx == nil { + ctx = bifrost.ctx } - if len(supportedKeys) == 1 { - return supportedKeys[0].Value, nil + request := &schemas.BifrostListModelsRequest{ + Provider: req.Provider, + PageSize: req.PageSize, + PageToken: req.PageToken, + ExtraParams: req.ExtraParams, } - // Use a weighted random selection based on key weights - totalWeight := 0 - for _, key := range supportedKeys { - totalWeight += int(key.Weight * 100) // Convert float to int for better performance + provider := bifrost.getProviderByKey(req.Provider) + if provider == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "provider not found for list models request", + }, + } } - // Use a fast random number generator - randomSource := rand.New(rand.NewSource(time.Now().UnixNano())) - randomValue := randomSource.Intn(totalWeight) + // Determine the base provider type for key requirement checks + baseProvider := req.Provider + config, err := bifrost.account.GetConfigForProvider(req.Provider) + if err != nil { + return nil, newBifrostErrorFromMsg(fmt.Sprintf("failed to get config for provider %s: %v", req.Provider, err.Error())) + } + if config == nil { + return nil, newBifrostErrorFromMsg(fmt.Sprintf("config is nil for provider %s", req.Provider)) + } + if config.CustomProviderConfig != nil && config.CustomProviderConfig.BaseProviderType != "" { + baseProvider = config.CustomProviderConfig.BaseProviderType + } - // Select key based on weight - currentWeight := 0 - for _, key := range supportedKeys { - currentWeight += int(key.Weight * 100) - if randomValue < currentWeight { - return key.Value, nil + var keys []schemas.Key + if providerRequiresKey(baseProvider, config.CustomProviderConfig) { + keys, err = bifrost.getAllSupportedKeys(&ctx, req.Provider, baseProvider) + if err != nil { + return nil, newBifrostError(err) } } - // Fallback to first key if something goes wrong - return supportedKeys[0].Value, nil + response, bifrostErr := executeRequestWithRetries(&ctx, config, func() (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return provider.ListModels(ctx, keys, request) + }, schemas.ListModelsRequest, req.Provider, "") + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ListModelsRequest, + Provider: req.Provider, + } + return nil, bifrostErr + } + return response, nil } -// calculateBackoff implements exponential backoff with jitter for retry attempts. -func (bifrost *Bifrost) calculateBackoff(attempt int, config *schemas.ProviderConfig) time.Duration { - // Calculate an exponential backoff: initial * 2^attempt - backoff := config.NetworkConfig.RetryBackoffInitial * time.Duration(1< config.NetworkConfig.RetryBackoffMax { - backoff = config.NetworkConfig.RetryBackoffMax +// ListAllModels lists all models from all configured providers. +// It accumulates responses from all providers with a limit of 1000 per provider to get all results. +func (bifrost *Bifrost) ListAllModels(ctx context.Context, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if request == nil { + request = &schemas.BifrostListModelsRequest{} } - // Add jitter (Β±20%) - jitter := float64(backoff) * (0.8 + 0.4*rand.Float64()) - - return time.Duration(jitter) -} + providerKeys, err := bifrost.GetConfiguredProviders() + if err != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: err.Error(), + Error: err, + }, + } + } -// requestWorker handles incoming requests from the queue for a specific provider. -// It manages retries, error handling, and response processing. -func (bifrost *Bifrost) requestWorker(provider schemas.Provider, queue chan ChannelMessage) { - defer bifrost.waitGroups[provider.GetProviderKey()].Done() + startTime := time.Now() - for req := range queue { - var result *schemas.BifrostResponse - var bifrostError *schemas.BifrostError + // Result structure for collecting provider responses + type providerResult struct { + models []schemas.Model + err *schemas.BifrostError + } - key, err := bifrost.SelectKeyFromProviderForModel(provider.GetProviderKey(), req.Model) - if err != nil { - bifrost.logger.Warn(fmt.Sprintf("Error selecting key for model %s: %v", req.Model, err)) - req.Err <- schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), - Error: err, - }, - } - continue - } + results := make(chan providerResult, len(providerKeys)) + var wg sync.WaitGroup - config, err := bifrost.account.GetConfigForProvider(provider.GetProviderKey()) - if err != nil { - bifrost.logger.Warn(fmt.Sprintf("Error getting config for provider %s: %v", provider.GetProviderKey(), err)) - req.Err <- schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), - Error: err, - }, - } + // Launch concurrent requests for all providers + for _, providerKey := range providerKeys { + if strings.TrimSpace(string(providerKey)) == "" { continue } - // Track attempts - var attempts int + wg.Add(1) + go func(providerKey schemas.ModelProvider) { + defer wg.Done() - // Execute request with retries - for attempts = 0; attempts <= config.NetworkConfig.MaxRetries; attempts++ { - if attempts > 0 { - // Log retry attempt - bifrost.logger.Info(fmt.Sprintf( - "Retrying request (attempt %d/%d) for model %s: %s", - attempts, config.NetworkConfig.MaxRetries, req.Model, - bifrostError.Error.Message, - )) + providerModels := make([]schemas.Model, 0) + var providerErr *schemas.BifrostError - // Calculate and apply backoff - backoff := bifrost.calculateBackoff(attempts-1, config) - time.Sleep(backoff) + // Create request for this provider with limit of 1000 + providerRequest := &schemas.BifrostListModelsRequest{ + Provider: providerKey, + PageSize: schemas.DefaultPageSize, } - bifrost.logger.Debug(fmt.Sprintf("Attempting request for provider %s", provider.GetProviderKey())) + iterations := 0 + for { + // check for context cancellation + select { + case <-ctx.Done(): + bifrost.logger.Warn(fmt.Sprintf("context cancelled for provider %s", providerKey)) + return + default: + } - // Attempt the request - if req.Type == TextCompletionRequest { - if req.Input.TextCompletionInput == nil { - bifrostError = &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "text not provided for text completion request", - }, - } - break // Don't retry client errors - } else { - result, bifrostError = provider.TextCompletion(req.Model, key, *req.Input.TextCompletionInput, req.Params) + iterations++ + if iterations > schemas.MaxPaginationRequests { + bifrost.logger.Warn(fmt.Sprintf("reached maximum pagination requests (%d) for provider %s, please increase the page size", schemas.MaxPaginationRequests, providerKey)) + break } - } else if req.Type == ChatCompletionRequest { - if req.Input.ChatCompletionInput == nil { - bifrostError = &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "chats not provided for chat completion request", - }, + + response, bifrostErr := bifrost.ListModelsRequest(ctx, providerRequest) + if bifrostErr != nil { + // Skip logging "no keys found" and "not supported" errors as they are expected when a provider is not configured + if !strings.Contains(bifrostErr.Error.Message, "no keys found") && + !strings.Contains(bifrostErr.Error.Message, "not supported") { + providerErr = bifrostErr + bifrost.logger.Warn(fmt.Sprintf("failed to list models for provider %s: %s", providerKey, GetErrorMessage(bifrostErr))) } - break // Don't retry client errors - } else { - result, bifrostError = provider.ChatCompletion(req.Model, key, *req.Input.ChatCompletionInput, req.Params) + break } - } - bifrost.logger.Debug(fmt.Sprintf("Request for provider %s completed", provider.GetProviderKey())) + if response == nil || len(response.Data) == 0 { + break + } - // Check if successful or if we should retry - //TODO should have a better way to check for only network errors - if bifrostError == nil || bifrostError.IsBifrostError { // Only retry non-bifrost errors - break - } - } + providerModels = append(providerModels, response.Data...) - if bifrostError != nil { - // Add retry information to error - if attempts > 0 { - bifrost.logger.Warn(fmt.Sprintf("Request failed after %d %s", - attempts, - map[bool]string{true: "retries", false: "retry"}[attempts > 1])) - } - req.Err <- *bifrostError - } else { - req.Response <- result - } - } + // Check if there are more pages + if response.NextPageToken == "" { + break + } - bifrost.logger.Debug(fmt.Sprintf("Worker for provider %s exiting...", provider.GetProviderKey())) -} + // Set the page token for the next request + providerRequest.PageToken = response.NextPageToken + } -// GetConfiguredProviderFromProviderKey returns the provider instance for a given provider key. -// Uses the GetProviderKey method of the provider interface to find the provider. -func (bifrost *Bifrost) GetConfiguredProviderFromProviderKey(key schemas.ModelProvider) (schemas.Provider, error) { - for _, provider := range bifrost.providers { - if provider.GetProviderKey() == key { - return provider, nil - } + results <- providerResult{models: providerModels, err: providerErr} + }(providerKey) } - return nil, fmt.Errorf("no provider found for key: %s", key) -} - -// GetProviderQueue returns the request queue for a given provider key. -// If the queue doesn't exist, it creates one at runtime and initializes the provider, -// given the provider config is provided in the account interface implementation. -func (bifrost *Bifrost) GetProviderQueue(providerKey schemas.ModelProvider) (chan ChannelMessage, error) { - var queue chan ChannelMessage - var exists bool + // Wait for all goroutines to complete + wg.Wait() + close(results) - if queue, exists = bifrost.requestQueues[providerKey]; !exists { - bifrost.logger.Debug(fmt.Sprintf("Creating new request queue for provider %s at runtime", providerKey)) + // Accumulate all models from all providers + allModels := make([]schemas.Model, 0) + var firstError *schemas.BifrostError - config, err := bifrost.account.GetConfigForProvider(providerKey) - if err != nil { - return nil, fmt.Errorf("failed to get config for provider: %v", err) + for result := range results { + if len(result.models) > 0 { + allModels = append(allModels, result.models...) } - - if err := bifrost.prepareProvider(providerKey, config); err != nil { - return nil, err + if result.err != nil && firstError == nil { + firstError = result.err } + } - queue = bifrost.requestQueues[providerKey] + // If we couldn't get any models from any provider, return the first error + if len(allModels) == 0 && firstError != nil { + return nil, firstError } - return queue, nil + // Sort models alphabetically by ID + sort.Slice(allModels, func(i, j int) bool { + return allModels[i].ID < allModels[j].ID + }) + + // Return aggregated response with accumulated latency + response := &schemas.BifrostListModelsResponse{ + Data: allModels, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ListModelsRequest, + Latency: time.Since(startTime).Milliseconds(), + }, + } + + response = response.ApplyPagination(request.PageSize, request.PageToken) + + return response, nil } // TextCompletionRequest sends a text completion request to the specified provider. -// It handles plugin hooks, request validation, response processing, and fallback providers. -// If the primary provider fails, it will try each fallback provider in order until one succeeds. -func (bifrost *Bifrost) TextCompletionRequest(providerKey schemas.ModelProvider, req *schemas.BifrostRequest, ctx context.Context) (*schemas.BifrostResponse, *schemas.BifrostError) { +func (bifrost *Bifrost) TextCompletionRequest(ctx context.Context, req *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "bifrost request cannot be nil", + Error: &schemas.ErrorField{ + Message: "text completion request is nil", }, } } - - if req.Model == "" { + if req.Input == nil || (req.Input.PromptStr == nil && req.Input.PromptArray == nil) { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "model is required", + Error: &schemas.ErrorField{ + Message: "prompt not provided for text completion request", }, } } + // Preparing request + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.TextCompletionRequest + bifrostReq.TextCompletionRequest = req - // Try the primary provider first - primaryResult, primaryErr := bifrost.tryTextCompletion(providerKey, req, ctx) - if primaryErr == nil { - return primaryResult, nil + response, err := bifrost.handleRequest(ctx, bifrostReq) + if err != nil { + return nil, err } + //TODO: Release the response + return response.TextCompletionResponse, nil +} - // If primary provider failed and we have fallbacks, try them in order - if len(req.Fallbacks) > 0 { - for _, fallback := range req.Fallbacks { - // Check if we have config for this fallback provider - _, err := bifrost.account.GetConfigForProvider(fallback.Provider) - if err != nil { - bifrost.logger.Warn(fmt.Sprintf("Config not found for provider %s, skipping fallback: %v", fallback.Provider, err)) - continue - } - - // Create a new request with the fallback model - fallbackReq := *req - fallbackReq.Model = fallback.Model - - // Try the fallback provider - result, fallbackErr := bifrost.tryTextCompletion(fallback.Provider, &fallbackReq, ctx) - if fallbackErr == nil { - bifrost.logger.Info(fmt.Sprintf("Successfully used fallback provider %s with model %s", fallback.Provider, fallback.Model)) - return result, nil - } - bifrost.logger.Warn(fmt.Sprintf("Fallback provider %s failed: %s", fallback.Provider, fallbackErr.Error.Message)) +// TextCompletionStreamRequest sends a streaming text completion request to the specified provider. +func (bifrost *Bifrost) TextCompletionStreamRequest(ctx context.Context, req *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if req == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "text completion stream request is nil", + }, } } - - // All providers failed, return the original error - return nil, primaryErr + if req.Input == nil || (req.Input.PromptStr == nil && req.Input.PromptArray == nil) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "text not provided for text completion stream request", + }, + } + } + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.TextCompletionStreamRequest + bifrostReq.TextCompletionRequest = req + return bifrost.handleStreamRequest(ctx, bifrostReq) } -// tryTextCompletion attempts a text completion request with a single provider. -// This is a helper function used by TextCompletionRequest to handle individual provider attempts. -func (bifrost *Bifrost) tryTextCompletion(providerKey schemas.ModelProvider, req *schemas.BifrostRequest, ctx context.Context) (*schemas.BifrostResponse, *schemas.BifrostError) { - queue, err := bifrost.GetProviderQueue(providerKey) - if err != nil { +// ChatCompletionRequest sends a chat completion request to the specified provider. +func (bifrost *Bifrost) ChatCompletionRequest(ctx context.Context, req *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), + Error: &schemas.ErrorField{ + Message: "chat completion request is nil", }, } } - - for _, plugin := range bifrost.plugins { - req, err = plugin.PreHook(&ctx, req) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), - }, - } + if req.Input == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "chats not provided for chat completion request", + }, } } + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.ChatCompletionRequest + bifrostReq.ChatRequest = req + + response, err := bifrost.handleRequest(ctx, bifrostReq) + if err != nil { + return nil, err + } + //TODO: Release the response + return response.ChatResponse, nil +} + +// ChatCompletionStreamRequest sends a chat completion stream request to the specified provider. +func (bifrost *Bifrost) ChatCompletionStreamRequest(ctx context.Context, req *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "bifrost request after plugin hooks cannot be nil", + Error: &schemas.ErrorField{ + Message: "chat completion stream request is nil", + }, + } + } + if req.Input == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "chats not provided for chat completion request", }, } } - // Get a ChannelMessage from the pool - msg := bifrost.getChannelMessage(*req, TextCompletionRequest) + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.ChatCompletionStreamRequest + bifrostReq.ChatRequest = req - // Handle queue send with context and proper cleanup - select { - case queue <- *msg: - // Message was sent successfully - case <-ctx.Done(): - // Request was cancelled by caller - bifrost.releaseChannelMessage(msg) + return bifrost.handleStreamRequest(ctx, bifrostReq) +} + +// ResponsesRequest sends a responses request to the specified provider. +func (bifrost *Bifrost) ResponsesRequest(ctx context.Context, req *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "request cancelled while waiting for queue space", + Error: &schemas.ErrorField{ + Message: "responses request is nil", }, } - default: - if bifrost.dropExcessRequests { - // Drop request immediately if configured to do so - bifrost.releaseChannelMessage(msg) - bifrost.logger.Warn("Request dropped: queue is full, please increase the queue size or set dropExcessRequests to false") - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "request dropped: queue is full", - }, - } - } - // If not dropping excess requests, wait with context - if ctx == nil { - ctx = bifrost.backgroundCtx - } - select { - case queue <- *msg: - // Message was sent successfully - case <-ctx.Done(): - bifrost.releaseChannelMessage(msg) - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "request cancelled while waiting for queue space", - }, - } - } } - - // Handle response - var result *schemas.BifrostResponse - select { - case result = <-msg.Response: - // Run plugins in reverse order - for i := len(bifrost.plugins) - 1; i >= 0; i-- { - result, err = bifrost.plugins[i].PostHook(&ctx, result) - if err != nil { - bifrost.releaseChannelMessage(msg) - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), - }, - } - } + if req.Input == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "responses not provided for responses request", + }, } - case err := <-msg.Err: - bifrost.releaseChannelMessage(msg) - return nil, &err } - // Return message to pool - bifrost.releaseChannelMessage(msg) - return result, nil + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.ResponsesRequest + bifrostReq.ResponsesRequest = req + + response, err := bifrost.handleRequest(ctx, bifrostReq) + if err != nil { + return nil, err + } + //TODO: Release the response + return response.ResponsesResponse, nil } -// ChatCompletionRequest sends a chat completion request to the specified provider. -// It handles plugin hooks, request validation, response processing, and fallback providers. -// If the primary provider fails, it will try each fallback provider in order until one succeeds. -func (bifrost *Bifrost) ChatCompletionRequest(providerKey schemas.ModelProvider, req *schemas.BifrostRequest, ctx context.Context) (*schemas.BifrostResponse, *schemas.BifrostError) { +// ResponsesStreamRequest sends a responses stream request to the specified provider. +func (bifrost *Bifrost) ResponsesStreamRequest(ctx context.Context, req *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "bifrost request cannot be nil", + Error: &schemas.ErrorField{ + Message: "responses stream request is nil", }, } } - - if req.Model == "" { + if req.Input == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "model is required", + Error: &schemas.ErrorField{ + Message: "responses not provided for responses stream request", }, } } - // Try the primary provider first - primaryResult, primaryErr := bifrost.tryChatCompletion(providerKey, req, ctx) - if primaryErr == nil { - return primaryResult, nil + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.ResponsesStreamRequest + bifrostReq.ResponsesRequest = req + + return bifrost.handleStreamRequest(ctx, bifrostReq) +} + +// EmbeddingRequest sends an embedding request to the specified provider. +func (bifrost *Bifrost) EmbeddingRequest(ctx context.Context, req *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + if req == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "embedding request is nil", + }, + } + } + if req.Input == nil || (req.Input.Text == nil && req.Input.Texts == nil && req.Input.Embedding == nil && req.Input.Embeddings == nil) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "embedding input not provided for embedding request", + }, + } } - // If primary provider failed and we have fallbacks, try them in order - if len(req.Fallbacks) > 0 { - for _, fallback := range req.Fallbacks { - // Check if we have config for this fallback provider - _, err := bifrost.account.GetConfigForProvider(fallback.Provider) - if err != nil { - bifrost.logger.Warn(fmt.Sprintf("Skipping fallback provider %s: %v", fallback.Provider, err)) - continue - } + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.EmbeddingRequest + bifrostReq.EmbeddingRequest = req - // Create a new request with the fallback model - fallbackReq := *req - fallbackReq.Model = fallback.Model + response, err := bifrost.handleRequest(ctx, bifrostReq) + if err != nil { + return nil, err + } + //TODO: Release the response + return response.EmbeddingResponse, nil +} - // Try the fallback provider - result, fallbackErr := bifrost.tryChatCompletion(fallback.Provider, &fallbackReq, ctx) - if fallbackErr == nil { - bifrost.logger.Info(fmt.Sprintf("Successfully used fallback provider %s with model %s", fallback.Provider, fallback.Model)) - return result, nil - } - bifrost.logger.Warn(fmt.Sprintf("Fallback provider %s failed: %v", fallback.Provider, fallbackErr.Error.Message)) +// SpeechRequest sends a speech request to the specified provider. +func (bifrost *Bifrost) SpeechRequest(ctx context.Context, req *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + if req == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "speech request is nil", + }, + } + } + if req.Input == nil || req.Input.Input == "" { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "speech input not provided for speech request", + }, } } - // All providers failed, return the original error - return nil, primaryErr -} + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.SpeechRequest + bifrostReq.SpeechRequest = req -// tryChatCompletion attempts a chat completion request with a single provider. -// This is a helper function used by ChatCompletionRequest to handle individual provider attempts. -func (bifrost *Bifrost) tryChatCompletion(providerKey schemas.ModelProvider, req *schemas.BifrostRequest, ctx context.Context) (*schemas.BifrostResponse, *schemas.BifrostError) { - queue, err := bifrost.GetProviderQueue(providerKey) + response, err := bifrost.handleRequest(ctx, bifrostReq) if err != nil { + return nil, err + } + //TODO: Release the response + return response.SpeechResponse, nil +} + +// SpeechStreamRequest sends a speech stream request to the specified provider. +func (bifrost *Bifrost) SpeechStreamRequest(ctx context.Context, req *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), + Error: &schemas.ErrorField{ + Message: "speech stream request is nil", }, } } - - for _, plugin := range bifrost.plugins { - req, err = plugin.PreHook(&ctx, req) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), - }, - } + if req.Input == nil || req.Input.Input == "" { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "speech input not provided for speech stream request", + }, } } + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.SpeechStreamRequest + bifrostReq.SpeechRequest = req + + return bifrost.handleStreamRequest(ctx, bifrostReq) +} + +// TranscriptionRequest sends a transcription request to the specified provider. +func (bifrost *Bifrost) TranscriptionRequest(ctx context.Context, req *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "bifrost request after plugin hooks cannot be nil", + Error: &schemas.ErrorField{ + Message: "transcription request is nil", + }, + } + } + if req.Input == nil || req.Input.File == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "transcription input not provided for transcription request", }, } } - // Get a ChannelMessage from the pool - msg := bifrost.getChannelMessage(*req, ChatCompletionRequest) + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.TranscriptionRequest + bifrostReq.TranscriptionRequest = req - // Handle queue send with context and proper cleanup - select { - case queue <- *msg: - // Message was sent successfully - case <-ctx.Done(): - // Request was cancelled by caller - bifrost.releaseChannelMessage(msg) + response, err := bifrost.handleRequest(ctx, bifrostReq) + if err != nil { + return nil, err + } + //TODO: Release the response + return response.TranscriptionResponse, nil +} + +// TranscriptionStreamRequest sends a transcription stream request to the specified provider. +func (bifrost *Bifrost) TranscriptionStreamRequest(ctx context.Context, req *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if req == nil { return nil, &schemas.BifrostError{ IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "request cancelled while waiting for queue space", + Error: &schemas.ErrorField{ + Message: "transcription stream request is nil", }, } - default: - if bifrost.dropExcessRequests { - // Drop request immediately if configured to do so - bifrost.releaseChannelMessage(msg) - bifrost.logger.Warn("Request dropped: queue is full, please increase the queue size or set dropExcessRequests to false") - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "request dropped: queue is full", - }, + } + if req.Input == nil || req.Input.File == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "transcription input not provided for transcription stream request", + }, + } + } + + bifrostReq := bifrost.getBifrostRequest() + bifrostReq.RequestType = schemas.TranscriptionStreamRequest + bifrostReq.TranscriptionRequest = req + + return bifrost.handleStreamRequest(ctx, bifrostReq) +} + +// RemovePlugin removes a plugin from the server. +func (bifrost *Bifrost) RemovePlugin(name string) error { + + for { + oldPlugins := bifrost.plugins.Load() + if oldPlugins == nil { + return nil + } + var pluginToCleanup schemas.Plugin + found := false + // Create new slice with replaced plugin + newPlugins := make([]schemas.Plugin, len(*oldPlugins)) + copy(newPlugins, *oldPlugins) + for i, p := range newPlugins { + if p.GetName() == name { + pluginToCleanup = p + bifrost.logger.Debug("removing plugin %s", name) + newPlugins = append(newPlugins[:i], newPlugins[i+1:]...) + found = true + break } } - // If not dropping excess requests, wait with context - if ctx == nil { - ctx = bifrost.backgroundCtx + if !found { + return nil } - select { - case queue <- *msg: - // Message was sent successfully - case <-ctx.Done(): - bifrost.releaseChannelMessage(msg) - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "request cancelled while waiting for queue space", - }, + if pluginToCleanup != nil { + // Atomic compare-and-swap + if bifrost.plugins.CompareAndSwap(oldPlugins, &newPlugins) { + // Cleanup the old plugin + err := pluginToCleanup.Cleanup() + if err != nil { + bifrost.logger.Warn("failed to cleanup old plugin %s: %v", pluginToCleanup.GetName(), err) + } + return nil } } + // Retrying as swapping did not work } +} - // Handle response - var result *schemas.BifrostResponse - select { - case result = <-msg.Response: - // Run plugins in reverse order - for i := len(bifrost.plugins) - 1; i >= 0; i-- { - result, err = bifrost.plugins[i].PostHook(&ctx, result) - if err != nil { - bifrost.releaseChannelMessage(msg) - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: err.Error(), - }, +// ReloadPlugin reloads a plugin with new instance +// During the reload - it's stop the world phase where we take a global lock on the plugin mutex +func (bifrost *Bifrost) ReloadPlugin(plugin schemas.Plugin) error { + for { + var pluginToCleanup schemas.Plugin + found := false + oldPlugins := bifrost.plugins.Load() + if oldPlugins == nil { + return nil + } + // Create new slice with replaced plugin + newPlugins := make([]schemas.Plugin, len(*oldPlugins)) + copy(newPlugins, *oldPlugins) + for i, p := range newPlugins { + if p.GetName() == plugin.GetName() { + // Cleaning up old plugin before replacing it + pluginToCleanup = p + bifrost.logger.Debug("replacing plugin %s with new instance", plugin.GetName()) + newPlugins[i] = plugin + found = true + break + } + } + if !found { + // This means that user is adding a new plugin + bifrost.logger.Debug("adding new plugin %s", plugin.GetName()) + newPlugins = append(newPlugins, plugin) + } + // Atomic compare-and-swap + if bifrost.plugins.CompareAndSwap(oldPlugins, &newPlugins) { + // Cleanup the old plugin + if found && pluginToCleanup != nil { + err := pluginToCleanup.Cleanup() + if err != nil { + bifrost.logger.Warn("failed to cleanup old plugin %s: %v", pluginToCleanup.GetName(), err) } } + return nil + } + // Retrying as swapping did not work + } +} + +// GetConfiguredProviders returns a configured providers list. +func (bifrost *Bifrost) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + providers := bifrost.providers.Load() + if providers == nil { + return nil, fmt.Errorf("no providers configured") + } + modelProviders := make([]schemas.ModelProvider, len(*providers)) + for i, provider := range *providers { + modelProviders[i] = provider.GetProviderKey() + } + return modelProviders, nil +} + +// UpdateProvider dynamically updates a provider with new configuration. +// This method gracefully recreates the provider instance with updated settings, +// stops existing workers, creates a new queue with updated settings, +// and starts new workers with the updated provider and concurrency configuration. +// +// Parameters: +// - providerKey: The provider to update +// +// Returns: +// - error: Any error that occurred during the update process +// +// Note: This operation will temporarily pause request processing for the specified provider +// while the transition occurs. In-flight requests will complete before workers are stopped. +// Buffered requests in the old queue will be transferred to the new queue to prevent loss. +func (bifrost *Bifrost) UpdateProvider(providerKey schemas.ModelProvider) error { + bifrost.logger.Info(fmt.Sprintf("Updating provider configuration for provider %s", providerKey)) + + // Get the updated configuration from the account + providerConfig, err := bifrost.account.GetConfigForProvider(providerKey) + if err != nil { + return fmt.Errorf("failed to get updated config for provider %s: %v", providerKey, err) + } + if providerConfig == nil { + return fmt.Errorf("config is nil for provider %s", providerKey) + } + + // Lock the provider to prevent concurrent access during update + providerMutex := bifrost.getProviderMutex(providerKey) + providerMutex.Lock() + defer providerMutex.Unlock() + + // Check if provider currently exists + oldQueueValue, exists := bifrost.requestQueues.Load(providerKey) + if !exists { + bifrost.logger.Debug("provider %s not currently active, initializing with new configuration", providerKey) + // If provider doesn't exist, just prepare it with new configuration + return bifrost.prepareProvider(providerKey, providerConfig) + } + + oldQueue := oldQueueValue.(chan *ChannelMessage) + + bifrost.logger.Debug("gracefully stopping existing workers for provider %s", providerKey) + + // Step 1: Create new queue with updated buffer size + newQueue := make(chan *ChannelMessage, providerConfig.ConcurrencyAndBufferSize.BufferSize) + + // Step 2: Transfer any buffered requests from old queue to new queue + // This prevents request loss during the transition + transferredCount := 0 + var transferWaitGroup sync.WaitGroup + for { + select { + case msg := <-oldQueue: + select { + case newQueue <- msg: + transferredCount++ + default: + // New queue is full, handle this request in a goroutine + // This is unlikely with proper buffer sizing but provides safety + transferWaitGroup.Add(1) + go func(m *ChannelMessage) { + defer transferWaitGroup.Done() + select { + case newQueue <- m: + // Message successfully transferred + case <-time.After(5 * time.Second): + bifrost.logger.Warn("Failed to transfer buffered request to new queue within timeout") + // Send error response to avoid hanging the client + select { + case m.Err <- schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "request failed during provider concurrency update", + }, + }: + case <-time.After(1 * time.Second): + // If we can't send the error either, just log and continue + bifrost.logger.Warn("Failed to send error response during transfer timeout") + } + } + }(msg) + goto transferComplete + } + default: + // No more buffered messages + goto transferComplete + } + } + +transferComplete: + // Wait for all transfer goroutines to complete + transferWaitGroup.Wait() + if transferredCount > 0 { + bifrost.logger.Info("transferred %d buffered requests to new queue for provider %s", transferredCount, providerKey) + } + + // Step 3: Close the old queue to signal workers to stop + close(oldQueue) + + // Step 4: Atomically replace the queue + bifrost.requestQueues.Store(providerKey, newQueue) + + // Step 5: Wait for all existing workers to finish processing in-flight requests + waitGroup, exists := bifrost.waitGroups.Load(providerKey) + if exists { + waitGroup.(*sync.WaitGroup).Wait() + bifrost.logger.Debug("all workers for provider %s have stopped", providerKey) + } + + // Step 6: Create new wait group for the updated workers + bifrost.waitGroups.Store(providerKey, &sync.WaitGroup{}) + + // Step 7: Create provider instance + provider, err := bifrost.createBaseProvider(providerKey, providerConfig) + if err != nil { + return fmt.Errorf("failed to create provider instance for %s: %v", providerKey, err) + } + + // Step 7.5: Atomically replace the provider in the providers slice + // This must happen before starting new workers to prevent stale reads + bifrost.logger.Debug("atomically replacing provider instance in providers slice for %s", providerKey) + + replacementAttempts := 0 + maxReplacementAttempts := 100 // Prevent infinite loops in high-contention scenarios + + for { + replacementAttempts++ + if replacementAttempts > maxReplacementAttempts { + return fmt.Errorf("failed to replace provider %s in providers slice after %d attempts", providerKey, maxReplacementAttempts) + } + + oldPtr := bifrost.providers.Load() + var oldSlice []schemas.Provider + if oldPtr != nil { + oldSlice = *oldPtr + } + + // Create new slice without the old provider of this key + // Use exact capacity to avoid allocations + newSlice := make([]schemas.Provider, 0, len(oldSlice)) + oldProviderFound := false + + for _, existingProvider := range oldSlice { + if existingProvider.GetProviderKey() != providerKey { + newSlice = append(newSlice, existingProvider) + } else { + oldProviderFound = true + } + } + + // Add the new provider + newSlice = append(newSlice, provider) + + if bifrost.providers.CompareAndSwap(oldPtr, &newSlice) { + if oldProviderFound { + bifrost.logger.Debug("successfully replaced existing provider instance for %s in providers slice", providerKey) + } else { + bifrost.logger.Debug("successfully added new provider instance for %s to providers slice", providerKey) + } + break + } + // Retrying as swapping did not work (likely due to concurrent modification) + } + + // Step 8: Start new workers with updated concurrency + bifrost.logger.Debug("starting %d new workers for provider %s with buffer size %d", + providerConfig.ConcurrencyAndBufferSize.Concurrency, + providerKey, + providerConfig.ConcurrencyAndBufferSize.BufferSize) + + waitGroupValue, _ := bifrost.waitGroups.Load(providerKey) + currentWaitGroup := waitGroupValue.(*sync.WaitGroup) + + for range providerConfig.ConcurrencyAndBufferSize.Concurrency { + currentWaitGroup.Add(1) + go bifrost.requestWorker(provider, providerConfig, newQueue) + } + + bifrost.logger.Info("successfully updated provider configuration for provider %s", providerKey) + return nil +} + +// GetDropExcessRequests returns the current value of DropExcessRequests +func (bifrost *Bifrost) GetDropExcessRequests() bool { + return bifrost.dropExcessRequests.Load() +} + +// UpdateDropExcessRequests updates the DropExcessRequests setting at runtime. +// This allows for hot-reloading of this configuration value. +func (bifrost *Bifrost) UpdateDropExcessRequests(value bool) { + bifrost.dropExcessRequests.Store(value) + bifrost.logger.Info("drop_excess_requests updated to: %v", value) +} + +// getProviderMutex gets or creates a mutex for the given provider +func (bifrost *Bifrost) getProviderMutex(providerKey schemas.ModelProvider) *sync.RWMutex { + mutexValue, _ := bifrost.providerMutexes.LoadOrStore(providerKey, &sync.RWMutex{}) + return mutexValue.(*sync.RWMutex) +} + +// MCP PUBLIC API + +// RegisterMCPTool registers a typed tool handler with the MCP integration. +// This allows developers to easily add custom tools that will be available +// to all LLM requests processed by this Bifrost instance. +// +// Parameters: +// - name: Unique tool name +// - description: Human-readable tool description +// - handler: Function that handles tool execution +// - toolSchema: Bifrost tool schema for function calling +// +// Returns: +// - error: Any registration error +// +// Example: +// +// type EchoArgs struct { +// Message string `json:"message"` +// } +// +// err := bifrost.RegisterMCPTool("echo", "Echo a message", +// func(args EchoArgs) (string, error) { +// return args.Message, nil +// }, toolSchema) +func (bifrost *Bifrost) RegisterMCPTool(name, description string, handler func(args any) (string, error), toolSchema schemas.ChatTool) error { + if bifrost.mcpManager == nil { + return fmt.Errorf("MCP is not configured in this Bifrost instance") + } + + return bifrost.mcpManager.registerTool(name, description, handler, toolSchema) +} + +// ExecuteMCPTool executes an MCP tool call and returns the result as a tool message. +// This is the main public API for manual MCP tool execution. +// +// Parameters: +// - ctx: Execution context +// - toolCall: The tool call to execute (from assistant message) +// +// Returns: +// - schemas.ChatMessage: Tool message with execution result +// - schemas.BifrostError: Any execution error +func (bifrost *Bifrost) ExecuteMCPTool(ctx context.Context, toolCall schemas.ChatAssistantMessageToolCall) (*schemas.ChatMessage, *schemas.BifrostError) { + if bifrost.mcpManager == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "MCP is not configured in this Bifrost instance", + }, + } + } + + result, err := bifrost.mcpManager.executeTool(ctx, toolCall) + if err != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: err.Error(), + Error: err, + }, } - case err := <-msg.Err: - bifrost.releaseChannelMessage(msg) - return nil, &err } - // Return message to pool - bifrost.releaseChannelMessage(msg) return result, nil } -// Shutdown gracefully stops all workers when triggered. -// It closes all request channels and waits for workers to exit. -func (bifrost *Bifrost) Shutdown() { - bifrost.logger.Info("[BIFROST] Graceful Shutdown Initiated - Closing all request channels...") +// IMPORTANT: Running the MCP client management operations (GetMCPClients, AddMCPClient, RemoveMCPClient, EditMCPClientTools) +// may temporarily increase latency for incoming requests while the operations are being processed. +// These operations involve network I/O and connection management that require mutex locks +// which can block briefly during execution. + +// GetMCPClients returns all MCP clients managed by the Bifrost instance. +// +// Returns: +// - []schemas.MCPClient: List of all MCP clients +// - error: Any retrieval error +func (bifrost *Bifrost) GetMCPClients() ([]schemas.MCPClient, error) { + if bifrost.mcpManager == nil { + return nil, fmt.Errorf("MCP is not configured in this Bifrost instance") + } - // Close all provider queues to signal workers to stop - for _, queue := range bifrost.requestQueues { - close(queue) + clients, err := bifrost.mcpManager.GetClients() + if err != nil { + return nil, err } - // Wait for all workers to exit - for _, waitGroup := range bifrost.waitGroups { - waitGroup.Wait() + clientsInConfig := make([]schemas.MCPClient, 0, len(clients)) + for _, client := range clients { + tools := make([]schemas.ChatToolFunction, 0, len(client.ToolMap)) + for _, tool := range client.ToolMap { + if tool.Function != nil { + tools = append(tools, *tool.Function) + } + } + + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + state := schemas.MCPConnectionStateConnected + if client.Conn == nil { + state = schemas.MCPConnectionStateDisconnected + } + + clientsInConfig = append(clientsInConfig, schemas.MCPClient{ + Config: client.ExecutionConfig, + Tools: tools, + State: state, + }) + } + + return clientsInConfig, nil +} + +// AddMCPClient adds a new MCP client to the Bifrost instance. +// This allows for dynamic MCP client management at runtime. +// +// Parameters: +// - config: MCP client configuration +// +// Returns: +// - error: Any registration error +// +// Example: +// +// err := bifrost.AddMCPClient(schemas.MCPClientConfig{ +// Name: "my-mcp-client", +// ConnectionType: schemas.MCPConnectionTypeHTTP, +// ConnectionString: &url, +// }) +func (bifrost *Bifrost) AddMCPClient(config schemas.MCPClientConfig) error { + if bifrost.mcpManager == nil { + manager := &MCPManager{ + ctx: bifrost.ctx, + clientMap: make(map[string]*MCPClient), + logger: bifrost.logger, + } + + bifrost.mcpManager = manager + } + + return bifrost.mcpManager.AddClient(config) +} + +// RemoveMCPClient removes an MCP client from the Bifrost instance. +// This allows for dynamic MCP client management at runtime. +// +// Parameters: +// - id: ID of the client to remove +// +// Returns: +// - error: Any removal error +// +// Example: +// +// err := bifrost.RemoveMCPClient("my-mcp-client-id") +// if err != nil { +// log.Fatalf("Failed to remove MCP client: %v", err) +// } +func (bifrost *Bifrost) RemoveMCPClient(id string) error { + if bifrost.mcpManager == nil { + return fmt.Errorf("MCP is not configured in this Bifrost instance") + } + + return bifrost.mcpManager.RemoveClient(id) +} + +// EditMCPClient edits the tools of an MCP client. +// This allows for dynamic MCP client tool management at runtime. +// +// Parameters: +// - id: ID of the client to edit +// - updatedConfig: Updated MCP client configuration +// +// Returns: +// - error: Any edit error +// +// Example: +// +// err := bifrost.EditMCPClient("my-mcp-client-id", schemas.MCPClientConfig{ +// Name: "my-mcp-client-name", +// ToolsToExecute: []string{"tool1", "tool2"}, +// }) +func (bifrost *Bifrost) EditMCPClient(id string, updatedConfig schemas.MCPClientConfig) error { + if bifrost.mcpManager == nil { + return fmt.Errorf("MCP is not configured in this Bifrost instance") + } + + return bifrost.mcpManager.EditClient(id, updatedConfig) +} + +// ReconnectMCPClient attempts to reconnect an MCP client if it is disconnected. +// +// Parameters: +// - id: ID of the client to reconnect +// +// Returns: +// - error: Any reconnection error +func (bifrost *Bifrost) ReconnectMCPClient(id string) error { + if bifrost.mcpManager == nil { + return fmt.Errorf("MCP is not configured in this Bifrost instance") } + + return bifrost.mcpManager.ReconnectClient(id) } -// Cleanup handles SIGINT (Ctrl+C) to exit cleanly. -// It sets up signal handling and calls Shutdown when interrupted. -func (bifrost *Bifrost) Cleanup() { - signalChan := make(chan os.Signal, 1) - signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) +// PROVIDER MANAGEMENT + +// createBaseProvider creates a provider based on the base provider type +func (bifrost *Bifrost) createBaseProvider(providerKey schemas.ModelProvider, config *schemas.ProviderConfig) (schemas.Provider, error) { + // Determine which provider type to create + targetProviderKey := providerKey + + if config.CustomProviderConfig != nil { + // Validate custom provider config + if config.CustomProviderConfig.BaseProviderType == "" { + return nil, fmt.Errorf("custom provider config missing base provider type") + } + + // Validate that base provider type is supported + if !IsSupportedBaseProvider(config.CustomProviderConfig.BaseProviderType) { + return nil, fmt.Errorf("unsupported base provider type: %s", config.CustomProviderConfig.BaseProviderType) + } + + // Automatically set the custom provider key to the provider name + config.CustomProviderConfig.CustomProviderKey = string(providerKey) + + targetProviderKey = config.CustomProviderConfig.BaseProviderType + } - <-signalChan // Wait for interrupt signal - bifrost.Shutdown() // Gracefully shut down + switch targetProviderKey { + case schemas.OpenAI: + return openai.NewOpenAIProvider(config, bifrost.logger), nil + case schemas.Anthropic: + return anthropic.NewAnthropicProvider(config, bifrost.logger), nil + case schemas.Bedrock: + return bedrock.NewBedrockProvider(config, bifrost.logger) + case schemas.Cohere: + return cohere.NewCohereProvider(config, bifrost.logger) + case schemas.Azure: + return azure.NewAzureProvider(config, bifrost.logger) + case schemas.Vertex: + return vertex.NewVertexProvider(config, bifrost.logger) + case schemas.Mistral: + return mistral.NewMistralProvider(config, bifrost.logger), nil + case schemas.Ollama: + return providers.NewOllamaProvider(config, bifrost.logger) + case schemas.Groq: + return providers.NewGroqProvider(config, bifrost.logger) + case schemas.SGL: + return providers.NewSGLProvider(config, bifrost.logger) + case schemas.Parasail: + return providers.NewParasailProvider(config, bifrost.logger) + case schemas.Perplexity: + return perplexity.NewPerplexityProvider(config, bifrost.logger) + case schemas.Cerebras: + return providers.NewCerebrasProvider(config, bifrost.logger) + case schemas.Gemini: + return gemini.NewGeminiProvider(config, bifrost.logger), nil + case schemas.OpenRouter: + return providers.NewOpenRouterProvider(config, bifrost.logger), nil + default: + return nil, fmt.Errorf("unsupported provider: %s", targetProviderKey) + } +} + +// prepareProvider sets up a provider with its configuration, keys, and worker channels. +// It initializes the request queue and starts worker goroutines for processing requests. +// Note: This function assumes the caller has already acquired the appropriate mutex for the provider. +func (bifrost *Bifrost) prepareProvider(providerKey schemas.ModelProvider, config *schemas.ProviderConfig) error { + providerConfig, err := bifrost.account.GetConfigForProvider(providerKey) + if err != nil { + return fmt.Errorf("failed to get config for provider: %v", err) + } + if providerConfig == nil { + return fmt.Errorf("config is nil for provider %s", providerKey) + } + + queue := make(chan *ChannelMessage, providerConfig.ConcurrencyAndBufferSize.BufferSize) // Buffered channel per provider + + bifrost.requestQueues.Store(providerKey, queue) + + // Start specified number of workers + bifrost.waitGroups.Store(providerKey, &sync.WaitGroup{}) + + provider, err := bifrost.createBaseProvider(providerKey, config) + if err != nil { + return fmt.Errorf("failed to create provider for the given key: %v", err) + } + + waitGroupValue, _ := bifrost.waitGroups.Load(providerKey) + currentWaitGroup := waitGroupValue.(*sync.WaitGroup) + + // Atomically append provider to the providers slice + for { + oldPtr := bifrost.providers.Load() + var oldSlice []schemas.Provider + if oldPtr != nil { + oldSlice = *oldPtr + } + newSlice := make([]schemas.Provider, len(oldSlice)+1) + copy(newSlice, oldSlice) + newSlice[len(oldSlice)] = provider + if bifrost.providers.CompareAndSwap(oldPtr, &newSlice) { + break + } + } + + for range providerConfig.ConcurrencyAndBufferSize.Concurrency { + currentWaitGroup.Add(1) + go bifrost.requestWorker(provider, providerConfig, queue) + } + + return nil +} + +// getProviderQueue returns the request queue for a given provider key. +// If the queue doesn't exist, it creates one at runtime and initializes the provider, +// given the provider config is provided in the account interface implementation. +// This function uses read locks to prevent race conditions during provider updates. +func (bifrost *Bifrost) getProviderQueue(providerKey schemas.ModelProvider) (chan *ChannelMessage, error) { + // Use read lock to allow concurrent reads but prevent concurrent updates + providerMutex := bifrost.getProviderMutex(providerKey) + providerMutex.RLock() + + if queueValue, exists := bifrost.requestQueues.Load(providerKey); exists { + queue := queueValue.(chan *ChannelMessage) + providerMutex.RUnlock() + return queue, nil + } + + // Provider doesn't exist, need to create it + // Upgrade to write lock for creation + providerMutex.RUnlock() + providerMutex.Lock() + defer providerMutex.Unlock() + + // Double-check after acquiring write lock (another goroutine might have created it) + if queueValue, exists := bifrost.requestQueues.Load(providerKey); exists { + queue := queueValue.(chan *ChannelMessage) + return queue, nil + } + + bifrost.logger.Debug(fmt.Sprintf("Creating new request queue for provider %s at runtime", providerKey)) + + config, err := bifrost.account.GetConfigForProvider(providerKey) + if err != nil { + return nil, fmt.Errorf("failed to get config for provider: %v", err) + } + if config == nil { + return nil, fmt.Errorf("config is nil for provider %s", providerKey) + } + + if err := bifrost.prepareProvider(providerKey, config); err != nil { + return nil, err + } + + queueValue, _ := bifrost.requestQueues.Load(providerKey) + queue := queueValue.(chan *ChannelMessage) + + return queue, nil +} + +// getProviderByKey retrieves a provider instance from the providers array by its provider key. +// Returns the provider if found, or nil if no provider with the given key exists. +func (bifrost *Bifrost) getProviderByKey(providerKey schemas.ModelProvider) schemas.Provider { + providers := bifrost.providers.Load() + if providers == nil { + return nil + } + + for _, provider := range *providers { + if provider.GetProviderKey() == providerKey { + return provider + } + } + + // Could happen when provider is not initialized yet, check if provider config exists in account and if so, initialize it + config, err := bifrost.account.GetConfigForProvider(providerKey) + if err != nil || config == nil { + return nil + } + + // Lock the provider mutex to avoid races + providerMutex := bifrost.getProviderMutex(providerKey) + providerMutex.Lock() + defer providerMutex.Unlock() + + // Double-check after acquiring the lock + providers = bifrost.providers.Load() + if providers != nil { + for _, p := range *providers { + if p.GetProviderKey() == providerKey { + return p + } + } + } + + if err := bifrost.prepareProvider(providerKey, config); err != nil { + return nil + } + + // Return newly prepared provider without recursion + providers = bifrost.providers.Load() + if providers != nil { + for _, p := range *providers { + if p.GetProviderKey() == providerKey { + return p + } + } + } + return nil +} + +// CORE INTERNAL LOGIC + +// shouldTryFallbacks handles the primary error and returns true if we should proceed with fallbacks, false if we should return immediately +func (bifrost *Bifrost) shouldTryFallbacks(req *schemas.BifrostRequest, primaryErr *schemas.BifrostError) bool { + // If no primary error, we succeeded + if primaryErr == nil { + bifrost.logger.Debug("No primary error, we should not try fallbacks") + return false + } + + // Handle request cancellation + if primaryErr.Error != nil && primaryErr.Error.Type != nil && *primaryErr.Error.Type == schemas.RequestCancelled { + bifrost.logger.Debug("Request cancelled, we should not try fallbacks") + return false + } + + // Check if this is a short-circuit error that doesn't allow fallbacks + // Note: AllowFallbacks = nil is treated as true (allow fallbacks by default) + if primaryErr.AllowFallbacks != nil && !*primaryErr.AllowFallbacks { + bifrost.logger.Debug("AllowFallbacks is false, we should not try fallbacks") + return false + } + + // If no fallbacks configured, return primary error + _, _, fallbacks := req.GetRequestFields() + if len(fallbacks) == 0 { + bifrost.logger.Debug("No fallbacks configured, we should not try fallbacks") + return false + } + + // Should proceed with fallbacks + return true +} + +// prepareFallbackRequest creates a fallback request and validates the provider config +// Returns the fallback request or nil if this fallback should be skipped +func (bifrost *Bifrost) prepareFallbackRequest(req *schemas.BifrostRequest, fallback schemas.Fallback) *schemas.BifrostRequest { + // Check if we have config for this fallback provider + _, err := bifrost.account.GetConfigForProvider(fallback.Provider) + if err != nil { + bifrost.logger.Warn(fmt.Sprintf("Config not found for provider %s, skipping fallback: %v", fallback.Provider, err)) + return nil + } + + // Create a new request with the fallback provider and model + fallbackReq := *req + + if req.TextCompletionRequest != nil { + tmp := *req.TextCompletionRequest + tmp.Provider = fallback.Provider + tmp.Model = fallback.Model + fallbackReq.TextCompletionRequest = &tmp + } + + if req.ChatRequest != nil { + tmp := *req.ChatRequest + tmp.Provider = fallback.Provider + tmp.Model = fallback.Model + fallbackReq.ChatRequest = &tmp + } + + if req.ResponsesRequest != nil { + tmp := *req.ResponsesRequest + tmp.Provider = fallback.Provider + tmp.Model = fallback.Model + fallbackReq.ResponsesRequest = &tmp + } + + if req.EmbeddingRequest != nil { + tmp := *req.EmbeddingRequest + tmp.Provider = fallback.Provider + tmp.Model = fallback.Model + fallbackReq.EmbeddingRequest = &tmp + } + + if req.SpeechRequest != nil { + tmp := *req.SpeechRequest + tmp.Provider = fallback.Provider + tmp.Model = fallback.Model + fallbackReq.SpeechRequest = &tmp + } + + if req.TranscriptionRequest != nil { + tmp := *req.TranscriptionRequest + tmp.Provider = fallback.Provider + tmp.Model = fallback.Model + fallbackReq.TranscriptionRequest = &tmp + } + + return &fallbackReq +} + +// shouldContinueWithFallbacks processes errors from fallback attempts +// Returns true if we should continue with more fallbacks, false if we should stop +func (bifrost *Bifrost) shouldContinueWithFallbacks(fallback schemas.Fallback, fallbackErr *schemas.BifrostError) bool { + if fallbackErr.Error.Type != nil && *fallbackErr.Error.Type == schemas.RequestCancelled { + return false + } + + // Check if it was a short-circuit error that doesn't allow fallbacks + if fallbackErr.AllowFallbacks != nil && !*fallbackErr.AllowFallbacks { + return false + } + + bifrost.logger.Debug(fmt.Sprintf("Fallback provider %s failed: %s", fallback.Provider, fallbackErr.Error.Message)) + return true +} + +// handleRequest handles the request to the provider based on the request type +// It handles plugin hooks, request validation, response processing, and fallback providers. +// If the primary provider fails, it will try each fallback provider in order until one succeeds. +// It is the wrapper for all non-streaming public API methods. +func (bifrost *Bifrost) handleRequest(ctx context.Context, req *schemas.BifrostRequest) (*schemas.BifrostResponse, *schemas.BifrostError) { + defer bifrost.releaseBifrostRequest(req) + + provider, model, fallbacks := req.GetRequestFields() + + if err := validateRequest(req); err != nil { + err.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + } + return nil, err + } + + // Handle nil context early to prevent blocking + if ctx == nil { + ctx = bifrost.ctx + } + + bifrost.logger.Debug(fmt.Sprintf("Primary provider %s with model %s and %d fallbacks", provider, model, len(fallbacks))) + + // Try the primary provider first + ctx = context.WithValue(ctx, schemas.BifrostContextKeyFallbackIndex, 0) + primaryResult, primaryErr := bifrost.tryRequest(ctx, req) + if primaryErr != nil { + if primaryErr.Error != nil { + bifrost.logger.Debug(fmt.Sprintf("Primary provider %s with model %s returned error: %s", provider, model, primaryErr.Error.Message)) + } else { + bifrost.logger.Debug(fmt.Sprintf("Primary provider %s with model %s returned error: %v", provider, model, primaryErr)) + } + if len(fallbacks) > 0 { + bifrost.logger.Debug(fmt.Sprintf("Check if we should try %d fallbacks", len(fallbacks))) + } + } + + // Check if we should proceed with fallbacks + shouldTryFallbacks := bifrost.shouldTryFallbacks(req, primaryErr) + if !shouldTryFallbacks { + if primaryErr != nil { + primaryErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + } + } + return primaryResult, primaryErr + } + + // Try fallbacks in order + for i, fallback := range fallbacks { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyFallbackIndex, i+1) + bifrost.logger.Debug(fmt.Sprintf("Trying fallback provider %s with model %s", fallback.Provider, fallback.Model)) + ctx = context.WithValue(ctx, schemas.BifrostContextKeyFallbackRequestID, uuid.New().String()) + + fallbackReq := bifrost.prepareFallbackRequest(req, fallback) + if fallbackReq == nil { + bifrost.logger.Debug(fmt.Sprintf("Fallback provider %s with model %s is nil", fallback.Provider, fallback.Model)) + continue + } + + // Try the fallback provider + result, fallbackErr := bifrost.tryRequest(ctx, fallbackReq) + if fallbackErr == nil { + bifrost.logger.Debug(fmt.Sprintf("Successfully used fallback provider %s with model %s", fallback.Provider, fallback.Model)) + return result, nil + } + + // Check if we should continue with more fallbacks + if !bifrost.shouldContinueWithFallbacks(fallback, fallbackErr) { + fallbackErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: fallback.Provider, + ModelRequested: fallback.Model, + } + return nil, fallbackErr + } + } + + if primaryErr != nil { + primaryErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + } + } + + // All providers failed, return the original error + return nil, primaryErr +} + +// handleStreamRequest handles the stream request to the provider based on the request type +// It handles plugin hooks, request validation, response processing, and fallback providers. +// If the primary provider fails, it will try each fallback provider in order until one succeeds. +// It is the wrapper for all streaming public API methods. +func (bifrost *Bifrost) handleStreamRequest(ctx context.Context, req *schemas.BifrostRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + defer bifrost.releaseBifrostRequest(req) + + provider, model, fallbacks := req.GetRequestFields() + + if err := validateRequest(req); err != nil { + err.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + } + return nil, err + } + + // Handle nil context early to prevent blocking + if ctx == nil { + ctx = bifrost.ctx + } + + // Try the primary provider first + ctx = context.WithValue(ctx, schemas.BifrostContextKeyFallbackIndex, 0) + primaryResult, primaryErr := bifrost.tryStreamRequest(ctx, req) + + // Check if we should proceed with fallbacks + shouldTryFallbacks := bifrost.shouldTryFallbacks(req, primaryErr) + if !shouldTryFallbacks { + if primaryErr != nil { + primaryErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + } + } + return primaryResult, primaryErr + } + + // Try fallbacks in order + for i, fallback := range fallbacks { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyFallbackIndex, i+1) + ctx = context.WithValue(ctx, schemas.BifrostContextKeyFallbackRequestID, uuid.New().String()) + + fallbackReq := bifrost.prepareFallbackRequest(req, fallback) + if fallbackReq == nil { + continue + } + + // Try the fallback provider + result, fallbackErr := bifrost.tryStreamRequest(ctx, fallbackReq) + if fallbackErr == nil { + bifrost.logger.Debug(fmt.Sprintf("Successfully used fallback provider %s with model %s", fallback.Provider, fallback.Model)) + return result, nil + } + + // Check if we should continue with more fallbacks + if !bifrost.shouldContinueWithFallbacks(fallback, fallbackErr) { + fallbackErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: fallback.Provider, + ModelRequested: fallback.Model, + } + return nil, fallbackErr + } + } + + if primaryErr != nil { + primaryErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + } + } + + // All providers failed, return the original error + return nil, primaryErr +} + +// tryRequest is a generic function that handles common request processing logic +// It consolidates queue setup, plugin pipeline execution, enqueue logic, and response handling +func (bifrost *Bifrost) tryRequest(ctx context.Context, req *schemas.BifrostRequest) (*schemas.BifrostResponse, *schemas.BifrostError) { + provider, _, _ := req.GetRequestFields() + queue, err := bifrost.getProviderQueue(provider) + if err != nil { + return nil, newBifrostError(err) + } + + // Add MCP tools to request if MCP is configured and requested + if req.RequestType != schemas.EmbeddingRequest && + req.RequestType != schemas.SpeechRequest && + req.RequestType != schemas.TranscriptionRequest && + bifrost.mcpManager != nil { + req = bifrost.mcpManager.addMCPToolsToBifrostRequest(ctx, req) + } + + pipeline := bifrost.getPluginPipeline() + defer bifrost.releasePluginPipeline(pipeline) + + preReq, shortCircuit, preCount := pipeline.RunPreHooks(&ctx, req) + if shortCircuit != nil { + // Handle short-circuit with response (success case) + if shortCircuit.Response != nil { + resp, bifrostErr := pipeline.RunPostHooks(&ctx, shortCircuit.Response, nil, preCount) + if bifrostErr != nil { + return nil, bifrostErr + } + return resp, nil + } + // Handle short-circuit with error + if shortCircuit.Error != nil { + resp, bifrostErr := pipeline.RunPostHooks(&ctx, nil, shortCircuit.Error, preCount) + if bifrostErr != nil { + return nil, bifrostErr + } + return resp, nil + } + } + if preReq == nil { + return nil, newBifrostErrorFromMsg("bifrost request after plugin hooks cannot be nil") + } + + msg := bifrost.getChannelMessage(*preReq) + msg.Context = ctx + select { + case queue <- msg: + // Message was sent successfully + case <-ctx.Done(): + bifrost.releaseChannelMessage(msg) + return nil, newBifrostErrorFromMsg("request cancelled while waiting for queue space") + default: + if bifrost.dropExcessRequests.Load() { + bifrost.releaseChannelMessage(msg) + bifrost.logger.Warn("Request dropped: queue is full, please increase the queue size or set dropExcessRequests to false") + return nil, newBifrostErrorFromMsg("request dropped: queue is full") + } + select { + case queue <- msg: + // Message was sent successfully + case <-ctx.Done(): + bifrost.releaseChannelMessage(msg) + return nil, newBifrostErrorFromMsg("request cancelled while waiting for queue space") + } + } + + var result *schemas.BifrostResponse + var resp *schemas.BifrostResponse + pluginCount := len(*bifrost.plugins.Load()) + select { + case result = <-msg.Response: + resp, bifrostErr := pipeline.RunPostHooks(&msg.Context, result, nil, pluginCount) + if bifrostErr != nil { + bifrost.releaseChannelMessage(msg) + return nil, bifrostErr + } + bifrost.releaseChannelMessage(msg) + return resp, nil + case bifrostErrVal := <-msg.Err: + bifrostErrPtr := &bifrostErrVal + resp, bifrostErrPtr = pipeline.RunPostHooks(&msg.Context, nil, bifrostErrPtr, pluginCount) + bifrost.releaseChannelMessage(msg) + if bifrostErrPtr != nil { + return nil, bifrostErrPtr + } + return resp, nil + } +} + +// tryStreamRequest is a generic function that handles common request processing logic +// It consolidates queue setup, plugin pipeline execution, enqueue logic, and response handling +func (bifrost *Bifrost) tryStreamRequest(ctx context.Context, req *schemas.BifrostRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + provider, _, _ := req.GetRequestFields() + queue, err := bifrost.getProviderQueue(provider) + if err != nil { + return nil, newBifrostError(err) + } + + // Add MCP tools to request if MCP is configured and requested + if req.RequestType != schemas.SpeechStreamRequest && req.RequestType != schemas.TranscriptionStreamRequest && bifrost.mcpManager != nil { + req = bifrost.mcpManager.addMCPToolsToBifrostRequest(ctx, req) + } + + pipeline := bifrost.getPluginPipeline() + defer bifrost.releasePluginPipeline(pipeline) + + preReq, shortCircuit, preCount := pipeline.RunPreHooks(&ctx, req) + if shortCircuit != nil { + // Handle short-circuit with response (success case) + if shortCircuit.Response != nil { + resp, bifrostErr := pipeline.RunPostHooks(&ctx, shortCircuit.Response, nil, preCount) + if bifrostErr != nil { + return nil, bifrostErr + } + return newBifrostMessageChan(resp), nil + } + // Handle short-circuit with stream + if shortCircuit.Stream != nil { + outputStream := make(chan *schemas.BifrostStream) + + // Create a post hook runner cause pipeline object is put back in the pool on defer + pipelinePostHookRunner := func(ctx *context.Context, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError) { + return pipeline.RunPostHooks(ctx, result, err, preCount) + } + + go func() { + defer close(outputStream) + + for streamMsg := range shortCircuit.Stream { + if streamMsg == nil { + continue + } + + bifrostResponse := &schemas.BifrostResponse{} + if streamMsg.BifrostTextCompletionResponse != nil { + bifrostResponse.TextCompletionResponse = streamMsg.BifrostTextCompletionResponse + } + if streamMsg.BifrostChatResponse != nil { + bifrostResponse.ChatResponse = streamMsg.BifrostChatResponse + } + if streamMsg.BifrostResponsesStreamResponse != nil { + bifrostResponse.ResponsesStreamResponse = streamMsg.BifrostResponsesStreamResponse + } + if streamMsg.BifrostSpeechStreamResponse != nil { + bifrostResponse.SpeechStreamResponse = streamMsg.BifrostSpeechStreamResponse + } + if streamMsg.BifrostTranscriptionStreamResponse != nil { + bifrostResponse.TranscriptionStreamResponse = streamMsg.BifrostTranscriptionStreamResponse + } + + // Run post hooks on the stream message + processedResponse, processedError := pipelinePostHookRunner(&ctx, bifrostResponse, streamMsg.BifrostError) + + streamResponse := &schemas.BifrostStream{} + if processedResponse != nil { + streamResponse.BifrostTextCompletionResponse = processedResponse.TextCompletionResponse + streamResponse.BifrostChatResponse = processedResponse.ChatResponse + streamResponse.BifrostResponsesStreamResponse = processedResponse.ResponsesStreamResponse + streamResponse.BifrostSpeechStreamResponse = processedResponse.SpeechStreamResponse + streamResponse.BifrostTranscriptionStreamResponse = processedResponse.TranscriptionStreamResponse + } + if processedError != nil { + streamResponse.BifrostError = processedError + } + + // Send the processed message to the output stream + outputStream <- streamResponse + + //TODO: Release the processed response immediately after use + } + }() + + return outputStream, nil + } + // Handle short-circuit with error + if shortCircuit.Error != nil { + resp, bifrostErr := pipeline.RunPostHooks(&ctx, nil, shortCircuit.Error, preCount) + if bifrostErr != nil { + return nil, bifrostErr + } + return newBifrostMessageChan(resp), nil + } + } + if preReq == nil { + return nil, newBifrostErrorFromMsg("bifrost request after plugin hooks cannot be nil") + } + + msg := bifrost.getChannelMessage(*preReq) + msg.Context = ctx + + select { + case queue <- msg: + // Message was sent successfully + case <-ctx.Done(): + bifrost.releaseChannelMessage(msg) + return nil, newBifrostErrorFromMsg("request cancelled while waiting for queue space") + default: + if bifrost.dropExcessRequests.Load() { + bifrost.releaseChannelMessage(msg) + bifrost.logger.Warn("Request dropped: queue is full, please increase the queue size or set dropExcessRequests to false") + return nil, newBifrostErrorFromMsg("request dropped: queue is full") + } + select { + case queue <- msg: + // Message was sent successfully + case <-ctx.Done(): + bifrost.releaseChannelMessage(msg) + return nil, newBifrostErrorFromMsg("request cancelled while waiting for queue space") + } + } + + select { + case stream := <-msg.ResponseStream: + bifrost.releaseChannelMessage(msg) + return stream, nil + case bifrostErrVal := <-msg.Err: + if bifrostErrVal.Error != nil { + bifrost.logger.Debug("error while executing stream request: %s", bifrostErrVal.Error.Message) + } else { + bifrost.logger.Debug("error while executing stream request: %+v", bifrostErrVal) + } + // Marking final chunk + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + // On error we will complete post-hooks + recoveredResp, recoveredErr := pipeline.RunPostHooks(&ctx, nil, &bifrostErrVal, len(*bifrost.plugins.Load())) + bifrost.releaseChannelMessage(msg) + if recoveredErr != nil { + return nil, recoveredErr + } + if recoveredResp != nil { + return newBifrostMessageChan(recoveredResp), nil + } + return nil, &bifrostErrVal + } +} + +// executeRequestWithRetries is a generic function that handles common request processing logic +// It consolidates retry logic, backoff calculation, and error handling +// It is not a bifrost method because interface methods in go cannot be generic +func executeRequestWithRetries[T any]( + ctx *context.Context, + config *schemas.ProviderConfig, + requestHandler func() (T, *schemas.BifrostError), + requestType schemas.RequestType, + providerKey schemas.ModelProvider, + model string, +) (T, *schemas.BifrostError) { + var result T + var bifrostError *schemas.BifrostError + var attempts int + + for attempts = 0; attempts <= config.NetworkConfig.MaxRetries; attempts++ { + *ctx = context.WithValue(*ctx, schemas.BifrostContextKeyNumberOfRetries, attempts) + if attempts > 0 { + // Log retry attempt + var retryMsg string + if bifrostError != nil && bifrostError.Error != nil { + retryMsg = bifrostError.Error.Message + } else if bifrostError != nil && bifrostError.StatusCode != nil { + retryMsg = fmt.Sprintf("status=%d", *bifrostError.StatusCode) + if bifrostError.Type != nil { + retryMsg += ", type=" + *bifrostError.Type + } + } + logger.Debug("retrying request (attempt %d/%d) for model %s: %s", attempts, config.NetworkConfig.MaxRetries, model, retryMsg) + + // Calculate and apply backoff + backoff := calculateBackoff(attempts-1, config) + time.Sleep(backoff) + } + + logger.Debug("attempting %s request for provider %s", requestType, providerKey) + + // Attempt the request + result, bifrostError = requestHandler() + + logger.Debug("request %s for provider %s completed", requestType, providerKey) + + // Check if successful or if we should retry + if bifrostError == nil || + bifrostError.IsBifrostError || + (bifrostError.Error != nil && bifrostError.Error.Type != nil && *bifrostError.Error.Type == schemas.RequestCancelled) { + break + } + + // Check if we should retry based on status code or error message + shouldRetry := false + + if bifrostError.Error != nil && bifrostError.Error.Message == schemas.ErrProviderDoRequest { + shouldRetry = true + logger.Debug("detected request HTTP error, will retry: %s", bifrostError.Error.Message) + } + + // Retry if status code or error object indicates rate limiting + if (bifrostError.StatusCode != nil && retryableStatusCodes[*bifrostError.StatusCode]) || + (bifrostError.Error != nil && + (IsRateLimitErrorMessage(bifrostError.Error.Message) || + (bifrostError.Error.Type != nil && IsRateLimitErrorMessage(*bifrostError.Error.Type)))) { + shouldRetry = true + logger.Debug("detected rate limit error in message, will retry: %s", bifrostError.Error.Message) + } + + if !shouldRetry { + break + } + } + + // Add retry information to error + if attempts > 0 { + logger.Debug("request failed after %d %s", attempts, map[bool]string{true: "retries", false: "retry"}[attempts > 1]) + } + + return result, bifrostError +} + +// requestWorker handles incoming requests from the queue for a specific provider. +// It manages retries, error handling, and response processing. +func (bifrost *Bifrost) requestWorker(provider schemas.Provider, config *schemas.ProviderConfig, queue chan *ChannelMessage) { + defer func() { + if waitGroupValue, ok := bifrost.waitGroups.Load(provider.GetProviderKey()); ok { + waitGroup := waitGroupValue.(*sync.WaitGroup) + waitGroup.Done() + } + }() + + for { + select { + case req, ok := <-queue: + if !ok { + // Queue closed, exit worker. + return + } + bifrost.processRequest(provider, config, req) + case <-bifrost.ctx.Done(): + // Context cancelled, drain all remaining queue items until queue is closed. + // Use blocking receive to ensure we don't miss items enqueued between + // context cancellation and queue closure. + for { + req, ok := <-queue + if !ok { + // Queue closed, exit. + return + } + // Send cancellation error with timeout to prevent blocking. + select { + case req.Err <- schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "bifrost context cancelled", + Error: context.Canceled, + }, + }: + case <-req.Context.Done(): + // Client context cancelled, skip. + case <-time.After(100 * time.Millisecond): + // Timeout sending error, continue draining. + } + } + } + } +} + +// processRequest handles a single request from the queue. +func (bifrost *Bifrost) processRequest(provider schemas.Provider, config *schemas.ProviderConfig, req *ChannelMessage) { + _, model, _ := req.BifrostRequest.GetRequestFields() + + var result *schemas.BifrostResponse + var stream chan *schemas.BifrostStream + var bifrostError *schemas.BifrostError + var err error + + // Determine the base provider type for key requirement checks. + baseProvider := provider.GetProviderKey() + if cfg := config.CustomProviderConfig; cfg != nil && cfg.BaseProviderType != "" { + baseProvider = cfg.BaseProviderType + } + + key := schemas.Key{} + if providerRequiresKey(baseProvider, config.CustomProviderConfig) { + // Use the custom provider name for actual key selection, but pass base provider type for key validation. + key, err = bifrost.selectKeyFromProviderForModel(&req.Context, req.RequestType, provider.GetProviderKey(), model, baseProvider) + if err != nil { + bifrost.logger.Warn("error selecting key for model %s: %v", model, err) + req.Err <- schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: err.Error(), + Error: err, + }, + } + return + } + req.Context = context.WithValue(req.Context, schemas.BifrostContextKeySelectedKeyID, key.ID) + req.Context = context.WithValue(req.Context, schemas.BifrostContextKeySelectedKeyName, key.Name) + } + // Create plugin pipeline for streaming requests outside retry loop to prevent leaks. + // Do not release the pipeline for streaming requests to avoid data race with provider goroutines. + var postHookRunner schemas.PostHookRunner + var pipeline *PluginPipeline + if IsStreamRequestType(req.RequestType) { + pipeline = bifrost.getPluginPipeline() + postHookRunner = func(ctx *context.Context, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError) { + resp, bifrostErr := pipeline.RunPostHooks(ctx, result, err, len(*bifrost.plugins.Load())) + if bifrostErr != nil { + return nil, bifrostErr + } + return resp, nil + } + } + + // Execute request with retries. + if IsStreamRequestType(req.RequestType) { + stream, bifrostError = executeRequestWithRetries(&req.Context, config, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return bifrost.handleProviderStreamRequest(provider, req, key, postHookRunner) + }, req.RequestType, provider.GetProviderKey(), model) + } else { + result, bifrostError = executeRequestWithRetries(&req.Context, config, func() (*schemas.BifrostResponse, *schemas.BifrostError) { + return bifrost.handleProviderRequest(provider, req, key) + }, req.RequestType, provider.GetProviderKey(), model) + } + + // Do not release the pipeline for streaming requests to avoid data race. + // The pipeline will be garbage collected when no longer referenced. + // This is a trade-off: we avoid the data race but don't reuse pipelines for streaming requests. + + if bifrostError != nil { + bifrostError.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: provider.GetProviderKey(), + ModelRequested: model, + RequestType: req.RequestType, + } + + // Send error with context awareness to prevent deadlock. + select { + case req.Err <- *bifrostError: + // Error sent successfully. + case <-req.Context.Done(): + // Client no longer listening, log and continue. + bifrost.logger.Debug("Client context cancelled while sending error response") + case <-time.After(5 * time.Second): + // Timeout to prevent indefinite blocking. + bifrost.logger.Warn("Timeout while sending error response, client may have disconnected") + } + } else { + if IsStreamRequestType(req.RequestType) { + // Send stream with context awareness to prevent deadlock. + select { + case req.ResponseStream <- stream: + // Stream sent successfully. + case <-req.Context.Done(): + // Client no longer listening, log and continue. + bifrost.logger.Debug("Client context cancelled while sending stream response") + case <-time.After(5 * time.Second): + // Timeout to prevent indefinite blocking. + bifrost.logger.Warn("Timeout while sending stream response, client may have disconnected") + } + } else { + // Send response with context awareness to prevent deadlock. + select { + case req.Response <- result: + // Response sent successfully. + case <-req.Context.Done(): + // Client no longer listening, log and continue. + bifrost.logger.Debug("Client context cancelled while sending response") + case <-time.After(5 * time.Second): + // Timeout to prevent indefinite blocking. + bifrost.logger.Warn("Timeout while sending response, client may have disconnected") + } + } + } +} + +// handleProviderRequest handles the request to the provider based on the request type +func (bifrost *Bifrost) handleProviderRequest(provider schemas.Provider, req *ChannelMessage, key schemas.Key) (*schemas.BifrostResponse, *schemas.BifrostError) { + response := &schemas.BifrostResponse{} + switch req.RequestType { + case schemas.TextCompletionRequest: + textCompletionResponse, bifrostError := provider.TextCompletion(req.Context, key, req.BifrostRequest.TextCompletionRequest) + if bifrostError != nil { + return nil, bifrostError + } + response.TextCompletionResponse = textCompletionResponse + case schemas.ChatCompletionRequest: + chatCompletionResponse, bifrostError := provider.ChatCompletion(req.Context, key, req.BifrostRequest.ChatRequest) + if bifrostError != nil { + return nil, bifrostError + } + response.ChatResponse = chatCompletionResponse + case schemas.ResponsesRequest: + responsesResponse, bifrostError := provider.Responses(req.Context, key, req.BifrostRequest.ResponsesRequest) + if bifrostError != nil { + return nil, bifrostError + } + response.ResponsesResponse = responsesResponse + case schemas.EmbeddingRequest: + embeddingResponse, bifrostError := provider.Embedding(req.Context, key, req.BifrostRequest.EmbeddingRequest) + if bifrostError != nil { + return nil, bifrostError + } + response.EmbeddingResponse = embeddingResponse + case schemas.SpeechRequest: + speechResponse, bifrostError := provider.Speech(req.Context, key, req.BifrostRequest.SpeechRequest) + if bifrostError != nil { + return nil, bifrostError + } + response.SpeechResponse = speechResponse + case schemas.TranscriptionRequest: + transcriptionResponse, bifrostError := provider.Transcription(req.Context, key, req.BifrostRequest.TranscriptionRequest) + if bifrostError != nil { + return nil, bifrostError + } + response.TranscriptionResponse = transcriptionResponse + default: + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("unsupported request type: %s", req.RequestType), + }, + } + } + return response, nil +} + +// handleProviderStreamRequest handles the stream request to the provider based on the request type +func (bifrost *Bifrost) handleProviderStreamRequest(provider schemas.Provider, req *ChannelMessage, key schemas.Key, postHookRunner schemas.PostHookRunner) (chan *schemas.BifrostStream, *schemas.BifrostError) { + switch req.RequestType { + case schemas.TextCompletionStreamRequest: + return provider.TextCompletionStream(req.Context, postHookRunner, key, req.BifrostRequest.TextCompletionRequest) + case schemas.ChatCompletionStreamRequest: + return provider.ChatCompletionStream(req.Context, postHookRunner, key, req.BifrostRequest.ChatRequest) + case schemas.ResponsesStreamRequest: + return provider.ResponsesStream(req.Context, postHookRunner, key, req.BifrostRequest.ResponsesRequest) + case schemas.SpeechStreamRequest: + return provider.SpeechStream(req.Context, postHookRunner, key, req.BifrostRequest.SpeechRequest) + case schemas.TranscriptionStreamRequest: + return provider.TranscriptionStream(req.Context, postHookRunner, key, req.BifrostRequest.TranscriptionRequest) + default: + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("unsupported request type: %s", req.RequestType), + }, + } + } +} + +// PLUGIN MANAGEMENT + +// RunPreHooks executes PreHooks in order, tracks how many ran, and returns the final request, any short-circuit decision, and the count. +func (p *PluginPipeline) RunPreHooks(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, int) { + var shortCircuit *schemas.PluginShortCircuit + var err error + for i, plugin := range p.plugins { + p.logger.Debug("running pre-hook for plugin %s", plugin.GetName()) + req, shortCircuit, err = plugin.PreHook(ctx, req) + if err != nil { + p.preHookErrors = append(p.preHookErrors, err) + p.logger.Warn("error in PreHook for plugin %s: %v", plugin.GetName(), err) + } + p.executedPreHooks = i + 1 + if shortCircuit != nil { + return req, shortCircuit, p.executedPreHooks // short-circuit: only plugins up to and including i ran + } + } + return req, nil, p.executedPreHooks +} + +// RunPostHooks executes PostHooks in reverse order for the plugins whose PreHook ran. +// Accepts the response and error, and allows plugins to transform either (e.g., recover from error, or invalidate a response). +// Returns the final response and error after all hooks. If both are set, error takes precedence unless error is nil. +// runFrom is the count of plugins whose PreHooks ran; PostHooks will run in reverse from index (runFrom - 1) down to 0 +func (p *PluginPipeline) RunPostHooks(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError, runFrom int) (*schemas.BifrostResponse, *schemas.BifrostError) { + // Defensive: ensure count is within valid bounds + if runFrom < 0 { + runFrom = 0 + } + if runFrom > len(p.plugins) { + runFrom = len(p.plugins) + } + var err error + for i := runFrom - 1; i >= 0; i-- { + plugin := p.plugins[i] + p.logger.Debug("running post-hook for plugin %s", plugin.GetName()) + resp, bifrostErr, err = plugin.PostHook(ctx, resp, bifrostErr) + if err != nil { + p.postHookErrors = append(p.postHookErrors, err) + p.logger.Warn("error in PostHook for plugin %s: %v", plugin.GetName(), err) + } + // If a plugin recovers from an error (sets bifrostErr to nil and sets resp), allow that + // If a plugin invalidates a response (sets resp to nil and sets bifrostErr), allow that + } + // Final logic: if both are set, error takes precedence, unless error is nil + if bifrostErr != nil { + if resp != nil && bifrostErr.StatusCode == nil && bifrostErr.Error != nil && bifrostErr.Error.Type == nil && + bifrostErr.Error.Message == "" && bifrostErr.Error.Error == nil { + // Defensive: treat as recovery if error is empty + return resp, nil + } + return resp, bifrostErr + } + return resp, nil +} + +// resetPluginPipeline resets a PluginPipeline instance for reuse +func (p *PluginPipeline) resetPluginPipeline() { + p.executedPreHooks = 0 + p.preHookErrors = p.preHookErrors[:0] + p.postHookErrors = p.postHookErrors[:0] +} + +// getPluginPipeline gets a PluginPipeline from the pool and configures it +func (bifrost *Bifrost) getPluginPipeline() *PluginPipeline { + pipeline := bifrost.pluginPipelinePool.Get().(*PluginPipeline) + pipeline.plugins = *bifrost.plugins.Load() + pipeline.logger = bifrost.logger + return pipeline +} + +// releasePluginPipeline returns a PluginPipeline to the pool +func (bifrost *Bifrost) releasePluginPipeline(pipeline *PluginPipeline) { + pipeline.resetPluginPipeline() + bifrost.pluginPipelinePool.Put(pipeline) +} + +// POOL & RESOURCE MANAGEMENT + +// getChannelMessage gets a ChannelMessage from the pool and configures it with the request. +// It also gets response and error channels from their respective pools. +func (bifrost *Bifrost) getChannelMessage(req schemas.BifrostRequest) *ChannelMessage { + // Get channels from pool + responseChan := bifrost.responseChannelPool.Get().(chan *schemas.BifrostResponse) + errorChan := bifrost.errorChannelPool.Get().(chan schemas.BifrostError) + + // Clear any previous values to avoid leaking between requests + select { + case <-responseChan: + default: + } + select { + case <-errorChan: + default: + } + + // Get message from pool and configure it + msg := bifrost.channelMessagePool.Get().(*ChannelMessage) + msg.BifrostRequest = req + msg.Response = responseChan + msg.Err = errorChan + + // Conditionally allocate ResponseStream for streaming requests only + if IsStreamRequestType(req.RequestType) { + responseStreamChan := bifrost.responseStreamPool.Get().(chan chan *schemas.BifrostStream) + // Clear any previous values to avoid leaking between requests + select { + case <-responseStreamChan: + default: + } + msg.ResponseStream = responseStreamChan + } + + return msg +} + +// releaseChannelMessage returns a ChannelMessage and its channels to their respective pools. +func (bifrost *Bifrost) releaseChannelMessage(msg *ChannelMessage) { + // Put channels back in pools + bifrost.responseChannelPool.Put(msg.Response) + bifrost.errorChannelPool.Put(msg.Err) + + // Return ResponseStream to pool if it was used + if msg.ResponseStream != nil { + // Drain any remaining channels to prevent memory leaks + select { + case <-msg.ResponseStream: + default: + } + bifrost.responseStreamPool.Put(msg.ResponseStream) + } + + // Release of Bifrost Request is handled in handle methods as they are required for fallbacks + + // Clear references and return to pool + msg.Response = nil + msg.ResponseStream = nil + msg.Err = nil + bifrost.channelMessagePool.Put(msg) +} + +// resetBifrostRequest resets a BifrostRequest instance for reuse +func resetBifrostRequest(req *schemas.BifrostRequest) { + req.RequestType = "" + req.TextCompletionRequest = nil + req.ChatRequest = nil + req.ResponsesRequest = nil + req.EmbeddingRequest = nil + req.SpeechRequest = nil + req.TranscriptionRequest = nil +} + +// getBifrostRequest gets a BifrostRequest from the pool +func (bifrost *Bifrost) getBifrostRequest() *schemas.BifrostRequest { + req := bifrost.bifrostRequestPool.Get().(*schemas.BifrostRequest) + return req +} + +// releaseBifrostRequest returns a BifrostRequest to the pool +func (bifrost *Bifrost) releaseBifrostRequest(req *schemas.BifrostRequest) { + resetBifrostRequest(req) + bifrost.bifrostRequestPool.Put(req) +} + +// getAllSupportedKeys retrieves all valid keys for a ListModels request. +// allowing the provider to aggregate results from multiple keys. +func (bifrost *Bifrost) getAllSupportedKeys(ctx *context.Context, providerKey schemas.ModelProvider, baseProviderType schemas.ModelProvider) ([]schemas.Key, error) { + // Check if key has been set in the context explicitly + if ctx != nil { + key, ok := (*ctx).Value(schemas.BifrostContextKeyDirectKey).(schemas.Key) + if ok { + // If a direct key is specified, return it as a single-element slice + return []schemas.Key{key}, nil + } + } + + keys, err := bifrost.account.GetKeysForProvider(ctx, providerKey) + if err != nil { + return nil, err + } + + if len(keys) == 0 { + return nil, fmt.Errorf("no keys found for provider: %v", providerKey) + } + + // Filter keys for ListModels - only check if key has a value + var supportedKeys []schemas.Key + for _, k := range keys { + if strings.TrimSpace(k.Value) != "" || canProviderKeyValueBeEmpty(baseProviderType) { + supportedKeys = append(supportedKeys, k) + } + } + + if len(supportedKeys) == 0 { + return nil, fmt.Errorf("no valid keys found for provider: %v", providerKey) + } + + return supportedKeys, nil +} + +// selectKeyFromProviderForModel selects an appropriate API key for a given provider and model. +// It uses weighted random selection if multiple keys are available. +func (bifrost *Bifrost) selectKeyFromProviderForModel(ctx *context.Context, requestType schemas.RequestType, providerKey schemas.ModelProvider, model string, baseProviderType schemas.ModelProvider) (schemas.Key, error) { + // Check if key has been set in the context explicitly + if ctx != nil { + key, ok := (*ctx).Value(schemas.BifrostContextKeyDirectKey).(schemas.Key) + if ok { + return key, nil + } + } + + if skipKeySelection, ok := (*ctx).Value(schemas.BifrostContextKeySkipKeySelection).(bool); ok && skipKeySelection && isKeySkippingAllowed(providerKey) { + return schemas.Key{}, nil + } + + keys, err := bifrost.account.GetKeysForProvider(ctx, providerKey) + if err != nil { + return schemas.Key{}, err + } + + if len(keys) == 0 { + return schemas.Key{}, fmt.Errorf("no keys found for provider: %v and model: %s", providerKey, model) + } + + // filter out keys which dont support the model, if the key has no models, it is supported for all models + var supportedKeys []schemas.Key + if requestType == schemas.ListModelsRequest { + // Skip deployment check but still check if the key has a value + for _, k := range keys { + if strings.TrimSpace(k.Value) != "" || canProviderKeyValueBeEmpty(baseProviderType) { + supportedKeys = append(supportedKeys, k) + } + } + } else { + for _, key := range keys { + modelSupported := (slices.Contains(key.Models, model) && (strings.TrimSpace(key.Value) != "" || canProviderKeyValueBeEmpty(baseProviderType))) || len(key.Models) == 0 + + // Additional deployment checks for Azure and Bedrock + deploymentSupported := true + if baseProviderType == schemas.Azure && key.AzureKeyConfig != nil { + // For Azure, check if deployment exists for this model + if len(key.AzureKeyConfig.Deployments) > 0 { + _, deploymentSupported = key.AzureKeyConfig.Deployments[model] + } + } else if baseProviderType == schemas.Bedrock && key.BedrockKeyConfig != nil { + // For Bedrock, check if deployment exists for this model + if len(key.BedrockKeyConfig.Deployments) > 0 { + _, deploymentSupported = key.BedrockKeyConfig.Deployments[model] + } + } + + if modelSupported && deploymentSupported { + supportedKeys = append(supportedKeys, key) + } + } + } + if len(supportedKeys) == 0 { + if baseProviderType == schemas.Azure || baseProviderType == schemas.Bedrock { + return schemas.Key{}, fmt.Errorf("no keys found that support model/deployment: %s", model) + } + return schemas.Key{}, fmt.Errorf("no keys found that support model: %s", model) + } + + if len(supportedKeys) == 1 { + return supportedKeys[0], nil + } + + selectedKey, err := bifrost.keySelector(ctx, supportedKeys, providerKey, model) + if err != nil { + return schemas.Key{}, err + } + + return selectedKey, nil + +} + +func WeightedRandomKeySelector(ctx *context.Context, keys []schemas.Key, providerKey schemas.ModelProvider, model string) (schemas.Key, error) { + // Use a weighted random selection based on key weights + totalWeight := 0 + for _, key := range keys { + totalWeight += int(key.Weight * 100) // Convert float to int for better performance + } + + // Use a fast random number generator + randomSource := rand.New(rand.NewSource(time.Now().UnixNano())) + randomValue := randomSource.Intn(totalWeight) + + // Select key based on weight + currentWeight := 0 + for _, key := range keys { + currentWeight += int(key.Weight * 100) + if randomValue < currentWeight { + return key, nil + } + } + + // Fallback to first key if something goes wrong + return keys[0], nil +} + +// Shutdown gracefully stops all workers when triggered. +// It closes all request channels and waits for workers to exit. +func (bifrost *Bifrost) Shutdown() { + bifrost.logger.Info("closing all request channels...") + + // Cancel the context if not already cancelled to signal provider goroutines to stop. + if bifrost.cancel != nil && bifrost.ctx.Err() == nil { + bifrost.cancel() + } + + // Close all provider queues to signal workers to stop. + // This must happen even if context is already cancelled - workers in drain mode need this signal. + bifrost.requestQueues.Range(func(key, value interface{}) bool { + queue := value.(chan *ChannelMessage) + select { + case <-queue: + // Queue already closed. + default: + close(queue) + } + return true + }) + + // Wait for all workers to exit. + bifrost.waitGroups.Range(func(key, value interface{}) bool { + waitGroup := value.(*sync.WaitGroup) + waitGroup.Wait() + return true + }) + + // Cleanup MCP manager + if bifrost.mcpManager != nil { + err := bifrost.mcpManager.cleanup() + if err != nil { + bifrost.logger.Warn(fmt.Sprintf("Error cleaning up MCP manager: %s", err.Error())) + } + } + + // Cleanup plugins + for _, plugin := range *bifrost.plugins.Load() { + err := plugin.Cleanup() + if err != nil { + bifrost.logger.Warn(fmt.Sprintf("Error cleaning up plugin: %s", err.Error())) + } + } + bifrost.logger.Info("all request channels closed") } diff --git a/core/bifrost_test.go b/core/bifrost_test.go new file mode 100644 index 000000000..abe06b6d4 --- /dev/null +++ b/core/bifrost_test.go @@ -0,0 +1,1048 @@ +package bifrost + +import ( + "context" + "fmt" + "runtime" + "strings" + "testing" + "time" + + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/assert" +) + +// Mock time.Sleep to avoid real delays in tests +var mockSleep func(time.Duration) + +// Override time.Sleep in tests and setup logger +func init() { + mockSleep = func(d time.Duration) { + // Do nothing in tests to avoid real delays + } + + // Setup test logger to avoid nil pointer dereference + logger = NewDefaultLogger(schemas.LogLevelError) // Use error level to keep tests quiet +} + +// Helper function to create test config with specific retry settings +func createTestConfig(maxRetries int, initialBackoff, maxBackoff time.Duration) *schemas.ProviderConfig { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + MaxRetries: maxRetries, + RetryBackoffInitial: initialBackoff, + RetryBackoffMax: maxBackoff, + }, + } +} + +// Helper function to create a BifrostError +func createBifrostError(message string, statusCode *int, errorType *string, isBifrostError bool) *schemas.BifrostError { + return &schemas.BifrostError{ + IsBifrostError: isBifrostError, + StatusCode: statusCode, + Error: &schemas.ErrorField{ + Message: message, + Type: errorType, + }, + } +} + +// Test executeRequestWithRetries - success scenarios +func TestExecuteRequestWithRetries_SuccessScenarios(t *testing.T) { + config := createTestConfig(3, 100*time.Millisecond, 1*time.Second) + ctx := context.Background() + + // Test immediate success + t.Run("ImmediateSuccess", func(t *testing.T) { + callCount := 0 + handler := func() (string, *schemas.BifrostError) { + callCount++ + return "success", nil + } + + result, err := executeRequestWithRetries( + &ctx, + config, + handler, + schemas.ChatCompletionRequest, + schemas.OpenAI, + "gpt-4", + ) + + if callCount != 1 { + t.Errorf("Expected 1 call, got %d", callCount) + } + if result != "success" { + t.Errorf("Expected 'success', got %s", result) + } + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + // Test success after retries + t.Run("SuccessAfterRetries", func(t *testing.T) { + callCount := 0 + handler := func() (string, *schemas.BifrostError) { + callCount++ + if callCount <= 2 { + // First two calls fail with retryable error + return "", createBifrostError("rate limit exceeded", Ptr(429), nil, false) + } + // Third call succeeds + return "success", nil + } + + result, err := executeRequestWithRetries( + &ctx, + config, + handler, + schemas.ChatCompletionRequest, + schemas.OpenAI, + "gpt-4", + ) + + if callCount != 3 { + t.Errorf("Expected 3 calls, got %d", callCount) + } + if result != "success" { + t.Errorf("Expected 'success', got %s", result) + } + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) +} + +// Test executeRequestWithRetries - retry limits +func TestExecuteRequestWithRetries_RetryLimits(t *testing.T) { + config := createTestConfig(2, 100*time.Millisecond, 1*time.Second) + ctx := context.Background() + t.Run("ExceedsMaxRetries", func(t *testing.T) { + callCount := 0 + handler := func() (string, *schemas.BifrostError) { + callCount++ + // Always fail with retryable error + return "", createBifrostError("rate limit exceeded", Ptr(429), nil, false) + } + + result, err := executeRequestWithRetries( + &ctx, + config, + handler, + schemas.ChatCompletionRequest, + schemas.OpenAI, + "gpt-4", + ) + + // Should try: initial + 2 retries = 3 total attempts + if callCount != 3 { + t.Errorf("Expected 3 calls (initial + 2 retries), got %d", callCount) + } + if result != "" { + t.Errorf("Expected empty result, got %s", result) + } + if err == nil { + t.Fatal("Expected error after exceeding max retries") + } + if err.Error == nil { + t.Fatal("Expected error structure, got nil") + } + if err.Error.Message != "rate limit exceeded" { + t.Errorf("Expected rate limit error, got %s", err.Error.Message) + } + }) +} + +// Test executeRequestWithRetries - non-retryable errors +func TestExecuteRequestWithRetries_NonRetryableErrors(t *testing.T) { + config := createTestConfig(3, 100*time.Millisecond, 1*time.Second) + ctx := context.Background() + testCases := []struct { + name string + error *schemas.BifrostError + }{ + { + name: "BifrostError", + error: createBifrostError("validation error", nil, nil, true), + }, + { + name: "RequestCancelled", + error: createBifrostError("request cancelled", nil, Ptr(schemas.ErrRequestCancelled), false), + }, + { + name: "Non-retryable status code", + error: createBifrostError("bad request", Ptr(400), nil, false), + }, + { + name: "Non-retryable error message", + error: createBifrostError("invalid model", nil, nil, false), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + callCount := 0 + handler := func() (string, *schemas.BifrostError) { + callCount++ + return "", tc.error + } + + result, err := executeRequestWithRetries( + &ctx, + config, + handler, + schemas.ChatCompletionRequest, + schemas.OpenAI, + "gpt-4", + ) + + if callCount != 1 { + t.Errorf("Expected 1 call (no retries), got %d", callCount) + } + if result != "" { + t.Errorf("Expected empty result, got %s", result) + } + if err != tc.error { + t.Error("Expected original error to be returned") + } + }) + } +} + +// Test executeRequestWithRetries - retryable conditions +func TestExecuteRequestWithRetries_RetryableConditions(t *testing.T) { + config := createTestConfig(1, 100*time.Millisecond, 1*time.Second) + ctx := context.Background() + testCases := []struct { + name string + error *schemas.BifrostError + }{ + { + name: "StatusCode_500", + error: createBifrostError("internal server error", Ptr(500), nil, false), + }, + { + name: "StatusCode_502", + error: createBifrostError("bad gateway", Ptr(502), nil, false), + }, + { + name: "StatusCode_503", + error: createBifrostError("service unavailable", Ptr(503), nil, false), + }, + { + name: "StatusCode_504", + error: createBifrostError("gateway timeout", Ptr(504), nil, false), + }, + { + name: "StatusCode_429", + error: createBifrostError("too many requests", Ptr(429), nil, false), + }, + { + name: "ErrProviderDoRequest", + error: createBifrostError(schemas.ErrProviderDoRequest, nil, nil, false), + }, + { + name: "RateLimitMessage", + error: createBifrostError("rate limit exceeded", nil, nil, false), + }, + { + name: "RateLimitType", + error: createBifrostError("some error", nil, Ptr("rate_limit"), false), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + callCount := 0 + handler := func() (string, *schemas.BifrostError) { + callCount++ + return "", tc.error + } + + result, err := executeRequestWithRetries( + &ctx, + config, + handler, + schemas.ChatCompletionRequest, + schemas.OpenAI, + "gpt-4", + ) + + // Should try: initial + 1 retry = 2 total attempts + if callCount != 2 { + t.Errorf("Expected 2 calls (initial + 1 retry), got %d", callCount) + } + if result != "" { + t.Errorf("Expected empty result, got %s", result) + } + if err != tc.error { + t.Error("Expected original error to be returned") + } + }) + } +} + +// Test calculateBackoff - exponential growth (base calculations without jitter) +func TestCalculateBackoff_ExponentialGrowth(t *testing.T) { + config := createTestConfig(5, 100*time.Millisecond, 5*time.Second) + + // Test the base exponential calculation by checking that results fall within expected ranges + // Since we can't easily mock rand.Float64, we'll test the bounds instead + testCases := []struct { + attempt int + minExpected time.Duration + maxExpected time.Duration + }{ + {0, 80 * time.Millisecond, 120 * time.Millisecond}, // 100ms Β± 20% + {1, 160 * time.Millisecond, 240 * time.Millisecond}, // 200ms Β± 20% + {2, 320 * time.Millisecond, 480 * time.Millisecond}, // 400ms Β± 20% + {3, 640 * time.Millisecond, 960 * time.Millisecond}, // 800ms Β± 20% + {4, 1280 * time.Millisecond, 1920 * time.Millisecond}, // 1600ms Β± 20% + {5, 2560 * time.Millisecond, 3840 * time.Millisecond}, // 3200ms Β± 20% + {10, 4 * time.Second, 6 * time.Second}, // should be capped at max (5s) Β± 20% + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("Attempt_%d", tc.attempt), func(t *testing.T) { + backoff := calculateBackoff(tc.attempt, config) + if backoff < tc.minExpected || backoff > tc.maxExpected { + t.Errorf("Backoff %v outside expected range [%v, %v]", backoff, tc.minExpected, tc.maxExpected) + } + }) + } +} + +// Test calculateBackoff - jitter bounds +func TestCalculateBackoff_JitterBounds(t *testing.T) { + config := createTestConfig(3, 100*time.Millisecond, 5*time.Second) + + // Test jitter bounds for multiple attempts + for attempt := 0; attempt < 3; attempt++ { + t.Run(fmt.Sprintf("Attempt_%d_JitterBounds", attempt), func(t *testing.T) { + // Calculate expected base backoff + baseBackoff := config.NetworkConfig.RetryBackoffInitial * time.Duration(1< config.NetworkConfig.RetryBackoffMax { + baseBackoff = config.NetworkConfig.RetryBackoffMax + } + + // Test multiple samples to verify jitter bounds + for i := 0; i < 100; i++ { + backoff := calculateBackoff(attempt, config) + + // Jitter should be Β±20% (0.8 to 1.2 multiplier) + minExpected := time.Duration(float64(baseBackoff) * 0.8) + maxExpected := time.Duration(float64(baseBackoff) * 1.2) + + if backoff < minExpected || backoff > maxExpected { + t.Errorf("Backoff %v outside expected range [%v, %v] for attempt %d", + backoff, minExpected, maxExpected, attempt) + } + } + }) + } +} + +// Test calculateBackoff - max backoff cap +func TestCalculateBackoff_MaxBackoffCap(t *testing.T) { + config := createTestConfig(10, 100*time.Millisecond, 500*time.Millisecond) + + // High attempt numbers should be capped at max backoff + for attempt := 5; attempt < 10; attempt++ { + backoff := calculateBackoff(attempt, config) + + // Even with jitter, should not exceed 1.2 * max (120% of max) + maxWithJitter := time.Duration(float64(config.NetworkConfig.RetryBackoffMax) * 1.2) + if backoff > maxWithJitter { + t.Errorf("Backoff %v exceeds max with jitter %v for attempt %d", + backoff, maxWithJitter, attempt) + } + } +} + +// Test IsRateLimitErrorMessage - all patterns +func TestIsRateLimitError_AllPatterns(t *testing.T) { + // Test all patterns from rateLimitPatterns + patterns := []string{ + "rate limit", + "rate_limit", + "ratelimit", + "too many requests", + "quota exceeded", + "quota_exceeded", + "request limit", + "throttled", + "throttling", + "rate exceeded", + "limit exceeded", + "requests per", + "rpm exceeded", + "tpm exceeded", + "tokens per minute", + "requests per minute", + "requests per second", + "api rate limit", + "usage limit", + "concurrent requests limit", + } + + for _, pattern := range patterns { + t.Run(fmt.Sprintf("Pattern_%s", strings.ReplaceAll(pattern, " ", "_")), func(t *testing.T) { + // Test exact match + if !IsRateLimitErrorMessage(pattern) { + t.Errorf("Pattern '%s' should be detected as rate limit error", pattern) + } + + // Test case insensitive - uppercase + if !IsRateLimitErrorMessage(strings.ToUpper(pattern)) { + t.Errorf("Uppercase pattern '%s' should be detected as rate limit error", strings.ToUpper(pattern)) + } + + // Test case insensitive - mixed case + if !IsRateLimitErrorMessage(strings.Title(pattern)) { + t.Errorf("Title case pattern '%s' should be detected as rate limit error", strings.Title(pattern)) + } + + // Test as part of larger message + message := fmt.Sprintf("Error: %s occurred", pattern) + if !IsRateLimitErrorMessage(message) { + t.Errorf("Pattern '%s' in message '%s' should be detected", pattern, message) + } + + // Test with prefix and suffix + message = fmt.Sprintf("API call failed due to %s - please retry later", pattern) + if !IsRateLimitErrorMessage(message) { + t.Errorf("Pattern '%s' in complex message should be detected", pattern) + } + }) + } +} + +// Test IsRateLimitErrorMessage - negative cases +func TestIsRateLimitError_NegativeCases(t *testing.T) { + negativeCases := []string{ + "", + "invalid request", + "authentication failed", + "model not found", + "internal server error", + "bad gateway", + "service unavailable", + "timeout", + "connection refused", + "rate", // partial match shouldn't trigger + "limit", // partial match shouldn't trigger + "quota", // partial match shouldn't trigger + "throttle", // partial match shouldn't trigger (need 'throttled' or 'throttling') + } + + for _, testCase := range negativeCases { + t.Run(fmt.Sprintf("Negative_%s", strings.ReplaceAll(testCase, " ", "_")), func(t *testing.T) { + if IsRateLimitErrorMessage(testCase) { + t.Errorf("Message '%s' should NOT be detected as rate limit error", testCase) + } + }) + } +} + +// Test IsRateLimitErrorMessage - edge cases +func TestIsRateLimitError_EdgeCases(t *testing.T) { + t.Run("EmptyString", func(t *testing.T) { + if IsRateLimitErrorMessage("") { + t.Error("Empty string should not be detected as rate limit error") + } + }) + + t.Run("OnlyWhitespace", func(t *testing.T) { + if IsRateLimitErrorMessage(" \t\n ") { + t.Error("Whitespace-only string should not be detected as rate limit error") + } + }) + + t.Run("UnicodeCharacters", func(t *testing.T) { + // Test with unicode characters that might affect case conversion + message := "RATE LIMIT exceeded 🚫" + if !IsRateLimitErrorMessage(message) { + t.Error("Message with unicode should still detect rate limit pattern") + } + }) +} + +// Test retry logging and attempt counting +func TestExecuteRequestWithRetries_LoggingAndCounting(t *testing.T) { + config := createTestConfig(2, 50*time.Millisecond, 1*time.Second) + ctx := context.Background() + + // Capture calls and timing for verification + var attemptCounts []int + callCount := 0 + + handler := func() (string, *schemas.BifrostError) { + callCount++ + attemptCounts = append(attemptCounts, callCount) + + if callCount <= 2 { + // First two calls fail with retryable error + return "", createBifrostError("rate limit exceeded", Ptr(429), nil, false) + } + // Third call succeeds + return "success", nil + } + + result, err := executeRequestWithRetries( + &ctx, + config, + handler, + schemas.ChatCompletionRequest, + schemas.OpenAI, + "gpt-4", + ) + + // Verify call progression + if len(attemptCounts) != 3 { + t.Errorf("Expected 3 attempts, got %d", len(attemptCounts)) + } + + for i, count := range attemptCounts { + if count != i+1 { + t.Errorf("Attempt %d should have call count %d, got %d", i, i+1, count) + } + } + + if result != "success" { + t.Errorf("Expected success result, got %s", result) + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +// Test that retryableStatusCodes are properly defined +func TestRetryableStatusCodes(t *testing.T) { + expectedCodes := map[int]bool{ + 500: true, // Internal Server Error + 502: true, // Bad Gateway + 503: true, // Service Unavailable + 504: true, // Gateway Timeout + 429: true, // Too Many Requests + } + + for code, expected := range expectedCodes { + if retryableStatusCodes[code] != expected { + t.Errorf("Status code %d should be retryable=%v, got %v", code, expected, retryableStatusCodes[code]) + } + } + + // Test non-retryable codes + nonRetryableCodes := []int{200, 201, 400, 401, 403, 404, 422} + for _, code := range nonRetryableCodes { + if retryableStatusCodes[code] { + t.Errorf("Status code %d should not be retryable", code) + } + } +} + +// Benchmark calculateBackoff performance +func BenchmarkCalculateBackoff(b *testing.B) { + config := createTestConfig(10, 100*time.Millisecond, 5*time.Second) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + calculateBackoff(i%10, config) + } +} + +// Benchmark IsRateLimitErrorMessage performance +func BenchmarkIsRateLimitError(b *testing.B) { + messages := []string{ + "rate limit exceeded", + "too many requests", + "quota exceeded", + "throttled by provider", + "API rate limit reached", + "not a rate limit error", + "authentication failed", + "model not found", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + IsRateLimitErrorMessage(messages[i%len(messages)]) + } +} + +// Mock Account implementation for testing UpdateProvider +type MockAccount struct { + configs map[schemas.ModelProvider]*schemas.ProviderConfig + keys map[schemas.ModelProvider][]schemas.Key +} + +func NewMockAccount() *MockAccount { + return &MockAccount{ + configs: make(map[schemas.ModelProvider]*schemas.ProviderConfig), + keys: make(map[schemas.ModelProvider][]schemas.Key), + } +} + +func (ma *MockAccount) AddProvider(provider schemas.ModelProvider, concurrency int, bufferSize int) { + ma.configs[provider] = &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 30, + MaxRetries: 3, + RetryBackoffInitial: 500 * time.Millisecond, + RetryBackoffMax: 5 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: concurrency, + BufferSize: bufferSize, + }, + } + + ma.keys[provider] = []schemas.Key{ + { + ID: fmt.Sprintf("test-key-%s", provider), + Value: fmt.Sprintf("sk-test-%s", provider), + Weight: 100, + }, + } +} + +func (ma *MockAccount) UpdateProviderConfig(provider schemas.ModelProvider, concurrency int, bufferSize int) { + if config, exists := ma.configs[provider]; exists { + config.ConcurrencyAndBufferSize.Concurrency = concurrency + config.ConcurrencyAndBufferSize.BufferSize = bufferSize + } +} + +func (ma *MockAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + providers := make([]schemas.ModelProvider, 0, len(ma.configs)) + for provider := range ma.configs { + providers = append(providers, provider) + } + return providers, nil +} + +func (ma *MockAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + if config, exists := ma.configs[provider]; exists { + // Return a copy to simulate real behavior + configCopy := *config + return &configCopy, nil + } + return nil, fmt.Errorf("provider %s not configured", provider) +} + +func (ma *MockAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + if keys, exists := ma.keys[provider]; exists { + return keys, nil + } + return nil, fmt.Errorf("no keys for provider %s", provider) +} + +// Test UpdateProvider functionality +func TestUpdateProvider(t *testing.T) { + t.Run("SuccessfulUpdate", func(t *testing.T) { + // Setup mock account with initial configuration + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 5, 1000) + + // Initialize Bifrost + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), // Keep tests quiet + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Verify initial provider exists + initialProvider := bifrost.getProviderByKey(schemas.OpenAI) + if initialProvider == nil { + t.Fatalf("Initial provider not found") + } + + // Update configuration + account.UpdateProviderConfig(schemas.OpenAI, 10, 2000) + + // Perform update + err = bifrost.UpdateProvider(schemas.OpenAI) + if err != nil { + t.Fatalf("UpdateProvider failed: %v", err) + } + + // Verify provider was replaced + updatedProvider := bifrost.getProviderByKey(schemas.OpenAI) + if updatedProvider == nil { + t.Fatalf("Updated provider not found") + } + + // Verify it's a different instance (provider should have been recreated) + if initialProvider == updatedProvider { + t.Errorf("Provider instance was not replaced - same memory address") + } + + // Verify provider key is still correct + if updatedProvider.GetProviderKey() != schemas.OpenAI { + t.Errorf("Updated provider has wrong key: got %s, want %s", + updatedProvider.GetProviderKey(), schemas.OpenAI) + } + }) + + t.Run("UpdateNonExistentProvider", func(t *testing.T) { + // Setup account without the provider we'll try to update + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 5, 1000) + + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Try to update a provider not in the account + err = bifrost.UpdateProvider(schemas.Anthropic) + if err == nil { + t.Errorf("Expected error when updating non-existent provider, got nil") + } + + // Verify error message + expectedErrMsg := "failed to get updated config for provider anthropic" + if err != nil && !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("Expected error containing '%s', got: %v", expectedErrMsg, err) + } + }) + + t.Run("UpdateInactiveProvider", func(t *testing.T) { + // Setup account with provider but don't initialize it in Bifrost + account := NewMockAccount() + + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Add provider to account after bifrost initialization + account.AddProvider(schemas.Anthropic, 3, 500) + + // Verify provider doesn't exist initially + if bifrost.getProviderByKey(schemas.Anthropic) != nil { + t.Fatal("Provider should not exist initially") + } + + // Update should succeed and initialize the provider + err = bifrost.UpdateProvider(schemas.Anthropic) + if err != nil { + t.Fatalf("UpdateProvider should succeed for inactive provider: %v", err) + } + + // Verify provider now exists + provider := bifrost.getProviderByKey(schemas.Anthropic) + if provider == nil { + t.Fatal("Provider should exist after update") + } + + if provider.GetProviderKey() != schemas.Anthropic { + t.Errorf("Provider has wrong key: got %s, want %s", + provider.GetProviderKey(), schemas.Anthropic) + } + }) + + t.Run("MultipleProviderUpdates", func(t *testing.T) { + // Test updating multiple different providers + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 5, 1000) + account.AddProvider(schemas.Anthropic, 3, 500) + account.AddProvider(schemas.Cohere, 2, 200) + + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Get initial provider references + initialOpenAI := bifrost.getProviderByKey(schemas.OpenAI) + initialAnthropic := bifrost.getProviderByKey(schemas.Anthropic) + initialCohere := bifrost.getProviderByKey(schemas.Cohere) + + // Update configurations + account.UpdateProviderConfig(schemas.OpenAI, 10, 2000) + account.UpdateProviderConfig(schemas.Anthropic, 6, 1000) + account.UpdateProviderConfig(schemas.Cohere, 4, 400) + + // Update all providers + providers := []schemas.ModelProvider{schemas.OpenAI, schemas.Anthropic, schemas.Cohere} + for _, provider := range providers { + err = bifrost.UpdateProvider(provider) + if err != nil { + t.Fatalf("Failed to update provider %s: %v", provider, err) + } + } + + // Verify all providers were replaced + newOpenAI := bifrost.getProviderByKey(schemas.OpenAI) + newAnthropic := bifrost.getProviderByKey(schemas.Anthropic) + newCohere := bifrost.getProviderByKey(schemas.Cohere) + + if initialOpenAI == newOpenAI { + t.Error("OpenAI provider was not replaced") + } + if initialAnthropic == newAnthropic { + t.Error("Anthropic provider was not replaced") + } + if initialCohere == newCohere { + t.Error("Cohere provider was not replaced") + } + + // Verify all providers still have correct keys + if newOpenAI.GetProviderKey() != schemas.OpenAI { + t.Error("OpenAI provider has wrong key after update") + } + if newAnthropic.GetProviderKey() != schemas.Anthropic { + t.Error("Anthropic provider has wrong key after update") + } + if newCohere.GetProviderKey() != schemas.Cohere { + t.Error("Cohere provider has wrong key after update") + } + }) + + t.Run("ConcurrentProviderUpdates", func(t *testing.T) { + // Test updating the same provider concurrently (should be serialized by mutex) + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 5, 1000) + + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Launch concurrent updates + const numConcurrentUpdates = 5 + errChan := make(chan error, numConcurrentUpdates) + + for i := 0; i < numConcurrentUpdates; i++ { + go func(updateNum int) { + // Update with slightly different config each time + account.UpdateProviderConfig(schemas.OpenAI, 5+updateNum, 1000+updateNum*100) + err := bifrost.UpdateProvider(schemas.OpenAI) + errChan <- err + }(i) + } + + // Collect results + var errors []error + for i := 0; i < numConcurrentUpdates; i++ { + if err := <-errChan; err != nil { + errors = append(errors, err) + } + } + + // All updates should succeed (mutex should serialize them) + if len(errors) > 0 { + t.Fatalf("Expected no errors from concurrent updates, got: %v", errors) + } + + // Verify provider still exists and has correct key + provider := bifrost.getProviderByKey(schemas.OpenAI) + if provider == nil { + t.Fatal("Provider should exist after concurrent updates") + } + if provider.GetProviderKey() != schemas.OpenAI { + t.Error("Provider has wrong key after concurrent updates") + } + }) +} + +// Test provider slice management during updates +func TestUpdateProvider_ProviderSliceIntegrity(t *testing.T) { + t.Run("ProviderSliceConsistency", func(t *testing.T) { + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 5, 1000) + account.AddProvider(schemas.Anthropic, 3, 500) + + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Get initial provider count + initialProviders := bifrost.providers.Load() + initialCount := len(*initialProviders) + + // Update one provider + account.UpdateProviderConfig(schemas.OpenAI, 10, 2000) + err = bifrost.UpdateProvider(schemas.OpenAI) + if err != nil { + t.Fatalf("UpdateProvider failed: %v", err) + } + + // Verify provider count is the same (replacement, not addition) + updatedProviders := bifrost.providers.Load() + updatedCount := len(*updatedProviders) + + if initialCount != updatedCount { + t.Errorf("Provider count changed: initial=%d, updated=%d", initialCount, updatedCount) + } + + // Verify both providers still exist with correct keys + foundOpenAI := false + foundAnthropic := false + + for _, provider := range *updatedProviders { + switch provider.GetProviderKey() { + case schemas.OpenAI: + foundOpenAI = true + case schemas.Anthropic: + foundAnthropic = true + } + } + + if !foundOpenAI { + t.Error("OpenAI provider not found in providers slice after update") + } + if !foundAnthropic { + t.Error("Anthropic provider not found in providers slice after update") + } + }) + + t.Run("ProviderSliceNoMemoryLeaks", func(t *testing.T) { + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 5, 1000) + + ctx := context.Background() + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Perform multiple updates to ensure no memory leaks in provider slice + for i := 0; i < 10; i++ { + account.UpdateProviderConfig(schemas.OpenAI, 5+i, 1000+i*100) + err = bifrost.UpdateProvider(schemas.OpenAI) + if err != nil { + t.Fatalf("UpdateProvider failed on iteration %d: %v", i, err) + } + + // Verify only one OpenAI provider exists + providers := bifrost.providers.Load() + openAICount := 0 + for _, provider := range *providers { + if provider.GetProviderKey() == schemas.OpenAI { + openAICount++ + } + } + + if openAICount != 1 { + t.Fatalf("Expected exactly 1 OpenAI provider, found %d on iteration %d", openAICount, i) + } + } + }) +} + +// Test context cancellation cleanup - verifies goroutines exit properly. +// Based on reproduction case from https://github.com/maximhq/bifrost/issues/828 +func TestBifrostContextCancellationCleanup(t *testing.T) { + cases := []struct { + name string + timeout time.Duration + description string + }{ + { + name: "ContextTimeoutDuringStream", + timeout: 2 * time.Second, + description: "Goroutines should exit when context times out during streaming", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Force garbage collection and measure baseline goroutines. + runtime.GC() + time.Sleep(100 * time.Millisecond) + beforeGoroutines := runtime.NumGoroutine() + + // Create context with timeout. + ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) + defer cancel() + + // Setup mock account and initialize bifrost. + account := NewMockAccount() + account.AddProvider(schemas.OpenAI, 2, 100) + + bifrost, err := Init(ctx, schemas.BifrostConfig{ + Account: account, + Logger: NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Failed to initialize Bifrost: %v", err) + } + + // Make a streaming request that will timeout. + // The mock provider won't actually stream, but this tests the worker loop. + contentStr := "Hello" + stream, _ := bifrost.ChatCompletionStreamRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: &contentStr}, + }, + }, + }) + + // Consume stream or wait for timeout. + if stream != nil { + select { + case <-stream: + // Stream data received. + case <-ctx.Done(): + // Context cancelled. + } + } else { + // Wait for context timeout. + <-ctx.Done() + } + + // Shutdown bifrost. + bifrost.Shutdown() + + // Allow time for goroutines to clean up. + time.Sleep(2 * time.Second) + runtime.GC() + time.Sleep(100 * time.Millisecond) + + // Measure goroutines after cleanup. + afterGoroutines := runtime.NumGoroutine() + leaked := afterGoroutines - beforeGoroutines + + // Allow for small variance (Β±2 goroutines) due to runtime internals. + assert.LessOrEqualf(t, leaked, 2, + "Goroutine leak detected: started with %d, ended with %d, leaked %d goroutines", + beforeGoroutines, afterGoroutines, leaked) + }) + } +} diff --git a/core/changelog.md b/core/changelog.md new file mode 100644 index 000000000..95cf174db --- /dev/null +++ b/core/changelog.md @@ -0,0 +1,4 @@ +- fix: goroutine leaks in worker loop and streaming request handlers +- feat: added unified streaming lifecycle events across all providers to fully align with OpenAI’s streaming response types. +- chore: shift from `alpha/responses` to `v1/responses` in openrouter provider for responses API +- fix: custom keyless providers initial list models request fixes diff --git a/core/go.mod b/core/go.mod index af649c745..c6d1e3611 100644 --- a/core/go.mod +++ b/core/go.mod @@ -1,33 +1,59 @@ module github.com/maximhq/bifrost/core -go 1.24.1 +go 1.24.0 -require github.com/joho/godotenv v1.5.1 +toolchain go1.24.3 require ( - github.com/aws/aws-sdk-go-v2 v1.36.3 - github.com/aws/aws-sdk-go-v2/config v1.29.14 - github.com/maximhq/bifrost/plugins v1.0.0 - github.com/valyala/fasthttp v1.60.0 + github.com/aws/aws-sdk-go-v2 v1.39.5 + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 + github.com/aws/aws-sdk-go-v2/config v1.31.13 + github.com/bytedance/sonic v1.14.1 + github.com/google/uuid v1.6.0 + github.com/mark3labs/mcp-go v0.41.1 + github.com/rs/zerolog v1.34.0 + github.com/valyala/fasthttp v1.67.0 + golang.org/x/oauth2 v0.32.0 ) require ( - github.com/andybalholm/brotli v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect - github.com/aws/smithy-go v1.22.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/maximhq/maxim-go v0.1.1 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/text v0.24.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/core/go.sum b/core/go.sum index d0f8edd17..14d25f0d3 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,48 +1,130 @@ -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= -github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/maximhq/bifrost/plugins v1.0.0 h1:ul4tMMQHOdhyFQueyZwmQB3uX+s2buYSKzq1FW0m090= -github.com/maximhq/bifrost/plugins v1.0.0/go.mod h1:IUDZ2NMgCjIn1SVCvYbWZd/Lsk96MNytOvEKpinjvHo= -github.com/maximhq/maxim-go v0.1.1 h1:69uUQjjDPmUGcKg/M4/3AO0fbD+70Agt66pH/UCsI5M= -github.com/maximhq/maxim-go v0.1.1/go.mod h1:0+UTWM7UZwNNE5VnljLtr/vpRGtYP8r/2q9WDwlLWFw= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw= -github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/core/logger.go b/core/logger.go index b5d1bfcfa..c4c554d3a 100644 --- a/core/logger.go +++ b/core/logger.go @@ -2,11 +2,12 @@ package bifrost import ( - "fmt" "os" "time" schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" ) // DefaultLogger implements the Logger interface with stdout/stderr printing. @@ -14,60 +15,103 @@ import ( // and error streams with formatted timestamps and log levels. // It is used as the default logger if no logger is provided in the BifrostConfig. type DefaultLogger struct { - level schemas.LogLevel // Current logging level + stderrLogger zerolog.Logger + stdoutLogger zerolog.Logger +} + +// toZerologLevel converts a Bifrost log level to a Zerolog level. +func toZerologLevel(l schemas.LogLevel) zerolog.Level { + switch l { + case schemas.LogLevelDebug: + return zerolog.DebugLevel + case schemas.LogLevelInfo: + return zerolog.InfoLevel + case schemas.LogLevelWarn: + return zerolog.WarnLevel + case schemas.LogLevelError: + return zerolog.ErrorLevel + default: + return zerolog.InfoLevel + } } // NewDefaultLogger creates a new DefaultLogger instance with the specified log level. // The log level determines which messages will be output based on their severity. func NewDefaultLogger(level schemas.LogLevel) *DefaultLogger { + zerolog.SetGlobalLevel(toZerologLevel(level)) + zerolog.DisableSampling(true) + zerolog.TimeFieldFormat = time.RFC3339 + log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger() return &DefaultLogger{ - level: level, - } -} - -// formatMessage formats the log message with timestamp, level, and optional error information. -// It creates a consistent log format: [BIFROST-TIMESTAMP] LEVEL: message (error: err) -func (logger *DefaultLogger) formatMessage(level schemas.LogLevel, msg string, err error) string { - timestamp := time.Now().Format(time.RFC3339) - baseMsg := fmt.Sprintf("[BIFROST-%s] %s: %s", timestamp, level, msg) - if err != nil { - return fmt.Sprintf("%s (error: %v)", baseMsg, err) + stderrLogger: zerolog.New(os.Stderr).With().Timestamp().Logger(), + stdoutLogger: zerolog.New(os.Stdout).With().Timestamp().Logger(), } - return baseMsg } // Debug logs a debug level message to stdout. // Messages are only output if the logger's level is set to LogLevelDebug. -func (logger *DefaultLogger) Debug(msg string) { - if logger.level == schemas.LogLevelDebug { - fmt.Fprintln(os.Stdout, logger.formatMessage(schemas.LogLevelDebug, msg, nil)) - } +func (logger *DefaultLogger) Debug(msg string, args ...any) { + logger.stdoutLogger.Debug().Msgf(msg, args...) } // Info logs an info level message to stdout. // Messages are output if the logger's level is LogLevelDebug or LogLevelInfo. -func (logger *DefaultLogger) Info(msg string) { - if logger.level == schemas.LogLevelDebug || logger.level == schemas.LogLevelInfo { - fmt.Fprintln(os.Stdout, logger.formatMessage(schemas.LogLevelInfo, msg, nil)) - } +func (logger *DefaultLogger) Info(msg string, args ...any) { + logger.stdoutLogger.Info().Msgf(msg, args...) } // Warn logs a warning level message to stdout. // Messages are output if the logger's level is LogLevelDebug, LogLevelInfo, or LogLevelWarn. -func (logger *DefaultLogger) Warn(msg string) { - if logger.level == schemas.LogLevelDebug || logger.level == schemas.LogLevelInfo || logger.level == schemas.LogLevelWarn { - fmt.Fprintln(os.Stdout, logger.formatMessage(schemas.LogLevelWarn, msg, nil)) - } +func (logger *DefaultLogger) Warn(msg string, args ...any) { + logger.stdoutLogger.Warn().Msgf(msg, args...) } // Error logs an error level message to stderr. // Error messages are always output regardless of the logger's level. -func (logger *DefaultLogger) Error(err error) { - fmt.Fprintln(os.Stderr, logger.formatMessage(schemas.LogLevelError, "", err)) +func (logger *DefaultLogger) Error(msg string, args ...any) { + logger.stderrLogger.Error().Msgf(msg, args...) +} + +// Fatal logs a fatal-level message to stderr. +// Fatal messages are always output regardless of the logger's level. +func (logger *DefaultLogger) Fatal(msg string, args ...any) { + // Check if any of the args is an error and exit with non-zero code if found + var errToPass error + for i, arg := range args { + if err, ok := arg.(error); ok && err != nil { + errToPass = err + // remove from args + args = append(args[:i], args[i+1:]...) + } + } + if errToPass != nil { + logger.stderrLogger.Fatal().Msgf(msg, errToPass) + } else { + logger.stderrLogger.Fatal().Msgf(msg, args...) + } } // SetLevel sets the logging level for the logger. // This determines which messages will be output based on their severity. func (logger *DefaultLogger) SetLevel(level schemas.LogLevel) { - logger.level = level + zerolog.SetGlobalLevel(toZerologLevel(level)) +} + +// SetOutputType sets the output type for the logger. +// This determines the format of the log output. +// If the output type is unknown, it defaults to JSON +func (logger *DefaultLogger) SetOutputType(outputType schemas.LoggerOutputType) { + switch outputType { + case schemas.LoggerOutputTypePretty: + logger.stdoutLogger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() + logger.stderrLogger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger() + case schemas.LoggerOutputTypeJSON: + logger.stdoutLogger = zerolog.New(os.Stdout).With().Timestamp().Logger() + logger.stderrLogger = zerolog.New(os.Stderr).With().Timestamp().Logger() + default: + logger.stderrLogger.Warn(). + Str("outputType", string(outputType)). + Msg("unknown logger output type; defaulting to JSON") + logger.stdoutLogger = zerolog.New(os.Stdout).With().Timestamp().Logger() + } } diff --git a/core/mcp.go b/core/mcp.go new file mode 100644 index 000000000..d2b611a3f --- /dev/null +++ b/core/mcp.go @@ -0,0 +1,1171 @@ +package bifrost + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const ( + // MCP defaults and identifiers + BifrostMCPVersion = "1.0.0" // Version identifier for Bifrost + BifrostMCPClientName = "BifrostClient" // Name for internal Bifrost MCP client + BifrostMCPClientKey = "bifrost-internal" // Key for internal Bifrost client in clientMap + MCPLogPrefix = "[Bifrost MCP]" // Consistent logging prefix + MCPClientConnectionEstablishTimeout = 30 * time.Second // Timeout for MCP client connection establishment + + // Context keys for client filtering in requests + // NOTE: []string is used for both keys, and by default all clients/tools are included (when nil). + // If "*" is present, all clients/tools are included, and [] means no clients/tools are included. + // Request context filtering takes priority over client config - context can override client exclusions. + MCPContextKeyIncludeClients schemas.BifrostContextKey = "mcp-include-clients" // Context key for whitelist client filtering + MCPContextKeyIncludeTools schemas.BifrostContextKey = "mcp-include-tools" // Context key for whitelist tool filtering (Note: toolName should be in "clientName/toolName" format) +) + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +// MCPManager manages MCP integration for Bifrost core. +// It provides a bridge between Bifrost and various MCP servers, supporting +// both local tool hosting and external MCP server connections. +type MCPManager struct { + ctx context.Context + server *server.MCPServer // Local MCP server instance for hosting tools (STDIO-based) + clientMap map[string]*MCPClient // Map of MCP client names to their configurations + mu sync.RWMutex // Read-write mutex for thread-safe operations + serverRunning bool // Track whether local MCP server is running + logger schemas.Logger // Logger instance for structured logging +} + +// MCPClient represents a connected MCP client with its configuration and tools. +type MCPClient struct { + // Name string // Unique name for this client + Conn *client.Client // Active MCP client connection + ExecutionConfig schemas.MCPClientConfig // Tool filtering settings + ToolMap map[string]schemas.ChatTool // Available tools mapped by name + ConnectionInfo MCPClientConnectionInfo `json:"connection_info"` // Connection metadata for management + cancelFunc context.CancelFunc `json:"-"` // Cancel function for SSE connections (not serialized) +} + +// MCPClientConnectionInfo stores metadata about how a client is connected. +type MCPClientConnectionInfo struct { + Type schemas.MCPConnectionType `json:"type"` // Connection type (HTTP, STDIO, SSE, or InProcess) + ConnectionURL *string `json:"connection_url,omitempty"` // HTTP/SSE endpoint URL (for HTTP/SSE connections) + StdioCommandString *string `json:"stdio_command_string,omitempty"` // Command string for display (for STDIO connections) +} + +// MCPToolHandler is a generic function type for handling tool calls with typed arguments. +// T represents the expected argument structure for the tool. +type MCPToolHandler[T any] func(args T) (string, error) + +// ============================================================================ +// CONSTRUCTOR AND INITIALIZATION +// ============================================================================ + +// newMCPManager creates and initializes a new MCP manager instance. +// +// Parameters: +// - config: MCP configuration including server port and client configs +// - logger: Logger instance for structured logging (uses default if nil) +// +// Returns: +// - *MCPManager: Initialized manager instance +// - error: Any initialization error +func newMCPManager(ctx context.Context, config schemas.MCPConfig, logger schemas.Logger) (*MCPManager, error) { + // Creating new instance + manager := &MCPManager{ + ctx: ctx, + clientMap: make(map[string]*MCPClient), + logger: logger, + } + // Process client configs: create client map entries and establish connections + for _, clientConfig := range config.ClientConfigs { + if err := manager.AddClient(clientConfig); err != nil { + manager.logger.Warn(fmt.Sprintf("%s Failed to add MCP client %s: %v", MCPLogPrefix, clientConfig.Name, err)) + } + } + manager.logger.Info(MCPLogPrefix + " MCP Manager initialized") + return manager, nil +} + +// GetClients returns all MCP clients managed by the manager. +// +// Returns: +// - []*MCPClient: List of all MCP clients +// - error: Any retrieval error +func (m *MCPManager) GetClients() ([]MCPClient, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + clients := make([]MCPClient, 0, len(m.clientMap)) + for _, client := range m.clientMap { + clients = append(clients, *client) + } + + return clients, nil +} + +// ReconnectClient attempts to reconnect an MCP client if it is disconnected. +func (m *MCPManager) ReconnectClient(id string) error { + m.mu.Lock() + + client, ok := m.clientMap[id] + if !ok { + m.mu.Unlock() + return fmt.Errorf("client %s not found", id) + } + + m.mu.Unlock() + + // connectToMCPClient handles locking internally + err := m.connectToMCPClient(client.ExecutionConfig) + if err != nil { + return fmt.Errorf("failed to connect to MCP client %s: %w", id, err) + } + + return nil +} + +// AddClient adds a new MCP client to the manager. +// It validates the client configuration and establishes a connection. +// +// Parameters: +// - config: MCP client configuration +// +// Returns: +func (m *MCPManager) AddClient(config schemas.MCPClientConfig) error { + if err := validateMCPClientConfig(&config); err != nil { + return fmt.Errorf("invalid MCP client configuration: %w", err) + } + + // Make a copy of the config to use after unlocking + configCopy := config + + m.mu.Lock() + + if _, ok := m.clientMap[config.ID]; ok { + m.mu.Unlock() + return fmt.Errorf("client %s already exists", config.Name) + } + + // Create placeholder entry + m.clientMap[config.ID] = &MCPClient{ + ExecutionConfig: config, + ToolMap: make(map[string]schemas.ChatTool), + } + + // Temporarily unlock for the connection attempt + // This is to avoid deadlocks when the connection attempt is made + m.mu.Unlock() + + // Connect using the copied config + if err := m.connectToMCPClient(configCopy); err != nil { + // Re-lock to clean up the failed entry + m.mu.Lock() + delete(m.clientMap, config.ID) + m.mu.Unlock() + return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) + } + + return nil +} + +// RemoveClient removes an MCP client from the manager. +// It handles cleanup for all transport types (HTTP, STDIO, SSE). +// +// Parameters: +// - id: ID of the client to remove +func (m *MCPManager) RemoveClient(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + return m.removeClientUnsafe(id) +} + +func (m *MCPManager) removeClientUnsafe(id string) error { + client, ok := m.clientMap[id] + if !ok { + return fmt.Errorf("client %s not found", id) + } + + m.logger.Info(fmt.Sprintf("%s Disconnecting MCP client: %s", MCPLogPrefix, client.ExecutionConfig.Name)) + + // Cancel SSE context if present (required for proper SSE cleanup) + if client.cancelFunc != nil { + client.cancelFunc() + client.cancelFunc = nil + } + + // Close the client transport connection + // This handles cleanup for all transport types (HTTP, STDIO, SSE) + if client.Conn != nil { + if err := client.Conn.Close(); err != nil { + m.logger.Error("%s Failed to close MCP client %s: %v", MCPLogPrefix, client.ExecutionConfig.Name, err) + } + client.Conn = nil + } + + // Clear client tool map + client.ToolMap = make(map[string]schemas.ChatTool) + + delete(m.clientMap, id) + return nil +} + +func (m *MCPManager) EditClient(id string, updatedConfig schemas.MCPClientConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + + client, ok := m.clientMap[id] + if !ok { + return fmt.Errorf("client %s not found", id) + } + + // Update the client's execution config with new tool filters + config := client.ExecutionConfig + config.Name = updatedConfig.Name + config.Headers = updatedConfig.Headers + config.ToolsToExecute = updatedConfig.ToolsToExecute + + // Store the updated config + client.ExecutionConfig = config + + if client.Conn == nil { + return nil // Client is not connected, so no tools to update + } + + // Clear current tool map + client.ToolMap = make(map[string]schemas.ChatTool) + + // Temporarily unlock for the network call + m.mu.Unlock() + + // Retrieve tools with updated configuration + tools, err := m.retrieveExternalTools(m.ctx, client.Conn, config) + + // Re-lock to update the tool map + m.mu.Lock() + + // Verify client still exists + if _, ok := m.clientMap[id]; !ok { + return fmt.Errorf("client %s was removed during tool update", id) + } + + if err != nil { + return fmt.Errorf("failed to retrieve external tools: %w", err) + } + + // Store discovered tools + maps.Copy(client.ToolMap, tools) + + return nil +} + +// ============================================================================ +// TOOL REGISTRATION AND DISCOVERY +// ============================================================================ + +// getAvailableTools returns all tools from connected MCP clients. +// Applies client filtering if specified in the context. +func (m *MCPManager) getAvailableTools(ctx context.Context) []schemas.ChatTool { + m.mu.RLock() + defer m.mu.RUnlock() + + var includeClients []string + + // Extract client filtering from request context + if existingIncludeClients, ok := ctx.Value(MCPContextKeyIncludeClients).([]string); ok && existingIncludeClients != nil { + includeClients = existingIncludeClients + } + + tools := make([]schemas.ChatTool, 0) + for id, client := range m.clientMap { + // Apply client filtering logic + if !m.shouldIncludeClient(id, includeClients) { + m.logger.Debug(fmt.Sprintf("%s Skipping MCP client %s: not in include clients list", MCPLogPrefix, id)) + continue + } + + m.logger.Debug(fmt.Sprintf("Checking tools for MCP client %s with tools to execute: %v", id, client.ExecutionConfig.ToolsToExecute)) + + // Add all tools from this client + for toolName, tool := range client.ToolMap { + // Check if tool should be skipped based on client configuration + if m.shouldSkipToolForConfig(toolName, client.ExecutionConfig) { + m.logger.Debug(fmt.Sprintf("%s Skipping MCP tool %s: not in tools to execute list", MCPLogPrefix, toolName)) + continue + } + + // Check if tool should be skipped based on request context + if m.shouldSkipToolForRequest(id, toolName, ctx) { + m.logger.Debug(fmt.Sprintf("%s Skipping MCP tool %s: not in include tools list", MCPLogPrefix, toolName)) + continue + } + + tools = append(tools, tool) + } + } + return tools +} + +// registerTool registers a typed tool handler with the local MCP server. +// This is a convenience function that handles the conversion between typed Go +// handlers and the MCP protocol. +// +// Type Parameters: +// - T: The expected argument type for the tool (must be JSON-deserializable) +// +// Parameters: +// - name: Unique tool name +// - description: Human-readable tool description +// - handler: Typed function that handles tool execution +// - toolSchema: Bifrost tool schema for function calling +// +// Returns: +// - error: Any registration error +// +// Example: +// +// type EchoArgs struct { +// Message string `json:"message"` +// } +// +// err := bifrost.RegisterMCPTool("echo", "Echo a message", +// func(args EchoArgs) (string, error) { +// return args.Message, nil +// }, toolSchema) +func (m *MCPManager) registerTool(name, description string, handler MCPToolHandler[any], toolSchema schemas.ChatTool) error { + // Ensure local server is set up + if err := m.setupLocalHost(); err != nil { + return fmt.Errorf("failed to setup local host: %w", err) + } + + // Verify internal client exists + if _, ok := m.clientMap[BifrostMCPClientKey]; !ok { + return fmt.Errorf("bifrost client not found") + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Check if tool name already exists to prevent silent overwrites + if _, exists := m.clientMap[BifrostMCPClientKey].ToolMap[name]; exists { + return fmt.Errorf("tool '%s' is already registered", name) + } + + m.logger.Info(fmt.Sprintf("%s Registering typed tool: %s", MCPLogPrefix, name)) + + // Create MCP handler wrapper that converts between typed and MCP interfaces + mcpHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Extract arguments from the request using the request's methods + args := request.GetArguments() + result, err := handler(args) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } + + // Register the tool with the local MCP server using AddTool + if m.server != nil { + tool := mcp.NewTool(name, mcp.WithDescription(description)) + m.server.AddTool(tool, mcpHandler) + } + + // Store tool definition for Bifrost integration + m.clientMap[BifrostMCPClientKey].ToolMap[name] = toolSchema + + return nil +} + +// setupLocalHost initializes the local MCP server and client if not already running. +// This creates a STDIO-based server for local tool hosting and a corresponding client. +// This is called automatically when tools are registered or when the server is needed. +// +// Returns: +// - error: Any setup error +func (m *MCPManager) setupLocalHost() error { + // Check if server is already running + if m.server != nil && m.serverRunning { + return nil + } + + // Create and configure local MCP server (STDIO-based) + server, err := m.createLocalMCPServer() + if err != nil { + return fmt.Errorf("failed to create local MCP server: %w", err) + } + m.server = server + + // Create and configure local MCP client (STDIO-based) + client, err := m.createLocalMCPClient() + if err != nil { + return fmt.Errorf("failed to create local MCP client: %w", err) + } + m.clientMap[BifrostMCPClientKey] = client + + // Start the server and initialize client connection + return m.startLocalMCPServer() +} + +// createLocalMCPServer creates a new local MCP server instance with STDIO transport. +// This server will host tools registered via RegisterTool function. +// +// Returns: +// - *server.MCPServer: Configured MCP server instance +// - error: Any creation error +func (m *MCPManager) createLocalMCPServer() (*server.MCPServer, error) { + // Create MCP server + mcpServer := server.NewMCPServer( + "Bifrost-MCP-Server", + "1.0.0", + server.WithToolCapabilities(true), + ) + + return mcpServer, nil +} + +// createLocalMCPClient creates a placeholder client entry for the local MCP server. +// The actual in-process client connection will be established in startLocalMCPServer. +// +// Returns: +// - *MCPClient: Placeholder client for local server +// - error: Any creation error +func (m *MCPManager) createLocalMCPClient() (*MCPClient, error) { + // Don't create the actual client connection here - it will be created + // after the server is ready using NewInProcessClient + return &MCPClient{ + ExecutionConfig: schemas.MCPClientConfig{ + Name: BifrostMCPClientName, + }, + ToolMap: make(map[string]schemas.ChatTool), + ConnectionInfo: MCPClientConnectionInfo{ + Type: schemas.MCPConnectionTypeInProcess, // Accurate: in-process (in-memory) transport + }, + }, nil +} + +// startLocalMCPServer creates an in-process connection between the local server and client. +// +// Returns: +// - error: Any startup error +func (m *MCPManager) startLocalMCPServer() error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if server is already running + if m.server != nil && m.serverRunning { + return nil + } + + if m.server == nil { + return fmt.Errorf("server not initialized") + } + + // Create in-process client directly connected to the server + inProcessClient, err := client.NewInProcessClient(m.server) + if err != nil { + return fmt.Errorf("failed to create in-process MCP client: %w", err) + } + + // Update the client connection + clientEntry, ok := m.clientMap[BifrostMCPClientKey] + if !ok { + return fmt.Errorf("bifrost client not found") + } + clientEntry.Conn = inProcessClient + + // Initialize the in-process client + ctx, cancel := context.WithTimeout(m.ctx, MCPClientConnectionEstablishTimeout) + defer cancel() + + // Create proper initialize request with correct structure + initRequest := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, + Capabilities: mcp.ClientCapabilities{}, + ClientInfo: mcp.Implementation{ + Name: BifrostMCPClientName, + Version: BifrostMCPVersion, + }, + }, + } + + _, err = inProcessClient.Initialize(ctx, initRequest) + if err != nil { + return fmt.Errorf("failed to initialize MCP client: %w", err) + } + + // Mark server as running + m.serverRunning = true + + return nil +} + +// executeTool executes a tool call and returns the result as a tool message. +// +// Parameters: +// - ctx: Execution context +// - toolCall: The tool call to execute (from assistant message) +// +// Returns: +// - schemas.ChatMessage: Tool message with execution result +// - error: Any execution error +func (m *MCPManager) executeTool(ctx context.Context, toolCall schemas.ChatAssistantMessageToolCall) (*schemas.ChatMessage, error) { + if toolCall.Function.Name == nil { + return nil, fmt.Errorf("tool call missing function name") + } + toolName := *toolCall.Function.Name + + // Parse tool arguments + var arguments map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil { + return nil, fmt.Errorf("failed to parse tool arguments for '%s': %v", toolName, err) + } + + // Find which client has this tool + client := m.findMCPClientForTool(toolName) + if client == nil { + return nil, fmt.Errorf("tool '%s' not found in any connected MCP client", toolName) + } + + if client.Conn == nil { + return nil, fmt.Errorf("client '%s' has no active connection", client.ExecutionConfig.Name) + } + + // Call the tool via MCP client -> MCP server + callRequest := mcp.CallToolRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodToolsCall), + }, + Params: mcp.CallToolParams{ + Name: toolName, + Arguments: arguments, + }, + } + + m.logger.Debug(fmt.Sprintf("%s Starting tool execution: %s via client: %s", MCPLogPrefix, toolName, client.ExecutionConfig.Name)) + + toolResponse, callErr := client.Conn.CallTool(ctx, callRequest) + if callErr != nil { + m.logger.Error("%s Tool execution failed for %s via client %s: %v", MCPLogPrefix, toolName, client.ExecutionConfig.Name, callErr) + return nil, fmt.Errorf("MCP tool call failed: %v", callErr) + } + + m.logger.Debug(fmt.Sprintf("%s Tool execution completed: %s", MCPLogPrefix, toolName)) + + // Extract text from MCP response + responseText := m.extractTextFromMCPResponse(toolResponse, toolName) + + // Create tool response message + return m.createToolResponseMessage(toolCall, responseText), nil +} + +// ============================================================================ +// EXTERNAL MCP CONNECTION MANAGEMENT +// ============================================================================ + +// connectToMCPClient establishes a connection to an external MCP server and +// registers its available tools with the manager. +func (m *MCPManager) connectToMCPClient(config schemas.MCPClientConfig) error { + // First lock: Initialize or validate client entry + m.mu.Lock() + + // Initialize or validate client entry + if existingClient, exists := m.clientMap[config.ID]; exists { + // Client entry exists from config, check for existing connection, if it does then close + if existingClient.cancelFunc != nil { + existingClient.cancelFunc() + existingClient.cancelFunc = nil + } + if existingClient.Conn != nil { + existingClient.Conn.Close() + } + // Update connection type for this connection attempt + existingClient.ConnectionInfo.Type = config.ConnectionType + } + // Create new client entry with configuration + m.clientMap[config.ID] = &MCPClient{ + ExecutionConfig: config, + ToolMap: make(map[string]schemas.ChatTool), + ConnectionInfo: MCPClientConnectionInfo{ + Type: config.ConnectionType, + }, + } + m.mu.Unlock() + + // Heavy operations performed outside lock + var externalClient *client.Client + var connectionInfo MCPClientConnectionInfo + var err error + + // Create appropriate transport based on connection type + switch config.ConnectionType { + case schemas.MCPConnectionTypeHTTP: + externalClient, connectionInfo, err = m.createHTTPConnection(config) + case schemas.MCPConnectionTypeSTDIO: + externalClient, connectionInfo, err = m.createSTDIOConnection(config) + case schemas.MCPConnectionTypeSSE: + externalClient, connectionInfo, err = m.createSSEConnection(config) + case schemas.MCPConnectionTypeInProcess: + externalClient, connectionInfo, err = m.createInProcessConnection(config) + default: + return fmt.Errorf("unknown connection type: %s", config.ConnectionType) + } + + if err != nil { + return fmt.Errorf("failed to create connection: %w", err) + } + + // Initialize the external client with timeout + // For SSE connections, we need a long-lived context, for others we can use timeout + var ctx context.Context + var cancel context.CancelFunc + + if config.ConnectionType == schemas.MCPConnectionTypeSSE { + // SSE connections need a long-lived context for the persistent stream + ctx, cancel = context.WithCancel(m.ctx) + // Don't defer cancel here - SSE needs the context to remain active + } else { + // Other connection types can use timeout context + ctx, cancel = context.WithTimeout(m.ctx, MCPClientConnectionEstablishTimeout) + defer cancel() + } + + // Start the transport first (required for STDIO and SSE clients) + if err := externalClient.Start(ctx); err != nil { + if config.ConnectionType == schemas.MCPConnectionTypeSSE { + cancel() // Cancel SSE context only on error + } + return fmt.Errorf("failed to start MCP client transport %s: %v", config.Name, err) + } + + // Create proper initialize request for external client + extInitRequest := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, + Capabilities: mcp.ClientCapabilities{}, + ClientInfo: mcp.Implementation{ + Name: fmt.Sprintf("Bifrost-%s", config.Name), + Version: "1.0.0", + }, + }, + } + + _, err = externalClient.Initialize(ctx, extInitRequest) + if err != nil { + if config.ConnectionType == schemas.MCPConnectionTypeSSE { + cancel() // Cancel SSE context only on error + } + return fmt.Errorf("failed to initialize MCP client %s: %v", config.Name, err) + } + + // Retrieve tools from the external server (this also requires network I/O) + tools, err := m.retrieveExternalTools(ctx, externalClient, config) + if err != nil { + m.logger.Warn(fmt.Sprintf("%s Failed to retrieve tools from %s: %v", MCPLogPrefix, config.Name, err)) + // Continue with connection even if tool retrieval fails + tools = make(map[string]schemas.ChatTool) + } + + // Second lock: Update client with final connection details and tools + m.mu.Lock() + defer m.mu.Unlock() + + // Verify client still exists (could have been cleaned up during heavy operations) + if client, exists := m.clientMap[config.ID]; exists { + // Store the external client connection and details + client.Conn = externalClient + client.ConnectionInfo = connectionInfo + + // Store cancel function for SSE connections to enable proper cleanup + if config.ConnectionType == schemas.MCPConnectionTypeSSE { + client.cancelFunc = cancel + } + + // Store discovered tools + for toolName, tool := range tools { + client.ToolMap[toolName] = tool + } + + m.logger.Info(fmt.Sprintf("%s Connected to MCP client: %s", MCPLogPrefix, config.Name)) + } else { + return fmt.Errorf("client %s was removed during connection setup", config.Name) + } + + return nil +} + +// retrieveExternalTools retrieves and filters tools from an external MCP server without holding locks. +func (m *MCPManager) retrieveExternalTools(ctx context.Context, client *client.Client, config schemas.MCPClientConfig) (map[string]schemas.ChatTool, error) { + // Get available tools from external server + listRequest := mcp.ListToolsRequest{ + PaginatedRequest: mcp.PaginatedRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodToolsList), + }, + }, + } + + toolsResponse, err := client.ListTools(ctx, listRequest) + if err != nil { + return nil, fmt.Errorf("failed to list tools: %v", err) + } + + if toolsResponse == nil { + return make(map[string]schemas.ChatTool), nil // No tools available + } + + m.logger.Debug(fmt.Sprintf("%s Retrieved %d tools from %s", MCPLogPrefix, len(toolsResponse.Tools), config.Name)) + + tools := make(map[string]schemas.ChatTool) + + // toolsResponse is already a ListToolsResult + for _, mcpTool := range toolsResponse.Tools { + // Convert MCP tool schema to Bifrost format + bifrostTool := m.convertMCPToolToBifrostSchema(&mcpTool) + tools[mcpTool.Name] = bifrostTool + } + + return tools, nil +} + +// shouldSkipToolForConfig checks if a tool should be skipped based on client configuration (without accessing clientMap). +func (m *MCPManager) shouldSkipToolForConfig(toolName string, config schemas.MCPClientConfig) bool { + // If ToolsToExecute is specified (not nil), apply filtering + if config.ToolsToExecute != nil { + // Handle empty array [] - means no tools are allowed + if len(config.ToolsToExecute) == 0 { + return true // No tools allowed + } + + // Handle wildcard "*" - if present, all tools are allowed + if slices.Contains(config.ToolsToExecute, "*") { + return false // All tools allowed + } + + // Check if specific tool is in the allowed list + for _, allowedTool := range config.ToolsToExecute { + if allowedTool == toolName { + return false // Tool is allowed + } + } + return true // Tool not in allowed list + } + + return true // Tool is skipped (nil is treated as [] - no tools) +} + +// shouldSkipToolForRequest checks if a tool should be skipped based on the request context. +func (m *MCPManager) shouldSkipToolForRequest(clientID, toolName string, ctx context.Context) bool { + includeTools := ctx.Value(MCPContextKeyIncludeTools) + + if includeTools != nil { + // Try []string first (preferred type) + if includeToolsList, ok := includeTools.([]string); ok { + // Handle empty array [] - means no tools are included + if len(includeToolsList) == 0 { + return true // No tools allowed + } + + // Handle wildcard "clientName/*" - if present, all tools are included for this client + if slices.Contains(includeToolsList, fmt.Sprintf("%s/*", clientID)) { + return false // All tools allowed + } + + // Check if specific tool is in the list (format: clientName/toolName) + fullToolName := fmt.Sprintf("%s/%s", clientID, toolName) + if slices.Contains(includeToolsList, fullToolName) { + return false // Tool is explicitly allowed + } + + // If includeTools is specified but this tool is not in it, skip it + return true + } + } + + return false // Tool is allowed (default when no filtering specified) +} + +// convertMCPToolToBifrostSchema converts an MCP tool definition to Bifrost format. +func (m *MCPManager) convertMCPToolToBifrostSchema(mcpTool *mcp.Tool) schemas.ChatTool { + return schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: mcpTool.Name, + Description: Ptr(mcpTool.Description), + Parameters: &schemas.ToolFunctionParameters{ + Type: mcpTool.InputSchema.Type, + Properties: Ptr(mcpTool.InputSchema.Properties), + Required: mcpTool.InputSchema.Required, + }, + }, + } +} + +// extractTextFromMCPResponse extracts text content from an MCP tool response. +func (m *MCPManager) extractTextFromMCPResponse(toolResponse *mcp.CallToolResult, toolName string) string { + if toolResponse == nil { + return fmt.Sprintf("MCP tool '%s' executed successfully", toolName) + } + + var result strings.Builder + for _, contentBlock := range toolResponse.Content { + // Handle typed content + switch content := contentBlock.(type) { + case mcp.TextContent: + result.WriteString(content.Text) + case mcp.ImageContent: + result.WriteString(fmt.Sprintf("[Image Response: %s, MIME: %s]\n", content.Data, content.MIMEType)) + case mcp.AudioContent: + result.WriteString(fmt.Sprintf("[Audio Response: %s, MIME: %s]\n", content.Data, content.MIMEType)) + case mcp.EmbeddedResource: + result.WriteString(fmt.Sprintf("[Embedded Resource Response: %s]\n", content.Type)) + default: + // Fallback: try to extract from map structure + if jsonBytes, err := json.Marshal(contentBlock); err == nil { + var contentMap map[string]interface{} + if json.Unmarshal(jsonBytes, &contentMap) == nil { + if text, ok := contentMap["text"].(string); ok { + result.WriteString(fmt.Sprintf("[Text Response: %s]\n", text)) + continue + } + } + // Final fallback: serialize as JSON + result.WriteString(string(jsonBytes)) + } + } + } + + if result.Len() > 0 { + return strings.TrimSpace(result.String()) + } + return fmt.Sprintf("MCP tool '%s' executed successfully", toolName) +} + +// createToolResponseMessage creates a tool response message with the execution result. +func (m *MCPManager) createToolResponseMessage(toolCall schemas.ChatAssistantMessageToolCall, responseText string) *schemas.ChatMessage { + return &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleTool, + Content: &schemas.ChatMessageContent{ + ContentStr: &responseText, + }, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: toolCall.ID, + }, + } +} + +func (m *MCPManager) addMCPToolsToBifrostRequest(ctx context.Context, req *schemas.BifrostRequest) *schemas.BifrostRequest { + mcpTools := m.getAvailableTools(ctx) + if len(mcpTools) > 0 { + m.logger.Debug(fmt.Sprintf("%s Adding %d MCP tools to request", MCPLogPrefix, len(mcpTools))) + switch req.RequestType { + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + // Only allocate new Params if it's nil to preserve caller-supplied settings + if req.ChatRequest.Params == nil { + req.ChatRequest.Params = &schemas.ChatParameters{} + } + + tools := req.ChatRequest.Params.Tools + + // Create a map of existing tool names for O(1) lookup + existingToolsMap := make(map[string]bool) + for _, tool := range tools { + if tool.Function != nil && tool.Function.Name != "" { + existingToolsMap[tool.Function.Name] = true + } + } + + // Add MCP tools that are not already present + for _, mcpTool := range mcpTools { + // Skip tools with nil Function or empty Name + if mcpTool.Function == nil || mcpTool.Function.Name == "" { + continue + } + + if !existingToolsMap[mcpTool.Function.Name] { + tools = append(tools, mcpTool) + // Update the map to prevent duplicates within MCP tools as well + existingToolsMap[mcpTool.Function.Name] = true + } + } + req.ChatRequest.Params.Tools = tools + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + // Only allocate new Params if it's nil to preserve caller-supplied settings + if req.ResponsesRequest.Params == nil { + req.ResponsesRequest.Params = &schemas.ResponsesParameters{} + } + + tools := req.ResponsesRequest.Params.Tools + + // Create a map of existing tool names for O(1) lookup + existingToolsMap := make(map[string]bool) + for _, tool := range tools { + if tool.Name != nil { + existingToolsMap[*tool.Name] = true + } + } + + // Add MCP tools that are not already present + for _, mcpTool := range mcpTools { + // Skip tools with nil Function or empty Name + if mcpTool.Function == nil || mcpTool.Function.Name == "" { + continue + } + + if !existingToolsMap[mcpTool.Function.Name] { + responsesTool := mcpTool.ToResponsesTool() + // Skip if the converted tool has nil Name + if responsesTool.Name == nil { + continue + } + + tools = append(tools, *responsesTool) + // Update the map to prevent duplicates within MCP tools as well + existingToolsMap[*responsesTool.Name] = true + } + } + req.ResponsesRequest.Params.Tools = tools + } + } + return req +} + +func validateMCPClientConfig(config *schemas.MCPClientConfig) error { + if strings.TrimSpace(config.ID) == "" { + return fmt.Errorf("id is required for MCP client config") + } + + if strings.TrimSpace(config.Name) == "" { + return fmt.Errorf("name is required for MCP client config") + } + + if config.ConnectionType == "" { + return fmt.Errorf("connection type is required for MCP client config") + } + + switch config.ConnectionType { + case schemas.MCPConnectionTypeHTTP: + if config.ConnectionString == nil { + return fmt.Errorf("ConnectionString is required for HTTP connection type in client '%s'", config.Name) + } + case schemas.MCPConnectionTypeSSE: + if config.ConnectionString == nil { + return fmt.Errorf("ConnectionString is required for SSE connection type in client '%s'", config.Name) + } + case schemas.MCPConnectionTypeSTDIO: + if config.StdioConfig == nil { + return fmt.Errorf("StdioConfig is required for STDIO connection type in client '%s'", config.Name) + } + case schemas.MCPConnectionTypeInProcess: + // InProcess requires a server instance to be provided programmatically + // This cannot be validated from JSON config - the server must be set when using the Go package + if config.InProcessServer == nil { + return fmt.Errorf("InProcessServer is required for InProcess connection type in client '%s' (Go package only)", config.Name) + } + default: + return fmt.Errorf("unknown connection type '%s' in client '%s'", config.ConnectionType, config.Name) + } + + return nil +} + +// ============================================================================ +// HELPER METHODS +// ============================================================================ + +// findMCPClientForTool safely finds a client that has the specified tool. +func (m *MCPManager) findMCPClientForTool(toolName string) *MCPClient { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, client := range m.clientMap { + if _, exists := client.ToolMap[toolName]; exists { + return client + } + } + return nil +} + +// shouldIncludeClient determines if a client should be included based on filtering rules. +func (m *MCPManager) shouldIncludeClient(clientID string, includeClients []string) bool { + // If includeClients is specified (not nil), apply whitelist filtering + if includeClients != nil { + // Handle empty array [] - means no clients are included + if len(includeClients) == 0 { + return false // No clients allowed + } + + // Handle wildcard "*" - if present, all clients are included + if slices.Contains(includeClients, "*") { + return true // All clients allowed + } + + // Check if specific client is in the list + return slices.Contains(includeClients, clientID) + } + + // Default: include all clients when no filtering specified (nil case) + return true +} + +// createHTTPConnection creates an HTTP-based MCP client connection without holding locks. +func (m *MCPManager) createHTTPConnection(config schemas.MCPClientConfig) (*client.Client, MCPClientConnectionInfo, error) { + if config.ConnectionString == nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("HTTP connection string is required") + } + + // Prepare connection info + connectionInfo := MCPClientConnectionInfo{ + Type: config.ConnectionType, + ConnectionURL: config.ConnectionString, + } + + // Create StreamableHTTP transport + httpTransport, err := transport.NewStreamableHTTP(*config.ConnectionString, transport.WithHTTPHeaders(config.Headers)) + if err != nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("failed to create HTTP transport: %w", err) + } + + client := client.NewClient(httpTransport) + + return client, connectionInfo, nil +} + +// createSTDIOConnection creates a STDIO-based MCP client connection without holding locks. +func (m *MCPManager) createSTDIOConnection(config schemas.MCPClientConfig) (*client.Client, MCPClientConnectionInfo, error) { + if config.StdioConfig == nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("stdio config is required") + } + + // Prepare STDIO command info for display + cmdString := fmt.Sprintf("%s %s", config.StdioConfig.Command, strings.Join(config.StdioConfig.Args, " ")) + + // Check if environment variables are set + for _, env := range config.StdioConfig.Envs { + if os.Getenv(env) == "" { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("environment variable %s is not set for MCP client %s", env, config.Name) + } + } + + // Create STDIO transport + stdioTransport := transport.NewStdio( + config.StdioConfig.Command, + config.StdioConfig.Envs, + config.StdioConfig.Args..., + ) + + // Prepare connection info + connectionInfo := MCPClientConnectionInfo{ + Type: config.ConnectionType, + StdioCommandString: &cmdString, + } + + client := client.NewClient(stdioTransport) + + // Return nil for cmd since mark3labs/mcp-go manages the process internally + return client, connectionInfo, nil +} + +// createSSEConnection creates a SSE-based MCP client connection without holding locks. +func (m *MCPManager) createSSEConnection(config schemas.MCPClientConfig) (*client.Client, MCPClientConnectionInfo, error) { + if config.ConnectionString == nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("SSE connection string is required") + } + + // Prepare connection info + connectionInfo := MCPClientConnectionInfo{ + Type: config.ConnectionType, + ConnectionURL: config.ConnectionString, // Reuse HTTPConnectionURL field for SSE URL display + } + + // Create SSE transport + sseTransport, err := transport.NewSSE(*config.ConnectionString, transport.WithHeaders(config.Headers)) + if err != nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("failed to create SSE transport: %w", err) + } + + client := client.NewClient(sseTransport) + + return client, connectionInfo, nil +} + +// createInProcessConnection creates an in-process MCP client connection without holding locks. +// This allows direct connection to an MCP server running in the same process, providing +// the lowest latency and highest performance for tool execution. +func (m *MCPManager) createInProcessConnection(config schemas.MCPClientConfig) (*client.Client, MCPClientConnectionInfo, error) { + if config.InProcessServer == nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("InProcess connection requires a server instance") + } + + // Type assert to ensure we have a proper MCP server + mcpServer, ok := config.InProcessServer.(*server.MCPServer) + if !ok { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("InProcessServer must be a *server.MCPServer instance") + } + + // Create in-process client directly connected to the provided server + inProcessClient, err := client.NewInProcessClient(mcpServer) + if err != nil { + return nil, MCPClientConnectionInfo{}, fmt.Errorf("failed to create in-process client: %w", err) + } + + // Prepare connection info + connectionInfo := MCPClientConnectionInfo{ + Type: config.ConnectionType, + } + + return inProcessClient, connectionInfo, nil +} + +// cleanup performs cleanup of all MCP resources including clients and local server. +// This function safely disconnects all MCP clients (HTTP, STDIO, and SSE) and +// cleans up the local MCP server. It handles proper cancellation of SSE contexts +// and closes all transport connections. +// +// Returns: +// - error: Always returns nil, but maintains error interface for consistency +func (m *MCPManager) cleanup() error { + m.mu.Lock() + defer m.mu.Unlock() + + // Disconnect all external MCP clients + for id := range m.clientMap { + if err := m.removeClientUnsafe(id); err != nil { + m.logger.Error("%s Failed to remove MCP client %s: %v", MCPLogPrefix, id, err) + } + } + + // Clear the client map + m.clientMap = make(map[string]*MCPClient) + + // Clear local server reference + // Note: mark3labs/mcp-go STDIO server cleanup is handled automatically + if m.server != nil { + m.logger.Info(MCPLogPrefix + " Clearing local MCP server reference") + m.server = nil + m.serverRunning = false + } + + m.logger.Info(MCPLogPrefix + " MCP cleanup completed") + return nil +} diff --git a/core/providers/anthropic.go b/core/providers/anthropic.go deleted file mode 100644 index 881c0aced..000000000 --- a/core/providers/anthropic.go +++ /dev/null @@ -1,423 +0,0 @@ -// Package providers implements various LLM providers and their utility functions. -// This file contains the Anthropic provider implementation. -package providers - -import ( - "fmt" - "sync" - "time" - - "github.com/goccy/go-json" - - schemas "github.com/maximhq/bifrost/core/schemas" - "github.com/valyala/fasthttp" -) - -// AnthropicToolChoice represents the tool choice configuration for Anthropic's API. -// It specifies how tools should be used in the completion request. -type AnthropicToolChoice struct { - Type schemas.ToolChoiceType `json:"type"` // Type of tool choice - Name *string `json:"name"` // Name of the tool to use - DisableParallelToolUse *bool `json:"disable_parallel_tool_use"` // Whether to disable parallel tool use -} - -// AnthropicTextResponse represents the response structure from Anthropic's text completion API. -// It includes the completion text, model information, and token usage statistics. -type AnthropicTextResponse struct { - ID string `json:"id"` // Unique identifier for the completion - Type string `json:"type"` // Type of completion - Completion string `json:"completion"` // Generated completion text - Model string `json:"model"` // Model used for the completion - Usage struct { - InputTokens int `json:"input_tokens"` // Number of input tokens used - OutputTokens int `json:"output_tokens"` // Number of output tokens generated - } `json:"usage"` // Token usage statistics -} - -// AnthropicChatResponse represents the response structure from Anthropic's chat completion API. -// It includes message content, model information, and token usage statistics. -type AnthropicChatResponse struct { - ID string `json:"id"` // Unique identifier for the completion - Type string `json:"type"` // Type of completion - Role string `json:"role"` // Role of the message sender - Content []struct { - Type string `json:"type"` // Type of content - Text string `json:"text,omitempty"` // Text content - Thinking string `json:"thinking,omitempty"` // Thinking process - ID string `json:"id"` // Content identifier - Name string `json:"name"` // Name of the content - Input map[string]interface{} `json:"input"` // Input parameters - } `json:"content"` // Array of content items - Model string `json:"model"` // Model used for the completion - StopReason string `json:"stop_reason,omitempty"` // Reason for completion termination - StopSequence *string `json:"stop_sequence,omitempty"` // Sequence that caused completion to stop - Usage struct { - InputTokens int `json:"input_tokens"` // Number of input tokens used - OutputTokens int `json:"output_tokens"` // Number of output tokens generated - } `json:"usage"` // Token usage statistics -} - -// AnthropicError represents the error response structure from Anthropic's API. -// It includes error type and message information. -type AnthropicError struct { - Type string `json:"type"` // always "error" - Error struct { - Type string `json:"type"` // Error type - Message string `json:"message"` // Error message - } `json:"error"` // Error details -} - -// AnthropicProvider implements the Provider interface for Anthropic's Claude API. -type AnthropicProvider struct { - logger schemas.Logger // Logger for provider operations - client *fasthttp.Client // HTTP client for API requests -} - -// anthropicChatResponsePool provides a pool for Anthropic chat response objects. -var anthropicChatResponsePool = sync.Pool{ - New: func() interface{} { - return &AnthropicChatResponse{} - }, -} - -// anthropicTextResponsePool provides a pool for Anthropic text response objects. -var anthropicTextResponsePool = sync.Pool{ - New: func() interface{} { - return &AnthropicTextResponse{} - }, -} - -// acquireAnthropicChatResponse gets an Anthropic chat response from the pool and resets it. -func acquireAnthropicChatResponse() *AnthropicChatResponse { - resp := anthropicChatResponsePool.Get().(*AnthropicChatResponse) - *resp = AnthropicChatResponse{} // Reset the struct - return resp -} - -// releaseAnthropicChatResponse returns an Anthropic chat response to the pool. -func releaseAnthropicChatResponse(resp *AnthropicChatResponse) { - if resp != nil { - anthropicChatResponsePool.Put(resp) - } -} - -// acquireAnthropicTextResponse gets an Anthropic text response from the pool and resets it. -func acquireAnthropicTextResponse() *AnthropicTextResponse { - resp := anthropicTextResponsePool.Get().(*AnthropicTextResponse) - *resp = AnthropicTextResponse{} // Reset the struct - return resp -} - -// releaseAnthropicTextResponse returns an Anthropic text response to the pool. -func releaseAnthropicTextResponse(resp *AnthropicTextResponse) { - if resp != nil { - anthropicTextResponsePool.Put(resp) - } -} - -// NewAnthropicProvider creates a new Anthropic provider instance. -// It initializes the HTTP client with the provided configuration and sets up response pools. -// The client is configured with timeouts, concurrency limits, and optional proxy settings. -func NewAnthropicProvider(config *schemas.ProviderConfig, logger schemas.Logger) *AnthropicProvider { - setConfigDefaults(config) - - client := &fasthttp.Client{ - ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - MaxConnsPerHost: config.ConcurrencyAndBufferSize.BufferSize, - } - - // Pre-warm response pools - for range config.ConcurrencyAndBufferSize.Concurrency { - anthropicTextResponsePool.Put(&AnthropicTextResponse{}) - anthropicChatResponsePool.Put(&AnthropicChatResponse{}) - bifrostResponsePool.Put(&schemas.BifrostResponse{}) - } - - // Configure proxy if provided - client = configureProxy(client, config.ProxyConfig, logger) - - return &AnthropicProvider{ - logger: logger, - client: client, - } -} - -// GetProviderKey returns the provider identifier for Anthropic. -func (provider *AnthropicProvider) GetProviderKey() schemas.ModelProvider { - return schemas.Anthropic -} - -// prepareTextCompletionParams prepares text completion parameters for Anthropic's API. -// It handles parameter mapping and conversion to the format expected by Anthropic. -// Returns the modified parameters map. -func (provider *AnthropicProvider) prepareTextCompletionParams(params map[string]interface{}) map[string]interface{} { - // Check if there is a key entry for max_tokens - if maxTokens, exists := params["max_tokens"]; exists { - // Check if max_tokens_to_sample is already present - if _, exists := params["max_tokens_to_sample"]; !exists { - // If max_tokens_to_sample is not present, rename max_tokens to max_tokens_to_sample - params["max_tokens_to_sample"] = maxTokens - } - delete(params, "max_tokens") - } - return params -} - -// completeRequest sends a request to Anthropic's API and handles the response. -// It constructs the API URL, sets up authentication, and processes the response. -// Returns the response body or an error if the request fails. -func (provider *AnthropicProvider) completeRequest(requestBody map[string]interface{}, url string, key string) ([]byte, *schemas.BifrostError) { - // Marshal the request body - jsonData, err := json.Marshal(requestBody) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderJSONMarshaling, - Error: err, - }, - } - } - - // Create the request with the JSON body - req := fasthttp.AcquireRequest() - resp := fasthttp.AcquireResponse() - defer fasthttp.ReleaseRequest(req) - defer fasthttp.ReleaseResponse(resp) - - req.SetRequestURI(url) - req.Header.SetMethod("POST") - req.Header.SetContentType("application/json") - req.Header.Set("x-api-key", key) - req.Header.Set("anthropic-version", "2023-06-01") - req.SetBody(jsonData) - - // Send the request - if err := provider.client.Do(req, resp); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderRequest, - Error: err, - }, - } - } - - // Handle error response - if resp.StatusCode() != fasthttp.StatusOK { - var errorResp AnthropicError - - bifrostErr := handleProviderAPIError(resp, &errorResp) - bifrostErr.Error.Type = &errorResp.Error.Type - bifrostErr.Error.Message = errorResp.Error.Message - - return nil, bifrostErr - } - - // Read the response body - body := resp.Body() - - return body, nil -} - -// TextCompletion performs a text completion request to Anthropic's API. -// It formats the request, sends it to Anthropic, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *AnthropicProvider) TextCompletion(model, key, text string, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - preparedParams := provider.prepareTextCompletionParams(prepareParams(params)) - - // Merge additional parameters - requestBody := mergeConfig(map[string]interface{}{ - "model": model, - "prompt": fmt.Sprintf("\n\nHuman: %s\n\nAssistant:", text), - }, preparedParams) - - responseBody, err := provider.completeRequest(requestBody, "https://api.anthropic.com/v1/complete", key) - if err != nil { - return nil, err - } - - // Create response object from pool - response := acquireAnthropicTextResponse() - defer releaseAnthropicTextResponse(response) - - // Create Bifrost response from pool - bifrostResponse := acquireBifrostResponse() - defer releaseBifrostResponse(bifrostResponse) - - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - bifrostResponse.ID = response.ID - bifrostResponse.Choices = []schemas.BifrostResponseChoice{ - { - Index: 0, - Message: schemas.BifrostResponseChoiceMessage{ - Role: schemas.RoleAssistant, - Content: &response.Completion, - }, - }, - } - bifrostResponse.Usage = schemas.LLMUsage{ - PromptTokens: response.Usage.InputTokens, - CompletionTokens: response.Usage.OutputTokens, - TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, - } - bifrostResponse.Model = response.Model - bifrostResponse.ExtraFields = schemas.BifrostResponseExtraFields{ - Provider: schemas.Anthropic, - RawResponse: rawResponse, - } - - return bifrostResponse, nil -} - -// ChatCompletion performs a chat completion request to Anthropic's API. -// It formats the request, sends it to Anthropic, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *AnthropicProvider) ChatCompletion(model, key string, messages []schemas.Message, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - // Format messages for Anthropic API - var formattedMessages []map[string]interface{} - for _, msg := range messages { - if msg.ImageContent != nil { - var content []map[string]interface{} - - imageContent := map[string]interface{}{ - "type": "image", - "source": map[string]interface{}{ - "type": msg.ImageContent.Type, - }, - } - - // Handle different image source types - if *msg.ImageContent.Type == "url" { - imageContent["source"].(map[string]interface{})["url"] = msg.ImageContent.URL - } else { - imageContent["source"].(map[string]interface{})["media_type"] = msg.ImageContent.MediaType - imageContent["source"].(map[string]interface{})["data"] = msg.ImageContent.URL - } - - content = append(content, imageContent) - - // Add text content if present - if msg.Content != nil { - content = append(content, map[string]interface{}{ - "type": "text", - "text": msg.Content, - }) - } - - formattedMessages = append(formattedMessages, map[string]interface{}{ - "role": msg.Role, - "content": content, - }) - } else { - formattedMessages = append(formattedMessages, map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - }) - } - } - - preparedParams := prepareParams(params) - - // Transform tools if present - if params != nil && params.Tools != nil && len(*params.Tools) > 0 { - var tools []map[string]interface{} - for _, tool := range *params.Tools { - tools = append(tools, map[string]interface{}{ - "name": tool.Function.Name, - "description": tool.Function.Description, - "input_schema": tool.Function.Parameters, - }) - } - - preparedParams["tools"] = tools - } - - // Merge additional parameters - requestBody := mergeConfig(map[string]interface{}{ - "model": model, - "messages": formattedMessages, - }, preparedParams) - - responseBody, err := provider.completeRequest(requestBody, "https://api.anthropic.com/v1/messages", key) - if err != nil { - return nil, err - } - - // Create response object from pool - response := acquireAnthropicChatResponse() - defer releaseAnthropicChatResponse(response) - - // Create Bifrost response from pool - bifrostResponse := acquireBifrostResponse() - defer releaseBifrostResponse(bifrostResponse) - - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - // Process the response into our BifrostResponse format - var choices []schemas.BifrostResponseChoice - - // Process content and tool calls - for i, c := range response.Content { - var content string - var toolCalls []schemas.ToolCall - - switch c.Type { - case "thinking": - content = c.Thinking - case "text": - content = c.Text - case "tool_use": - function := schemas.FunctionCall{ - Name: &c.Name, - } - - args, err := json.Marshal(c.Input) - if err != nil { - function.Arguments = fmt.Sprintf("%v", c.Input) - } else { - function.Arguments = string(args) - } - - toolCalls = append(toolCalls, schemas.ToolCall{ - Type: StrPtr("function"), - ID: &c.ID, - Function: function, - }) - } - - choices = append(choices, schemas.BifrostResponseChoice{ - Index: i, - Message: schemas.BifrostResponseChoiceMessage{ - Role: schemas.RoleAssistant, - Content: &content, - ToolCalls: &toolCalls, - }, - FinishReason: &response.StopReason, - StopString: response.StopSequence, - }) - } - - bifrostResponse.ID = response.ID - bifrostResponse.Choices = choices - bifrostResponse.Usage = schemas.LLMUsage{ - PromptTokens: response.Usage.InputTokens, - CompletionTokens: response.Usage.OutputTokens, - TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, - } - bifrostResponse.Model = response.Model - bifrostResponse.ExtraFields = schemas.BifrostResponseExtraFields{ - Provider: schemas.Anthropic, - RawResponse: rawResponse, - } - - return bifrostResponse, nil -} diff --git a/core/providers/anthropic/anthropic.go b/core/providers/anthropic/anthropic.go new file mode 100644 index 000000000..488cfc7b7 --- /dev/null +++ b/core/providers/anthropic/anthropic.go @@ -0,0 +1,975 @@ +package anthropic + +import ( + "bufio" + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/bytedance/sonic" + + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// AnthropicProvider implements the Provider interface for Anthropic's Claude API. +type AnthropicProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + apiVersion string // API version for the provider + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse + customProviderConfig *schemas.CustomProviderConfig // Custom provider config +} + +// anthropicChatResponsePool provides a pool for Anthropic chat response objects. +var anthropicChatResponsePool = sync.Pool{ + New: func() interface{} { + return &AnthropicMessageResponse{} + }, +} + +// anthropicTextResponsePool provides a pool for Anthropic text response objects. +var anthropicTextResponsePool = sync.Pool{ + New: func() interface{} { + return &AnthropicTextResponse{} + }, +} + +// AcquireAnthropicChatResponse gets an Anthropic chat response from the pool. +func AcquireAnthropicChatResponse() *AnthropicMessageResponse { + resp := anthropicChatResponsePool.Get().(*AnthropicMessageResponse) + *resp = AnthropicMessageResponse{} // Reset the struct + return resp +} + +// ReleaseAnthropicChatResponse returns an Anthropic chat response to the pool. +func ReleaseAnthropicChatResponse(resp *AnthropicMessageResponse) { + if resp != nil { + anthropicChatResponsePool.Put(resp) + } +} + +// acquireAnthropicTextResponse gets an Anthropic text response from the pool. +func acquireAnthropicTextResponse() *AnthropicTextResponse { + resp := anthropicTextResponsePool.Get().(*AnthropicTextResponse) + *resp = AnthropicTextResponse{} // Reset the struct + return resp +} + +// releaseAnthropicTextResponse returns an Anthropic text response to the pool. +func releaseAnthropicTextResponse(resp *AnthropicTextResponse) { + if resp != nil { + anthropicTextResponsePool.Put(resp) + } +} + +// NewAnthropicProvider creates a new Anthropic provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewAnthropicProvider(config *schemas.ProviderConfig, logger schemas.Logger) *AnthropicProvider { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Pre-warm response pools + for i := 0; i < config.ConcurrencyAndBufferSize.Concurrency; i++ { + anthropicTextResponsePool.Put(&AnthropicTextResponse{}) + anthropicChatResponsePool.Put(&AnthropicMessageResponse{}) + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.anthropic.com" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &AnthropicProvider{ + logger: logger, + client: client, + apiVersion: "2023-06-01", + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + customProviderConfig: config.CustomProviderConfig, + } +} + +// GetProviderKey returns the provider identifier for Anthropic. +func (provider *AnthropicProvider) GetProviderKey() schemas.ModelProvider { + return providerUtils.GetProviderName(schemas.Anthropic, provider.customProviderConfig) +} + +// buildRequestURL constructs the full request URL using the provider's configuration. +func (provider *AnthropicProvider) buildRequestURL(ctx context.Context, defaultPath string, requestType schemas.RequestType) string { + return provider.networkConfig.BaseURL + providerUtils.GetRequestPath(ctx, defaultPath, provider.customProviderConfig, requestType) +} + +// completeRequest sends a request to Anthropic's API and handles the response. +// It constructs the API URL, sets up authentication, and processes the response. +// Returns the response body or an error if the request fails. +func (provider *AnthropicProvider) completeRequest(ctx context.Context, jsonData []byte, url string, key string) ([]byte, time.Duration, *schemas.BifrostError) { + // Create the request with the JSON body + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + // Can be empty in case of passthrough or keyless custom provider + if key != "" { + req.Header.Set("x-api-key", key) + } + req.Header.Set("anthropic-version", provider.apiVersion) + req.SetBody(jsonData) + + // Send the request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, latency, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + provider.logger.Debug(fmt.Sprintf("error from %s provider: %s", provider.GetProviderKey(), string(resp.Body()))) + + var errorResp AnthropicError + + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + bifrostErr.Error.Type = &errorResp.Error.Type + bifrostErr.Error.Message = errorResp.Error.Message + + return nil, latency, bifrostErr + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + // Read the response body and copy it before releasing the response + // to avoid use-after-free since respBody references fasthttp's internal buffer + bodyCopy := append([]byte(nil), body...) + + return bodyCopy, latency, nil +} + +// listModelsByKey performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +func (provider *AnthropicProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Build URL using centralized URL construction + req.SetRequestURI(provider.buildRequestURL(ctx, fmt.Sprintf("/v1/models?limit=%d", schemas.DefaultPageSize), schemas.ListModelsRequest)) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("x-api-key", key.Value) + } + req.Header.Set("anthropic-version", provider.apiVersion) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + var errorResp AnthropicError + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + bifrostErr.Error.Type = &errorResp.Error.Type + bifrostErr.Error.Message = errorResp.Error.Message + return nil, bifrostErr + } + + // Parse Anthropic's response + var anthropicResponse AnthropicListModelsResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(resp.Body(), &anthropicResponse, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create final response + response := anthropicResponse.ToBifrostListModelsResponse(provider.GetProviderKey()) + response.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ListModels performs a list models request to Anthropic's API. +// It fetches models using all provided keys and aggregates the results. +// Uses a best-effort approach: continues with remaining keys even if some fail. +// Requests are made concurrently for improved performance. +func (provider *AnthropicProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Anthropic, provider.customProviderConfig, schemas.ListModelsRequest); err != nil { + return nil, err + } + if provider.customProviderConfig != nil && provider.customProviderConfig.IsKeyLess { + return provider.listModelsByKey(ctx, schemas.Key{}, request) + } + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion performs a text completion request to Anthropic's API. +// It formats the request, sends it to Anthropic, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *AnthropicProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Anthropic, provider.customProviderConfig, schemas.TextCompletionRequest); err != nil { + return nil, err + } + + // Convert to Anthropic format using the centralized converter + jsonData, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToAnthropicTextCompletionRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + // Use struct directly for JSON marshaling + responseBody, latency, err := provider.completeRequest(ctx, jsonData, provider.buildRequestURL(ctx, "/v1/complete", schemas.TextCompletionRequest), key.Value) + if err != nil { + return nil, err + } + + // Create response object from pool + response := acquireAnthropicTextResponse() + defer releaseAnthropicTextResponse(response) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + bifrostResponse := response.ToBifrostTextCompletionResponse() + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.TextCompletionRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// TextCompletionStream performs a streaming text completion request to Anthropic's API. +// It formats the request, sends it to Anthropic, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *AnthropicProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to Anthropic's API. +// It formats the request, sends it to Anthropic, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *AnthropicProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Anthropic, provider.customProviderConfig, schemas.ChatCompletionRequest); err != nil { + return nil, err + } + + // Convert to Anthropic format using the centralized converter + jsonData, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToAnthropicChatCompletionRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + // Use struct directly for JSON marshaling + responseBody, latency, err := provider.completeRequest(ctx, jsonData, provider.buildRequestURL(ctx, "/v1/messages", schemas.ChatCompletionRequest), key.Value) + if err != nil { + return nil, err + } + + // Create response object from pool + response := AcquireAnthropicChatResponse() + defer ReleaseAnthropicChatResponse(response) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create final response + bifrostResponse := response.ToBifrostChatResponse() + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.ChatCompletionRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// ChatCompletionStream performs a streaming chat completion request to the Anthropic API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *AnthropicProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Anthropic, provider.customProviderConfig, schemas.ChatCompletionStreamRequest); err != nil { + return nil, err + } + + // Convert to Anthropic format using the centralized converter + jsonData, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToAnthropicChatCompletionRequest(request) + if reqBody != nil { + reqBody.Stream = schemas.Ptr(true) + } + return reqBody, nil + }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + // Prepare Anthropic headers + headers := map[string]string{ + "Content-Type": "application/json", + "anthropic-version": provider.apiVersion, + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + if key.Value != "" { + headers["x-api-key"] = key.Value + } + + // Use shared Anthropic streaming logic + return HandleAnthropicChatCompletionStreaming( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/messages", schemas.ChatCompletionStreamRequest), + jsonData, + headers, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// HandleAnthropicChatCompletionStreaming handles streaming for Anthropic-compatible APIs. +// This shared function reduces code duplication between providers that use the same SSE event format. +func HandleAnthropicChatCompletionStreaming( + ctx context.Context, + client *fasthttp.Client, + url string, + jsonBody []byte, + headers map[string]string, + extraHeaders map[string]string, + sendBackRawResponse bool, + providerType schemas.ModelProvider, + postHookRunner schemas.PostHookRunner, + logger schemas.Logger, + inactivityTimeoutSeconds int, +) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var err error + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true // Initialize for streaming + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(url) + req.Header.SetContentType("application/json") + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + req.SetBody(jsonBody) + + // Make the request + err = client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerType) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerType) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamAnthropicError(resp, providerType) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + if resp.BodyStream() == nil { + bifrostErr := providerUtils.NewBifrostOperationError( + "Provider returned an empty response", + fmt.Errorf("provider returned an empty response"), + providerType, + ) + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, logger) + return + } + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(inactivityTimeoutSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + chunkIndex := 0 + + startTime := time.Now() + lastChunkTime := startTime + + // Track minimal state needed for response format + var messageID string + var modelName string + var usage *schemas.BifrostLLMUsage + var finishReason *string + + // Track SSE event parsing state + var eventType string + var eventData string + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Parse SSE event - track event type and data separately + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventType = after + continue + } else if strings.HasPrefix(line, "data: ") { + eventData = strings.TrimPrefix(line, "data: ") + } else { + continue + } + + // Skip if we don't have both event type and data + if eventType == "" || eventData == "" { + continue + } + + var event AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(eventData), &event); err != nil { + logger.Warn(fmt.Sprintf("Failed to parse message_start event: %v", err)) + continue + } + + if event.Type == AnthropicStreamEventTypeMessageStart && event.Message != nil && event.Message.ID != "" { + messageID = event.Message.ID + } + + if event.Usage != nil { + usage = &schemas.BifrostLLMUsage{ + PromptTokens: event.Usage.InputTokens, + CompletionTokens: event.Usage.OutputTokens, + TotalTokens: event.Usage.InputTokens + event.Usage.OutputTokens, + } + } + if event.Delta != nil && event.Delta.StopReason != nil { + mappedReason := ConvertAnthropicFinishReasonToBifrost(*event.Delta.StopReason) + finishReason = &mappedReason + } + if event.Message != nil { + // Handle different event types + modelName = event.Message.Model + } + + response, bifrostErr, isLastChunk := event.ToBifrostChatCompletionStream() + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ChatCompletionStreamRequest, + Provider: providerType, + ModelRequested: modelName, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, logger) + break + } + if response != nil { + response.ID = messageID + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionStreamRequest, + Provider: providerType, + ModelRequested: modelName, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + lastChunkTime = time.Now() + chunkIndex++ + + if sendBackRawResponse { + response.ExtraFields.RawResponse = eventData + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + } + + if isLastChunk { + break + } + + // Reset for next event + eventType = "" + eventData = "" + } + + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Error reading %s stream: %v", providerType, err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ChatCompletionStreamRequest, providerType, modelName, logger) + } else if ctx.Err() == nil { + response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finishReason, chunkIndex, schemas.ChatCompletionStreamRequest, providerType, modelName) + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + } + }() + + return responseChan, nil +} + +// Responses performs a chat completion request to Anthropic's API. +// It formats the request, sends it to Anthropic, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *AnthropicProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Anthropic, provider.customProviderConfig, schemas.ResponsesRequest); err != nil { + return nil, err + } + + // Convert to Anthropic format using the centralized converter + jsonData, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToAnthropicResponsesRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + // Use struct directly for JSON marshaling + responseBody, latency, err := provider.completeRequest(ctx, jsonData, provider.buildRequestURL(ctx, "/v1/messages", schemas.ResponsesRequest), key.Value) + if err != nil { + return nil, err + } + + // Create response object from pool + response := AcquireAnthropicChatResponse() + defer ReleaseAnthropicChatResponse(response) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create final response + bifrostResponse := response.ToBifrostResponsesResponse() + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.ResponsesRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// ResponsesStream performs a streaming responses request to the Anthropic API. +func (provider *AnthropicProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Anthropic, provider.customProviderConfig, schemas.ResponsesStreamRequest); err != nil { + return nil, err + } + + // Convert to Anthropic format using the centralized converter + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToAnthropicResponsesRequest(request) + if reqBody != nil { + reqBody.Stream = schemas.Ptr(true) + } + return reqBody, nil + }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.buildRequestURL(ctx, "/v1/messages", schemas.ResponsesStreamRequest)) + req.Header.SetContentType("application/json") + req.Header.Set("anthropic-version", provider.apiVersion) + if key.Value != "" { + req.Header.Set("x-api-key", key.Value) + } + + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + // Set body + req.SetBody(jsonBody) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, provider.GetProviderKey()) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, provider.GetProviderKey()) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamAnthropicError(resp, provider.GetProviderKey()) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer providerUtils.ReleaseStreamingResponse(resp) + defer close(responseChan) + + if resp.BodyStream() == nil { + bifrostErr := providerUtils.NewBifrostOperationError( + "Provider returned an empty response", + fmt.Errorf("provider returned an empty response"), + provider.GetProviderKey(), + ) + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + return + } + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + chunkIndex := 0 + + startTime := time.Now() + lastChunkTime := startTime + + // Track minimal state needed for response format + var usage *schemas.ResponsesResponseUsage + + // Create stream state for stateful conversions + streamState := acquireAnthropicResponsesStreamState() + defer releaseAnthropicResponsesStreamState(streamState) + + // Track SSE event parsing state + var eventType string + var eventData string + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Parse SSE event - track event type and data separately + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventType = after + continue + } else if strings.HasPrefix(line, "data: ") { + eventData = strings.TrimPrefix(line, "data: ") + } else { + continue + } + + // Skip if we don't have both event type and data + if eventType == "" || eventData == "" { + continue + } + + var event AnthropicStreamEvent + if err := sonic.Unmarshal([]byte(eventData), &event); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse message_start event: %v", err)) + continue + } + + // Note: response.created and response.in_progress are now emitted by ToBifrostResponsesStream + // from the message_start event, so we don't need to call them manually here + + if event.Usage != nil { + usage = &schemas.ResponsesResponseUsage{ + InputTokens: event.Usage.InputTokens, + OutputTokens: event.Usage.OutputTokens, + TotalTokens: event.Usage.InputTokens + event.Usage.OutputTokens, + } + } + + responses, bifrostErr, isLastChunk := event.ToBifrostResponsesStream(chunkIndex, streamState) + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: provider.GetProviderKey(), + ModelRequested: request.Model, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + break + } + // Handle each response in the slice + for i, response := range responses { + if response != nil { + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: provider.GetProviderKey(), + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + lastChunkTime = time.Now() + chunkIndex++ + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = eventData + } + + if isLastChunk && i == len(responses)-1 { + if response.Response == nil { + response.Response = &schemas.BifrostResponsesResponse{} + } + if usage != nil { + response.Response.Usage = usage + } + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + return + } + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + } + } + + // Reset for next event + eventType = "" + eventData = "" + } + + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading %s stream: %v", provider.GetProviderKey(), err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ResponsesStreamRequest, provider.GetProviderKey(), request.Model, provider.logger) + } + }() + + return responseChan, nil +} + +// Embedding is not supported by the Anthropic provider. +func (provider *AnthropicProvider) Embedding(ctx context.Context, key schemas.Key, input *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.EmbeddingRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the Anthropic provider. +func (provider *AnthropicProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Anthropic provider. +func (provider *AnthropicProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Anthropic provider. +func (provider *AnthropicProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Anthropic provider. +func (provider *AnthropicProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} + +// parseStreamAnthropicError parses Anthropic streaming error responses. +func parseStreamAnthropicError(resp *fasthttp.Response, providerType schemas.ModelProvider) *schemas.BifrostError { + statusCode := resp.StatusCode() + body := resp.Body() + return providerUtils.NewProviderAPIError(string(body), nil, statusCode, providerType, nil, nil) +} diff --git a/core/providers/anthropic/chat.go b/core/providers/anthropic/chat.go new file mode 100644 index 000000000..dd9a0f8b3 --- /dev/null +++ b/core/providers/anthropic/chat.go @@ -0,0 +1,1014 @@ +package anthropic + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBifrostChatRequest converts an Anthropic messages request to Bifrost format +func (request *AnthropicMessageRequest) ToBifrostChatRequest() *schemas.BifrostChatRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.Anthropic) + + bifrostReq := &schemas.BifrostChatRequest{ + Provider: provider, + Model: model, + } + + messages := []schemas.ChatMessage{} + + // Add system message if present + if request.System != nil { + if request.System.ContentStr != nil && *request.System.ContentStr != "" { + messages = append(messages, schemas.ChatMessage{ + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ + ContentStr: request.System.ContentStr, + }, + }) + } else if request.System.ContentBlocks != nil { + contentBlocks := []schemas.ChatContentBlock{} + for _, block := range request.System.ContentBlocks { + if block.Text != nil { // System messages will only have text content + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: block.Text, + }) + } + } + messages = append(messages, schemas.ChatMessage{ + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ + ContentBlocks: contentBlocks, + }, + }) + } + } + + // Convert messages + for _, msg := range request.Messages { + if msg.Content.ContentStr != nil { + // Simple text message + bifrostMsg := schemas.ChatMessage{ + Role: schemas.ChatMessageRole(msg.Role), + Content: &schemas.ChatMessageContent{ + ContentStr: msg.Content.ContentStr, + }, + } + messages = append(messages, bifrostMsg) + } else if msg.Content.ContentBlocks != nil { + // Check if this is a user message with multiple tool results + var toolResults []AnthropicContentBlock + var nonToolContent []AnthropicContentBlock + + for _, content := range msg.Content.ContentBlocks { + if content.Type == AnthropicContentBlockTypeToolResult { + toolResults = append(toolResults, content) + } else { + nonToolContent = append(nonToolContent, content) + } + } + + // If we have tool results, create separate messages for each + if len(toolResults) > 0 { + for _, toolResult := range toolResults { + if toolResult.ToolUseID != nil { + var contentBlocks []schemas.ChatContentBlock + + // Convert tool result content + if toolResult.Content.ContentStr != nil { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: toolResult.Content.ContentStr, + }) + } else if toolResult.Content.ContentBlocks != nil { + for _, block := range toolResult.Content.ContentBlocks { + if block.Text != nil { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: block.Text, + }) + } else if block.Source != nil { + contentBlocks = append(contentBlocks, block.ToBifrostContentImageBlock()) + } + } + } + + toolMsg := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleTool, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: toolResult.ToolUseID, + }, + Content: &schemas.ChatMessageContent{ + ContentBlocks: contentBlocks, + }, + } + messages = append(messages, toolMsg) + } + } + } + + // Handle non-tool content (regular user/assistant message) + if len(nonToolContent) > 0 { + var bifrostMsg schemas.ChatMessage + bifrostMsg.Role = schemas.ChatMessageRole(msg.Role) + + var toolCalls []schemas.ChatAssistantMessageToolCall + var contentBlocks []schemas.ChatContentBlock + + for _, content := range nonToolContent { + switch content.Type { + case AnthropicContentBlockTypeText: + if content.Text != nil { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: content.Text, + }) + } + case AnthropicContentBlockTypeImage: + if content.Source != nil { + contentBlocks = append(contentBlocks, content.ToBifrostContentImageBlock()) + } + case AnthropicContentBlockTypeToolUse: + if content.ID != nil && content.Name != nil { + tc := schemas.ChatAssistantMessageToolCall{ + Index: uint16(len(toolCalls)), + Type: schemas.Ptr(string(schemas.ChatToolChoiceTypeFunction)), + ID: content.ID, + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: content.Name, + Arguments: schemas.JsonifyInput(content.Input), + }, + } + toolCalls = append(toolCalls, tc) + } + } + } + + // Set content + if len(contentBlocks) > 0 { + bifrostMsg.Content = &schemas.ChatMessageContent{ + ContentBlocks: contentBlocks, + } + } + + // Set tool calls for assistant messages + if len(toolCalls) > 0 && msg.Role == AnthropicMessageRoleAssistant { + bifrostMsg.ChatAssistantMessage = &schemas.ChatAssistantMessage{ + ToolCalls: toolCalls, + } + } + + messages = append(messages, bifrostMsg) + } + } + } + + bifrostReq.Input = messages + + // Convert parameters + if request.MaxTokens > 0 || request.Temperature != nil || request.TopP != nil || request.TopK != nil || request.StopSequences != nil { + params := &schemas.ChatParameters{ + ExtraParams: make(map[string]interface{}), + } + + if request.MaxTokens > 0 { + params.MaxCompletionTokens = &request.MaxTokens + } + if request.Temperature != nil { + params.Temperature = request.Temperature + } + if request.TopP != nil { + params.TopP = request.TopP + } + if request.TopK != nil { + params.ExtraParams["top_k"] = *request.TopK + } + if request.StopSequences != nil { + params.Stop = request.StopSequences + } + + bifrostReq.Params = params + } + + // Convert tools + if request.Tools != nil { + tools := []schemas.ChatTool{} + for _, tool := range request.Tools { + // Convert input_schema to FunctionParameters + params := schemas.ToolFunctionParameters{ + Type: "object", + } + if tool.InputSchema != nil { + params.Type = tool.InputSchema.Type + params.Required = tool.InputSchema.Required + params.Properties = tool.InputSchema.Properties + } + + tools = append(tools, schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: tool.Name, + Description: tool.Description, + Parameters: ¶ms, + }, + }) + } + if bifrostReq.Params == nil { + bifrostReq.Params = &schemas.ChatParameters{} + } + bifrostReq.Params.Tools = tools + } + + // Convert tool choice + if request.ToolChoice != nil { + if bifrostReq.Params == nil { + bifrostReq.Params = &schemas.ChatParameters{} + } + toolChoice := &schemas.ChatToolChoice{ + ChatToolChoiceStruct: &schemas.ChatToolChoiceStruct{ + Type: func() schemas.ChatToolChoiceType { + if request.ToolChoice.Type == "tool" { + return schemas.ChatToolChoiceTypeFunction + } + return schemas.ChatToolChoiceType(request.ToolChoice.Type) + }(), + }, + } + if request.ToolChoice.Type == "tool" && request.ToolChoice.Name != "" { + toolChoice.ChatToolChoiceStruct.Function = schemas.ChatToolChoiceFunction{ + Name: request.ToolChoice.Name, + } + } + bifrostReq.Params.ToolChoice = toolChoice + } + + return bifrostReq +} + +// ToBifrostChatResponse converts an Anthropic message response to Bifrost format +func (response *AnthropicMessageResponse) ToBifrostChatResponse() *schemas.BifrostChatResponse { + if response == nil { + return nil + } + + // Initialize Bifrost response + bifrostResponse := &schemas.BifrostChatResponse{ + ID: response.ID, + Model: response.Model, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: schemas.Anthropic, + }, + Created: int(time.Now().Unix()), + } + + // Collect all content and tool calls into a single message + var toolCalls []schemas.ChatAssistantMessageToolCall + var contentBlocks []schemas.ChatContentBlock + var contentStr *string + + // Process content and tool calls + if response.Content != nil { + if len(response.Content) == 1 && response.Content[0].Type == AnthropicContentBlockTypeText { + contentStr = response.Content[0].Text + } else { + for _, c := range response.Content { + switch c.Type { + case AnthropicContentBlockTypeText: + if c.Text != nil { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: c.Text, + }) + } + case AnthropicContentBlockTypeToolUse: + if c.ID != nil && c.Name != nil { + function := schemas.ChatAssistantMessageToolCallFunction{ + Name: c.Name, + } + + // Marshal the input to JSON string + if c.Input != nil { + args, err := json.Marshal(c.Input) + if err != nil { + function.Arguments = fmt.Sprintf("%v", c.Input) + } else { + function.Arguments = string(args) + } + } else { + function.Arguments = "{}" + } + + toolCalls = append(toolCalls, schemas.ChatAssistantMessageToolCall{ + Index: uint16(len(toolCalls)), + Type: schemas.Ptr(string(schemas.ChatToolTypeFunction)), + ID: c.ID, + Function: function, + }) + } + } + } + } + } + + // Create a single choice with the collected content + // Create message content + messageContent := schemas.ChatMessageContent{ + ContentStr: contentStr, + ContentBlocks: contentBlocks, + } + + // Create the assistant message + var assistantMessage *schemas.ChatAssistantMessage + + // Create AssistantMessage if we have tool calls or thinking + if len(toolCalls) > 0 { + assistantMessage = &schemas.ChatAssistantMessage{ + ToolCalls: toolCalls, + } + } + + // Create message + message := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &messageContent, + ChatAssistantMessage: assistantMessage, + } + + // Create choice + choice := schemas.BifrostResponseChoice{ + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &message, + StopString: response.StopSequence, + }, + FinishReason: func() *string { + if response.StopReason != "" { + mapped := ConvertAnthropicFinishReasonToBifrost(response.StopReason) + return &mapped + } + return nil + }(), + } + + bifrostResponse.Choices = []schemas.BifrostResponseChoice{choice} + + // Convert usage information + if response.Usage != nil { + bifrostResponse.Usage = &schemas.BifrostLLMUsage{ + PromptTokens: response.Usage.InputTokens, + PromptTokensDetails: &schemas.ChatPromptTokensDetails{ + CachedTokens: response.Usage.CacheCreationInputTokens + response.Usage.CacheReadInputTokens, + }, + CompletionTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, + } + } + + return bifrostResponse +} + +// ToAnthropicChatCompletionRequest converts a Bifrost request to Anthropic format +// This is the reverse of ConvertChatRequestToBifrost for provider-side usage +func ToAnthropicChatCompletionRequest(bifrostReq *schemas.BifrostChatRequest) *AnthropicMessageRequest { + if bifrostReq == nil || bifrostReq.Input == nil { + return nil + } + + messages := bifrostReq.Input + anthropicReq := &AnthropicMessageRequest{ + Model: bifrostReq.Model, + MaxTokens: AnthropicDefaultMaxTokens, + } + + // Convert parameters + if bifrostReq.Params != nil { + if bifrostReq.Params.MaxCompletionTokens != nil { + anthropicReq.MaxTokens = *bifrostReq.Params.MaxCompletionTokens + } + + anthropicReq.Temperature = bifrostReq.Params.Temperature + anthropicReq.TopP = bifrostReq.Params.TopP + anthropicReq.StopSequences = bifrostReq.Params.Stop + topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]) + if ok { + anthropicReq.TopK = topK + } + + // Convert tools + if bifrostReq.Params.Tools != nil { + tools := make([]AnthropicTool, 0, len(bifrostReq.Params.Tools)) + for _, tool := range bifrostReq.Params.Tools { + if tool.Function == nil { + continue + } + anthropicTool := AnthropicTool{ + Name: tool.Function.Name, + } + if tool.Function.Description != nil { + anthropicTool.Description = tool.Function.Description + } + + // Convert function parameters to input_schema + if tool.Function.Parameters != nil && (tool.Function.Parameters.Type != "" || tool.Function.Parameters.Properties != nil) { + anthropicTool.InputSchema = &schemas.ToolFunctionParameters{ + Type: tool.Function.Parameters.Type, + Properties: tool.Function.Parameters.Properties, + Required: tool.Function.Parameters.Required, + } + } + + tools = append(tools, anthropicTool) + } + anthropicReq.Tools = tools + } + + // Convert tool choice + if bifrostReq.Params.ToolChoice != nil { + toolChoice := &AnthropicToolChoice{} + if bifrostReq.Params.ToolChoice.ChatToolChoiceStr != nil { + switch schemas.ChatToolChoiceType(*bifrostReq.Params.ToolChoice.ChatToolChoiceStr) { + case schemas.ChatToolChoiceTypeAny: + toolChoice.Type = "any" + case schemas.ChatToolChoiceTypeRequired: + toolChoice.Type = "any" + case schemas.ChatToolChoiceTypeNone: + toolChoice.Type = "none" + default: + toolChoice.Type = "auto" + } + } else if bifrostReq.Params.ToolChoice.ChatToolChoiceStruct != nil { + switch bifrostReq.Params.ToolChoice.ChatToolChoiceStruct.Type { + case schemas.ChatToolChoiceTypeFunction: + toolChoice.Type = "tool" + toolChoice.Name = bifrostReq.Params.ToolChoice.ChatToolChoiceStruct.Function.Name + case schemas.ChatToolChoiceTypeAllowedTools: + toolChoice.Type = "any" + case schemas.ChatToolChoiceTypeCustom: + toolChoice.Type = "auto" + default: + toolChoice.Type = "auto" + } + } + anthropicReq.ToolChoice = toolChoice + } + } + + // Convert messages - group consecutive tool messages into single user messages + var anthropicMessages []AnthropicMessage + var systemContent *AnthropicContent + + i := 0 + for i < len(messages) { + msg := messages[i] + + switch msg.Role { + case schemas.ChatMessageRoleSystem: + // Handle system message separately + if msg.Content != nil { + if msg.Content.ContentStr != nil { + systemContent = &AnthropicContent{ContentStr: msg.Content.ContentStr} + } else if msg.Content.ContentBlocks != nil { + blocks := make([]AnthropicContentBlock, 0, len(msg.Content.ContentBlocks)) + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + blocks = append(blocks, AnthropicContentBlock{ + Type: "text", + Text: block.Text, + }) + } + } + if len(blocks) > 0 { + systemContent = &AnthropicContent{ContentBlocks: blocks} + } + } + } + i++ + + case schemas.ChatMessageRoleTool: + // Group consecutive tool messages into a single user message + var toolResults []AnthropicContentBlock + + // Collect all consecutive tool messages + for i < len(messages) && messages[i].Role == schemas.ChatMessageRoleTool { + toolMsg := messages[i] + if toolMsg.ChatToolMessage != nil && toolMsg.ChatToolMessage.ToolCallID != nil { + toolResult := AnthropicContentBlock{ + Type: "tool_result", + ToolUseID: toolMsg.ChatToolMessage.ToolCallID, + } + + // Convert tool result content + if toolMsg.Content != nil { + if toolMsg.Content.ContentStr != nil { + toolResult.Content = &AnthropicContent{ContentStr: toolMsg.Content.ContentStr} + } else if toolMsg.Content.ContentBlocks != nil { + blocks := make([]AnthropicContentBlock, 0, len(toolMsg.Content.ContentBlocks)) + for _, block := range toolMsg.Content.ContentBlocks { + if block.Text != nil { + blocks = append(blocks, AnthropicContentBlock{ + Type: "text", + Text: block.Text, + }) + } else if block.ImageURLStruct != nil { + blocks = append(blocks, ConvertToAnthropicImageBlock(block)) + } + } + if len(blocks) > 0 { + toolResult.Content = &AnthropicContent{ContentBlocks: blocks} + } + } + } + + toolResults = append(toolResults, toolResult) + } + i++ + } + + // Create a single user message with all tool results + if len(toolResults) > 0 { + anthropicMessages = append(anthropicMessages, AnthropicMessage{ + Role: "user", // Tool results are sent as user messages in Anthropic + Content: AnthropicContent{ContentBlocks: toolResults}, + }) + } + + default: + // Handle user and assistant messages + anthropicMsg := AnthropicMessage{ + Role: AnthropicMessageRole(msg.Role), + } + + var content []AnthropicContentBlock + + if msg.Content != nil { + // Convert text content + if msg.Content.ContentStr != nil { + content = append(content, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: msg.Content.ContentStr, + }) + } else if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + content = append(content, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: block.Text, + }) + } else if block.ImageURLStruct != nil { + content = append(content, ConvertToAnthropicImageBlock(block)) + } + } + } + } + + // Convert tool calls + if msg.ChatAssistantMessage != nil && msg.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { + toolUse := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + ID: toolCall.ID, + Name: toolCall.Function.Name, + } + + // Parse arguments JSON to interface{} + if toolCall.Function.Arguments != "" { + var input interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &input); err == nil { + toolUse.Input = input + } + } + + content = append(content, toolUse) + } + } + + // Set content + if len(content) == 1 && content[0].Type == AnthropicContentBlockTypeText { + // Single text content can be string + anthropicMsg.Content = AnthropicContent{ContentStr: content[0].Text} + } else if len(content) > 0 { + // Multiple content blocks + anthropicMsg.Content = AnthropicContent{ContentBlocks: content} + } + + anthropicMessages = append(anthropicMessages, anthropicMsg) + i++ + } + } + + anthropicReq.Messages = anthropicMessages + anthropicReq.System = systemContent + + return anthropicReq +} + +// ToAnthropicChatCompletionResponse converts a Bifrost response to Anthropic format +func ToAnthropicChatCompletionResponse(bifrostResp *schemas.BifrostChatResponse) *AnthropicMessageResponse { + if bifrostResp == nil { + return nil + } + + anthropicResp := &AnthropicMessageResponse{ + ID: bifrostResp.ID, + Type: "message", + Role: string(schemas.ChatMessageRoleAssistant), + Model: bifrostResp.Model, + } + + // Convert usage information + if bifrostResp.Usage != nil { + anthropicResp.Usage = &AnthropicUsage{ + InputTokens: bifrostResp.Usage.PromptTokens, + OutputTokens: bifrostResp.Usage.CompletionTokens, + } + + //NOTE: We cannot segregate between cache creation and cache read tokens, so we will use the total cached tokens as the cache read tokens + if bifrostResp.Usage.PromptTokensDetails != nil && bifrostResp.Usage.PromptTokensDetails.CachedTokens > 0 { + anthropicResp.Usage.CacheReadInputTokens = bifrostResp.Usage.PromptTokensDetails.CachedTokens + } + } + + // Convert choices to content + var content []AnthropicContentBlock + if len(bifrostResp.Choices) > 0 { + choice := bifrostResp.Choices[0] // Anthropic typically returns one choice + + if choice.FinishReason != nil { + anthropicResp.StopReason = ConvertBifrostFinishReasonToAnthropic(*choice.FinishReason) + } + if choice.StopString != nil { + anthropicResp.StopSequence = choice.StopString + } + + // Add text content + if choice.Message.Content.ContentStr != nil && *choice.Message.Content.ContentStr != "" { + content = append(content, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: choice.Message.Content.ContentStr, + }) + } else if choice.Message.Content.ContentBlocks != nil { + for _, block := range choice.Message.Content.ContentBlocks { + if block.Text != nil { + content = append(content, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: block.Text, + }) + } + } + } + + // Add tool calls as tool_use content + if choice.Message.ChatAssistantMessage != nil && choice.Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range choice.Message.ChatAssistantMessage.ToolCalls { + // Parse arguments JSON string back to map + var input map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &input); err != nil { + input = map[string]interface{}{} + } + } else { + input = map[string]interface{}{} + } + + content = append(content, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + ID: toolCall.ID, + Name: toolCall.Function.Name, + Input: input, + }) + } + } + } + + if content == nil { + content = []AnthropicContentBlock{} + } + + anthropicResp.Content = content + return anthropicResp +} + +// ToBifrostChatCompletionStream converts an Anthropic stream event to a Bifrost Chat Completion Stream response +func (chunk *AnthropicStreamEvent) ToBifrostChatCompletionStream() (*schemas.BifrostChatResponse, *schemas.BifrostError, bool) { + switch chunk.Type { + case AnthropicStreamEventTypeMessageStart: + return nil, nil, false + + case AnthropicStreamEventTypeMessageStop: + return nil, nil, true + + case AnthropicStreamEventTypeContentBlockStart: + // Emit tool-call metadata when starting a tool_use content block + if chunk.Index != nil && chunk.ContentBlock != nil && chunk.ContentBlock.Type == AnthropicContentBlockTypeToolUse { + // Create streaming response with tool call metadata + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: *chunk.Index, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + ToolCalls: []schemas.ChatAssistantMessageToolCall{ + { + Type: schemas.Ptr(string(schemas.ChatToolTypeFunction)), + ID: chunk.ContentBlock.ID, + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: chunk.ContentBlock.Name, + Arguments: "", // Empty arguments initially, will be filled by subsequent deltas + }, + }, + }, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + return nil, nil, false + + case AnthropicStreamEventTypeContentBlockDelta: + if chunk.Index != nil && chunk.Delta != nil { + // Handle different delta types + switch chunk.Delta.Type { + case AnthropicStreamDeltaTypeText: + if chunk.Delta.Text != nil && *chunk.Delta.Text != "" { + // Create streaming response for this delta + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: *chunk.Index, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Content: chunk.Delta.Text, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case AnthropicStreamDeltaTypeInputJSON: + // Handle tool use streaming - accumulate partial JSON + if chunk.Delta.PartialJSON != nil && *chunk.Delta.PartialJSON != "" { + // Create streaming response for tool input delta + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: *chunk.Index, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + ToolCalls: []schemas.ChatAssistantMessageToolCall{ + { + Type: func() *string { s := "function"; return &s }(), + Function: schemas.ChatAssistantMessageToolCallFunction{ + Arguments: *chunk.Delta.PartialJSON, + }, + }, + }, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case AnthropicStreamDeltaTypeThinking: + // Handle thinking content streaming + if chunk.Delta.Thinking != nil && *chunk.Delta.Thinking != "" { + // Create streaming response for thinking delta + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: *chunk.Index, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Thought: chunk.Delta.Thinking, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case AnthropicStreamDeltaTypeSignature: + // Handle signature verification for thinking content + // This is used to verify the integrity of thinking content + + } + } + + case AnthropicStreamEventTypeContentBlockStop: + // Content block is complete, no specific action needed for streaming + return nil, nil, false + + case AnthropicStreamEventTypeMessageDelta: + return nil, nil, false + + case AnthropicStreamEventTypePing: + // Ping events are just keepalive, no action needed + return nil, nil, false + + case AnthropicStreamEventTypeError: + if chunk.Error != nil { + // Send error through channel before closing + bifrostErr := &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: &chunk.Error.Type, + Message: chunk.Error.Message, + }, + } + + return nil, bifrostErr, true + } + } + + return nil, nil, false +} + +// ToAnthropicChatCompletionStreamResponse converts a Bifrost streaming response to Anthropic SSE string format +func ToAnthropicChatCompletionStreamResponse(bifrostResp *schemas.BifrostChatResponse) string { + if bifrostResp == nil { + return "" + } + + streamResp := &AnthropicStreamEvent{} + + // Handle different streaming event types based on the response content + if len(bifrostResp.Choices) > 0 { + choice := bifrostResp.Choices[0] // Anthropic typically returns one choice + + // Handle streaming responses + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil { + delta := choice.ChatStreamResponseChoice.Delta + + // Handle text content deltas + if delta.Content != nil { + streamResp.Type = "content_block_delta" + streamResp.Index = &choice.Index + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeText, + Text: delta.Content, + } + } else if delta.Thought != nil { + // Handle thinking content deltas + streamResp.Type = "content_block_delta" + streamResp.Index = &choice.Index + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeThinking, + Thinking: delta.Thought, + } + } else if len(delta.ToolCalls) > 0 { + // Handle tool call deltas + toolCall := delta.ToolCalls[0] // Take first tool call + + if toolCall.Function.Name != nil && *toolCall.Function.Name != "" { + // Tool use start event + streamResp.Type = "content_block_start" + streamResp.Index = &choice.Index + streamResp.ContentBlock = &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + ID: toolCall.ID, + Name: toolCall.Function.Name, + } + } else if toolCall.Function.Arguments != "" { + // Tool input delta + streamResp.Type = "content_block_delta" + streamResp.Index = &choice.Index + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeInputJSON, + PartialJSON: &toolCall.Function.Arguments, + } + } + } else if choice.FinishReason != nil && *choice.FinishReason != "" { + // Handle finish reason - map back to Anthropic format + stopReason := ConvertBifrostFinishReasonToAnthropic(*choice.FinishReason) + streamResp.Type = "message_delta" + streamResp.Delta = &AnthropicStreamDelta{ + Type: "message_delta", + StopReason: &stopReason, + } + } + + } else if choice.ChatNonStreamResponseChoice != nil { + // Handle non-streaming response converted to streaming format + streamResp.Type = "message_start" + + // Create message start event + streamMessage := &AnthropicMessageResponse{ + ID: bifrostResp.ID, + Type: "message", + Role: string(choice.ChatNonStreamResponseChoice.Message.Role), + Model: bifrostResp.Model, + } + + // Convert content + var content []AnthropicContentBlock + if choice.ChatNonStreamResponseChoice.Message.Content.ContentStr != nil { + content = append(content, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: choice.ChatNonStreamResponseChoice.Message.Content.ContentStr, + }) + } + + streamMessage.Content = content + streamResp.Message = streamMessage + } + } + + // Handle usage information + if bifrostResp.Usage != nil { + if streamResp.Type == "" { + streamResp.Type = "message_delta" + } + streamResp.Usage = &AnthropicUsage{ + InputTokens: bifrostResp.Usage.PromptTokens, + OutputTokens: bifrostResp.Usage.CompletionTokens, + } + } + + // Set common fields + if bifrostResp.ID != "" { + streamResp.ID = &bifrostResp.ID + } + if bifrostResp.Model != "" { + if streamResp.Message == nil { + streamResp.Message = &AnthropicMessageResponse{} + } + streamResp.Message.Model = bifrostResp.Model + } + + // Default to empty content_block_delta if no specific type was set + if streamResp.Type == "" { + streamResp.Type = "content_block_delta" + streamResp.Index = schemas.Ptr(0) + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeText, + Text: schemas.Ptr(""), + } + } + + // Marshal to JSON and format as SSE + jsonData, err := json.Marshal(streamResp) + if err != nil { + return "" + } + + // Format as Anthropic SSE + return fmt.Sprintf("event: %s\ndata: %s\n\n", streamResp.Type, jsonData) +} + +// ToAnthropicChatCompletionStreamError converts a BifrostError to Anthropic streaming error in SSE format +func ToAnthropicChatCompletionStreamError(bifrostErr *schemas.BifrostError) string { + errorResp := ToAnthropicChatCompletionError(bifrostErr) + if errorResp == nil { + return "" + } + // Marshal to JSON + jsonData, err := json.Marshal(errorResp) + if err != nil { + return "" + } + // Format as Anthropic SSE error event + return fmt.Sprintf("event: error\ndata: %s\n\n", jsonData) +} + +// ToAnthropicChatCompletionError converts a BifrostError to AnthropicMessageError +func ToAnthropicChatCompletionError(bifrostErr *schemas.BifrostError) *AnthropicMessageError { + if bifrostErr == nil { + return nil + } + + // Provide blank strings for nil pointer fields + errorType := "" + if bifrostErr.Type != nil { + errorType = *bifrostErr.Type + } + + // Handle nested error fields with nil checks + errorStruct := AnthropicMessageErrorStruct{ + Type: errorType, + Message: bifrostErr.Error.Message, + } + + return &AnthropicMessageError{ + Type: "error", // always "error" for Anthropic + Error: errorStruct, + } +} diff --git a/core/providers/anthropic/models.go b/core/providers/anthropic/models.go new file mode 100644 index 000000000..d1bcc570c --- /dev/null +++ b/core/providers/anthropic/models.go @@ -0,0 +1,67 @@ +package anthropic + +import ( + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +func (response *AnthropicListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider) *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Data)), + FirstID: response.FirstID, + LastID: response.LastID, + HasMore: schemas.Ptr(response.HasMore), + } + + // Map Anthropic's cursor-based pagination to Bifrost's token-based pagination + // If there are more results, set next_page_token to last_id so it can be used in the next request + if response.HasMore && response.LastID != nil { + bifrostResponse.NextPageToken = *response.LastID + } + + for _, model := range response.Data { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(providerKey) + "/" + model.ID, + Name: schemas.Ptr(model.DisplayName), + Created: schemas.Ptr(model.CreatedAt.Unix()), + }) + } + + return bifrostResponse +} + +func ToAnthropicListModelsResponse(response *schemas.BifrostListModelsResponse) *AnthropicListModelsResponse { + if response == nil { + return nil + } + + anthropicResponse := &AnthropicListModelsResponse{ + Data: make([]AnthropicModel, 0, len(response.Data)), + } + if response.FirstID != nil { + anthropicResponse.FirstID = response.FirstID + } + if response.LastID != nil { + anthropicResponse.LastID = response.LastID + } + + for _, model := range response.Data { + anthropicModel := AnthropicModel{ + ID: model.ID, + } + if model.Name != nil { + anthropicModel.DisplayName = *model.Name + } + if model.Created != nil { + anthropicModel.CreatedAt = time.Unix(*model.Created, 0) + } + anthropicResponse.Data = append(anthropicResponse.Data, anthropicModel) + } + + return anthropicResponse +} diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go new file mode 100644 index 000000000..043dccdbb --- /dev/null +++ b/core/providers/anthropic/responses.go @@ -0,0 +1,2702 @@ +package anthropic + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// AnthropicResponsesStreamState tracks state during streaming conversion for responses API +type AnthropicResponsesStreamState struct { + ChunkIndex *int // index of the chunk in the stream + AccumulatedJSON string // deltas of any event + + // Computer tool accumulation + ComputerToolID *string + + // OpenAI Responses API mapping state + ContentIndexToOutputIndex map[int]int // Maps Anthropic content_index to OpenAI output_index + ToolArgumentBuffers map[int]string // Maps output_index to accumulated tool argument JSON + MCPCallOutputIndices map[int]bool // Tracks which output indices are MCP calls + ItemIDs map[int]string // Maps output_index to item ID for stable IDs + CurrentOutputIndex int // Current output index counter + MessageID *string // Message ID from message_start + Model *string // Model name from message_start + CreatedAt int // Timestamp for created_at consistency + HasEmittedCreated bool // Whether we've emitted response.created + HasEmittedInProgress bool // Whether we've emitted response.in_progress +} + +// anthropicResponsesStreamStatePool provides a pool for Anthropic responses stream state objects. +var anthropicResponsesStreamStatePool = sync.Pool{ + New: func() interface{} { + return &AnthropicResponsesStreamState{ + ContentIndexToOutputIndex: make(map[int]int), + ToolArgumentBuffers: make(map[int]string), + MCPCallOutputIndices: make(map[int]bool), + ItemIDs: make(map[int]string), + CurrentOutputIndex: 0, + CreatedAt: int(time.Now().Unix()), + HasEmittedCreated: false, + HasEmittedInProgress: false, + } + }, +} + +// acquireAnthropicResponsesStreamState gets an Anthropic responses stream state from the pool. +func acquireAnthropicResponsesStreamState() *AnthropicResponsesStreamState { + state := anthropicResponsesStreamStatePool.Get().(*AnthropicResponsesStreamState) + // Clear maps (they're already initialized from New or previous flush) + // Only initialize if nil (shouldn't happen, but defensive) + if state.ContentIndexToOutputIndex == nil { + state.ContentIndexToOutputIndex = make(map[int]int) + } else { + clear(state.ContentIndexToOutputIndex) + } + if state.ToolArgumentBuffers == nil { + state.ToolArgumentBuffers = make(map[int]string) + } else { + clear(state.ToolArgumentBuffers) + } + if state.MCPCallOutputIndices == nil { + state.MCPCallOutputIndices = make(map[int]bool) + } else { + clear(state.MCPCallOutputIndices) + } + if state.ItemIDs == nil { + state.ItemIDs = make(map[int]string) + } else { + clear(state.ItemIDs) + } + // Reset other fields + state.ChunkIndex = nil + state.AccumulatedJSON = "" + state.ComputerToolID = nil + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + return state +} + +// releaseAnthropicResponsesStreamState returns an Anthropic responses stream state to the pool. +func releaseAnthropicResponsesStreamState(state *AnthropicResponsesStreamState) { + if state != nil { + state.flush() // Clean before returning to pool + anthropicResponsesStreamStatePool.Put(state) + } +} + +// flush resets the state of the stream state to its initial values +func (state *AnthropicResponsesStreamState) flush() { + state.ChunkIndex = nil + state.AccumulatedJSON = "" + state.ComputerToolID = nil + state.ContentIndexToOutputIndex = make(map[int]int) + state.ToolArgumentBuffers = make(map[int]string) + state.MCPCallOutputIndices = make(map[int]bool) + state.ItemIDs = make(map[int]string) + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false +} + +// getOrCreateOutputIndex returns the output index for a given content index, creating a new one if needed +func (state *AnthropicResponsesStreamState) getOrCreateOutputIndex(contentIndex *int) int { + if contentIndex == nil { + // If no content index, create a new output index + outputIndex := state.CurrentOutputIndex + state.CurrentOutputIndex++ + return outputIndex + } + + if outputIndex, exists := state.ContentIndexToOutputIndex[*contentIndex]; exists { + return outputIndex + } + + // Create new output index for this content index + outputIndex := state.CurrentOutputIndex + state.CurrentOutputIndex++ + state.ContentIndexToOutputIndex[*contentIndex] = outputIndex + return outputIndex +} + +// ToBifrostResponsesRequest converts an Anthropic message request to Bifrost format +func (request *AnthropicMessageRequest) ToBifrostResponsesRequest() *schemas.BifrostResponsesRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.Anthropic) + + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: provider, + Model: model, + } + + // Convert basic parameters + params := &schemas.ResponsesParameters{ + ExtraParams: make(map[string]interface{}), + } + + if request.MaxTokens > 0 { + params.MaxOutputTokens = &request.MaxTokens + } + if request.Temperature != nil { + params.Temperature = request.Temperature + } + if request.TopP != nil { + params.TopP = request.TopP + } + if request.Metadata != nil && request.Metadata.UserID != nil { + params.User = request.Metadata.UserID + } + if request.TopK != nil { + params.ExtraParams["top_k"] = *request.TopK + } + if request.StopSequences != nil { + params.ExtraParams["stop"] = request.StopSequences + } + if request.Thinking != nil { + params.ExtraParams["thinking"] = request.Thinking + } + + // Add trucation parameter if computer tool is being used + if provider == schemas.OpenAI && request.Tools != nil { + for _, tool := range request.Tools { + if tool.Type != nil && *tool.Type == AnthropicToolTypeComputer20250124 { + params.Truncation = schemas.Ptr("auto") + break + } + } + } + + bifrostReq.Params = params + + // Convert messages directly to ChatMessage format + var bifrostMessages []schemas.ResponsesMessage + + // Handle system message - convert Anthropic system field to first message with role "system" + if request.System != nil { + var systemText string + if request.System.ContentStr != nil { + systemText = *request.System.ContentStr + } else if request.System.ContentBlocks != nil { + // Combine text blocks from system content + var textParts []string + for _, block := range request.System.ContentBlocks { + if block.Text != nil { + textParts = append(textParts, *block.Text) + } + } + systemText = strings.Join(textParts, "\n") + } + + if systemText != "" { + systemMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleSystem), + Content: &schemas.ResponsesMessageContent{ + ContentStr: &systemText, + }, + } + bifrostMessages = append(bifrostMessages, systemMsg) + } + } + + // Convert regular messages + for _, msg := range request.Messages { + convertedMessages := convertAnthropicMessageToBifrostResponsesMessages(&msg) + bifrostMessages = append(bifrostMessages, convertedMessages...) + } + + // Convert tools if present + if request.Tools != nil { + var bifrostTools []schemas.ResponsesTool + for _, tool := range request.Tools { + bifrostTool := convertAnthropicToolToBifrost(&tool) + if bifrostTool != nil { + bifrostTools = append(bifrostTools, *bifrostTool) + } + } + if len(bifrostTools) > 0 { + bifrostReq.Params.Tools = bifrostTools + } + } + + if request.MCPServers != nil { + var bifrostMCPTools []schemas.ResponsesTool + for _, mcpServer := range request.MCPServers { + bifrostMCPTool := convertAnthropicMCPServerToBifrostTool(&mcpServer) + if bifrostMCPTool != nil { + bifrostMCPTools = append(bifrostMCPTools, *bifrostMCPTool) + } + } + if len(bifrostMCPTools) > 0 { + bifrostReq.Params.Tools = append(bifrostReq.Params.Tools, bifrostMCPTools...) + } + } + + // Convert tool choice if present + if request.ToolChoice != nil { + bifrostToolChoice := convertAnthropicToolChoiceToBifrost(request.ToolChoice) + if bifrostToolChoice != nil { + bifrostReq.Params.ToolChoice = bifrostToolChoice + } + } + + // Set the converted messages + if len(bifrostMessages) > 0 { + bifrostReq.Input = bifrostMessages + } + + return bifrostReq +} + +// ToAnthropicResponsesRequest converts a BifrostRequest with Responses structure back to AnthropicMessageRequest +func ToAnthropicResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *AnthropicMessageRequest { + anthropicReq := &AnthropicMessageRequest{ + Model: bifrostReq.Model, + MaxTokens: AnthropicDefaultMaxTokens, + } + + // Convert basic parameters + if bifrostReq.Params != nil { + if bifrostReq.Params.MaxOutputTokens != nil { + anthropicReq.MaxTokens = *bifrostReq.Params.MaxOutputTokens + } + if bifrostReq.Params.Temperature != nil { + anthropicReq.Temperature = bifrostReq.Params.Temperature + } + if bifrostReq.Params.TopP != nil { + anthropicReq.TopP = bifrostReq.Params.TopP + } + if bifrostReq.Params.User != nil { + anthropicReq.Metadata = &AnthropicMetaData{ + UserID: bifrostReq.Params.User, + } + } + if bifrostReq.Params.ExtraParams != nil { + topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]) + if ok { + anthropicReq.TopK = topK + } + if stop, ok := schemas.SafeExtractStringSlice(bifrostReq.Params.ExtraParams["stop"]); ok { + anthropicReq.StopSequences = stop + } + if thinking, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "thinking"); ok { + if thinkingMap, ok := thinking.(map[string]interface{}); ok { + anthropicThinking := &AnthropicThinking{} + if thinkingType, ok := thinkingMap["type"].(string); ok { + anthropicThinking.Type = thinkingType + } + // Handle budget_tokens - JSON numbers can be float64 or int + budgetTokens, ok := schemas.SafeExtractInt(thinkingMap["budget_tokens"]) + if ok { + anthropicThinking.BudgetTokens = &budgetTokens + } + anthropicReq.Thinking = anthropicThinking + } + } + } + + // Convert tools + if bifrostReq.Params.Tools != nil { + anthropicTools := []AnthropicTool{} + mcpServers := []AnthropicMCPServer{} + for _, tool := range bifrostReq.Params.Tools { + // handle mcp tool differently + if tool.Type == schemas.ResponsesToolTypeMCP && tool.ResponsesToolMCP != nil { + mcpServer := convertBifrostMCPToolToAnthropicServer(&tool) + if mcpServer != nil { + mcpServers = append(mcpServers, *mcpServer) + } + continue // Skip converting MCP tools to anthropicTools since they're handled separately + } + anthropicTool := convertBifrostToolToAnthropic(&tool) + if anthropicTool != nil { + anthropicTools = append(anthropicTools, *anthropicTool) + } + } + if len(anthropicTools) > 0 { + anthropicReq.Tools = anthropicTools + } + if len(mcpServers) > 0 { + anthropicReq.MCPServers = mcpServers + } + } + + // Convert tool choice + if bifrostReq.Params.ToolChoice != nil { + anthropicToolChoice := convertResponsesToolChoiceToAnthropic(bifrostReq.Params.ToolChoice) + if anthropicToolChoice != nil { + anthropicReq.ToolChoice = anthropicToolChoice + } + } + } + + if bifrostReq.Input != nil { + anthropicMessages, systemContent := convertResponsesMessagesToAnthropicMessages(bifrostReq.Input) + + // Set system message if present + if systemContent != nil { + anthropicReq.System = systemContent + } + + // Set regular messages + anthropicReq.Messages = anthropicMessages + } + + return anthropicReq +} + +// ToBifrostResponsesResponse converts an Anthropic response to BifrostResponse with Responses structure +func (response *AnthropicMessageResponse) ToBifrostResponsesResponse() *schemas.BifrostResponsesResponse { + if response == nil { + return nil + } + + // Create the BifrostResponse with Responses structure + bifrostResp := &schemas.BifrostResponsesResponse{ + ID: schemas.Ptr(response.ID), + CreatedAt: int(time.Now().Unix()), + } + + // Convert usage information + if response.Usage != nil { + bifrostResp.Usage = &schemas.ResponsesResponseUsage{ + InputTokens: response.Usage.InputTokens, + OutputTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, + } + + // Handle cached tokens if present + if response.Usage.CacheReadInputTokens > 0 { + if bifrostResp.Usage.InputTokensDetails == nil { + bifrostResp.Usage.InputTokensDetails = &schemas.ResponsesResponseInputTokens{} + } + bifrostResp.Usage.InputTokensDetails.CachedTokens = response.Usage.CacheReadInputTokens + } + } + + // Convert content to Responses output messages + outputMessages := convertAnthropicContentBlocksToResponsesMessages(response.Content) + if len(outputMessages) > 0 { + bifrostResp.Output = outputMessages + } + + return bifrostResp +} + +// ToAnthropicResponsesResponse converts a BifrostResponse with Responses structure back to AnthropicMessageResponse +func ToAnthropicResponsesResponse(bifrostResp *schemas.BifrostResponsesResponse) *AnthropicMessageResponse { + anthropicResp := &AnthropicMessageResponse{ + Type: "message", + Role: "assistant", + } + if bifrostResp.ID != nil { + anthropicResp.ID = *bifrostResp.ID + } + + // Convert usage information + if bifrostResp.Usage != nil { + anthropicResp.Usage = &AnthropicUsage{ + InputTokens: bifrostResp.Usage.InputTokens, + OutputTokens: bifrostResp.Usage.OutputTokens, + } + + if bifrostResp.Usage.InputTokensDetails != nil && bifrostResp.Usage.InputTokensDetails.CachedTokens > 0 { + anthropicResp.Usage.CacheReadInputTokens = bifrostResp.Usage.InputTokensDetails.CachedTokens + } + } + + // Convert output messages to Anthropic content blocks + var contentBlocks []AnthropicContentBlock + if bifrostResp.Output != nil { + contentBlocks = convertBifrostMessagesToAnthropicContent(bifrostResp.Output) + } + + if len(contentBlocks) > 0 { + anthropicResp.Content = contentBlocks + } + + // Set default stop reason - could be enhanced based on additional context + anthropicResp.StopReason = AnthropicStopReasonEndTurn + + // Check if there are tool calls to set appropriate stop reason + for _, block := range contentBlocks { + if block.Type == AnthropicContentBlockTypeToolUse { + anthropicResp.StopReason = AnthropicStopReasonToolUse + break + } + } + + return anthropicResp +} + +// ToBifrostResponsesStream converts an Anthropic stream event to a Bifrost Responses Stream response +// It maintains state via the state for handling multi-chunk conversions like computer tools +// Returns a slice of responses to support cases where a single event produces multiple responses +func (chunk *AnthropicStreamEvent) ToBifrostResponsesStream(sequenceNumber int, state *AnthropicResponsesStreamState) ([]*schemas.BifrostResponsesStreamResponse, *schemas.BifrostError, bool) { + switch chunk.Type { + case AnthropicStreamEventTypeMessageStart: + // Message start - emit response.created and response.in_progress (OpenAI-style lifecycle) + if chunk.Message != nil { + state.MessageID = &chunk.Message.ID + state.Model = &chunk.Message.Model + // Use the state's CreatedAt for consistency + if state.CreatedAt == 0 { + state.CreatedAt = int(time.Now().Unix()) + } + + var responses []*schemas.BifrostResponsesStreamResponse + + // Emit response.created + if !state.HasEmittedCreated { + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + } + if state.Model != nil { + // Note: Model field doesn't exist in BifrostResponsesResponse, but we can add other fields + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeCreated, + SequenceNumber: sequenceNumber, + Response: response, + }) + state.HasEmittedCreated = true + } + + // Emit response.in_progress + if !state.HasEmittedInProgress { + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, // Use same timestamp + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeInProgress, + SequenceNumber: sequenceNumber + len(responses), + Response: response, + }) + state.HasEmittedInProgress = true + } + + if len(responses) > 0 { + return responses, nil, false + } + } + + case AnthropicStreamEventTypeContentBlockStart: + // Content block start - emit output_item.added (OpenAI-style) + if chunk.ContentBlock != nil && chunk.Index != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + + if chunk.ContentBlock.Type == AnthropicContentBlockTypeToolUse && + chunk.ContentBlock.Name != nil && + *chunk.ContentBlock.Name == string(AnthropicToolNameComputer) && + chunk.ContentBlock.ID != nil { + + // Start accumulating computer tool + state.ComputerToolID = chunk.ContentBlock.ID + state.ChunkIndex = chunk.Index + state.AccumulatedJSON = "" + + // Emit output_item.added for computer_call + item := &schemas.ResponsesMessage{ + ID: chunk.ContentBlock.ID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeComputerCall), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: chunk.ContentBlock.ID, + }, + } + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }}, nil, false + } + + switch chunk.ContentBlock.Type { + case AnthropicContentBlockTypeText: + // Text block - emit output_item.added with type "message" + messageType := schemas.ResponsesMessageTypeMessage + role := schemas.ResponsesInputMessageRoleAssistant + + // Generate stable ID for text item + var itemID string + if state.MessageID == nil { + itemID = fmt.Sprintf("item_%d", outputIndex) + } else { + itemID = fmt.Sprintf("msg_%s_item_%d", *state.MessageID, outputIndex) + } + state.ItemIDs[outputIndex] = itemID + + item := &schemas.ResponsesMessage{ + ID: &itemID, + Type: &messageType, + Role: &role, + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{}, // Empty blocks slice for mutation support + }, + } + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }}, nil, false + + case AnthropicContentBlockTypeToolUse: + // Function call starting - emit output_item.added with type "function_call" and status "in_progress" + statusInProgress := "in_progress" + itemID := "" + if chunk.ContentBlock.ID != nil { + itemID = *chunk.ContentBlock.ID + state.ItemIDs[outputIndex] = itemID + } + item := &schemas.ResponsesMessage{ + ID: chunk.ContentBlock.ID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: &statusInProgress, + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: chunk.ContentBlock.ID, + Name: chunk.ContentBlock.Name, + Arguments: schemas.Ptr(""), // Arguments will be filled by deltas + }, + } + + // Initialize argument buffer for this tool call + state.ToolArgumentBuffers[outputIndex] = "" + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + Item: item, + }}, nil, false + + case AnthropicContentBlockTypeMCPToolUse: + // MCP tool call starting - emit output_item.added + itemID := "" + if chunk.ContentBlock.ID != nil { + itemID = *chunk.ContentBlock.ID + state.ItemIDs[outputIndex] = itemID + } + item := &schemas.ResponsesMessage{ + ID: chunk.ContentBlock.ID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPCall), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + Name: chunk.ContentBlock.Name, + Arguments: schemas.Ptr(""), // Arguments will be filled by deltas + }, + } + + // Set server name if present + if chunk.ContentBlock.ServerName != nil { + item.ResponsesToolMessage.ResponsesMCPToolCall = &schemas.ResponsesMCPToolCall{ + ServerLabel: *chunk.ContentBlock.ServerName, + } + } + + // Initialize argument buffer for this MCP call and mark as MCP + state.ToolArgumentBuffers[outputIndex] = "" + state.MCPCallOutputIndices[outputIndex] = true + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + Item: item, + }}, nil, false + } + } + + case AnthropicStreamEventTypeContentBlockDelta: + if chunk.Index != nil && chunk.Delta != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + + // Handle different delta types + switch chunk.Delta.Type { + case AnthropicStreamDeltaTypeText: + if chunk.Delta.Text != nil && *chunk.Delta.Text != "" { + // Text content delta - emit output_text.delta with item ID + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputTextDelta, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Delta: chunk.Delta.Text, + } + if itemID != "" { + response.ItemID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{response}, nil, false + } + + case AnthropicStreamDeltaTypeInputJSON: + // Function call arguments delta + if chunk.Delta.PartialJSON != nil && *chunk.Delta.PartialJSON != "" { + // Check if we're accumulating a computer tool + if state.ComputerToolID != nil && + state.ChunkIndex != nil && + *state.ChunkIndex == *chunk.Index { + // Accumulate the JSON and don't emit anything + state.AccumulatedJSON += *chunk.Delta.PartialJSON + return nil, nil, false + } + + // Accumulate tool arguments in buffer + if _, exists := state.ToolArgumentBuffers[outputIndex]; !exists { + state.ToolArgumentBuffers[outputIndex] = "" + } + state.ToolArgumentBuffers[outputIndex] += *chunk.Delta.PartialJSON + + // Emit appropriate delta type based on whether this is an MCP call + var deltaType schemas.ResponsesStreamResponseType + if state.MCPCallOutputIndices[outputIndex] { + deltaType = schemas.ResponsesStreamResponseTypeMCPCallArgumentsDelta + } else { + deltaType = schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta + } + + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: deltaType, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Delta: chunk.Delta.PartialJSON, + } + if itemID != "" { + response.ItemID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{response}, nil, false + } + + case AnthropicStreamDeltaTypeThinking: + // Reasoning/thinking content delta + if chunk.Delta.Thinking != nil && *chunk.Delta.Thinking != "" { + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeReasoningSummaryTextDelta, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Delta: chunk.Delta.Thinking, + }}, nil, false + } + + case AnthropicStreamDeltaTypeSignature: + // Handle signature verification for thinking content + // This is used to verify the integrity of thinking content + // For now, we don't need to emit a specific event for signatures + return nil, nil, false + } + } + + case AnthropicStreamEventTypeContentBlockStop: + // Content block is complete - emit output_item.done (OpenAI-style) + if chunk.Index != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + + // Check if this is the end of a computer tool accumulation + if state.ComputerToolID != nil && + state.ChunkIndex != nil && + *state.ChunkIndex == *chunk.Index { + + // Parse accumulated JSON and convert to OpenAI format + var inputMap map[string]interface{} + var action *schemas.ResponsesComputerToolCallAction + + if state.AccumulatedJSON != "" { + if err := json.Unmarshal([]byte(state.AccumulatedJSON), &inputMap); err == nil { + action = convertAnthropicToResponsesComputerAction(inputMap) + } + } + + // Create computer_call item with action + statusCompleted := "completed" + item := &schemas.ResponsesMessage{ + ID: state.ComputerToolID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeComputerCall), + Status: &statusCompleted, + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: state.ComputerToolID, + ResponsesComputerToolCall: &schemas.ResponsesComputerToolCall{ + PendingSafetyChecks: []schemas.ResponsesComputerToolCallPendingSafetyCheck{}, + }, + }, + } + + // Add action if we successfully parsed it + if action != nil { + item.ResponsesToolMessage.Action = &schemas.ResponsesToolMessageActionStruct{ + ResponsesComputerToolCallAction: action, + } + } + + // Clear computer tool state + state.ComputerToolID = nil + state.ChunkIndex = nil + state.AccumulatedJSON = "" + + // Return output_item.done + return []*schemas.BifrostResponsesStreamResponse{ + { + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }, + }, nil, false + } + + // Check if this is a tool call (function_call or MCP call) + // If we have accumulated arguments, emit appropriate arguments.done first + var responses []*schemas.BifrostResponsesStreamResponse + if accumulatedArgs, hasArgs := state.ToolArgumentBuffers[outputIndex]; hasArgs && accumulatedArgs != "" { + // Emit appropriate arguments.done based on whether this is an MCP call + var doneType schemas.ResponsesStreamResponseType + if state.MCPCallOutputIndices[outputIndex] { + doneType = schemas.ResponsesStreamResponseTypeMCPCallArgumentsDone + } else { + doneType = schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDone + } + + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: doneType, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Arguments: &accumulatedArgs, + } + if itemID != "" { + response.ItemID = &itemID + } + responses = append(responses, response) + // Clear the buffer and MCP tracking + delete(state.ToolArgumentBuffers, outputIndex) + delete(state.MCPCallOutputIndices, outputIndex) + } + + // Emit output_item.done for all content blocks (text, tool, etc.) + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: doneItem, + }) + + return responses, nil, false + } + + case AnthropicStreamEventTypeMessageDelta: + // Message-level updates (like stop reason, usage, etc.) + // Note: We don't emit output_item.done here because items are already closed + // by content_block_stop. This event is informational only. + return nil, nil, false + + case AnthropicStreamEventTypeMessageStop: + // Message stop - emit response.completed (OpenAI-style) + response := &schemas.BifrostResponsesResponse{ + CreatedAt: state.CreatedAt, + } + if state.MessageID != nil { + response.ID = state.MessageID + } + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeCompleted, + SequenceNumber: sequenceNumber, + Response: response, + }}, nil, true // Indicate stream is complete + + case AnthropicStreamEventTypePing: + // Ping events are just keepalive, no action needed + return nil, nil, false + + case AnthropicStreamEventTypeError: + if chunk.Error != nil { + // Send error event + bifrostErr := &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: &chunk.Error.Type, + Message: chunk.Error.Message, + }, + } + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeError, + SequenceNumber: sequenceNumber, + Message: &chunk.Error.Message, + }}, bifrostErr, false + } + } + + return nil, nil, false +} + +// ToAnthropicResponsesStreamResponse converts a Bifrost Responses stream response to Anthropic SSE string format +func ToAnthropicResponsesStreamResponse(bifrostResp *schemas.BifrostResponsesStreamResponse) string { + if bifrostResp == nil { + return "" + } + + streamResp := &AnthropicStreamEvent{} + + // Map ResponsesStreamResponse types to Anthropic stream events + switch bifrostResp.Type { + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + // Check if this is a computer tool call + if bifrostResp.Item != nil && + bifrostResp.Item.Type != nil && + *bifrostResp.Item.Type == schemas.ResponsesMessageTypeComputerCall { + + // Computer tool - emit content_block_start + streamResp.Type = AnthropicStreamEventTypeContentBlockStart + + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } else if bifrostResp.OutputIndex != nil { + streamResp.Index = bifrostResp.OutputIndex + } + + // Build the content_block + contentBlock := &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + ID: bifrostResp.Item.ID, // The tool use ID + Name: schemas.Ptr(string(AnthropicToolNameComputer)), // "computer" + } + + streamResp.ContentBlock = contentBlock + + } else { + streamResp.Type = AnthropicStreamEventTypeMessageStart + if bifrostResp.Item != nil { + // Create message start event + streamMessage := &AnthropicMessageResponse{ + Type: "message", + Role: string(schemas.ResponsesInputMessageRoleAssistant), + } + if bifrostResp.Item.ID != nil { + streamMessage.ID = *bifrostResp.Item.ID + } + streamResp.Message = streamMessage + } + + } + case schemas.ResponsesStreamResponseTypeContentPartAdded: + streamResp.Type = AnthropicStreamEventTypeContentBlockStart + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } + if bifrostResp.Part != nil { + contentBlock := &AnthropicContentBlock{} + switch bifrostResp.Part.Type { + case schemas.ResponsesOutputMessageContentTypeText: + contentBlock.Type = AnthropicContentBlockTypeText + if bifrostResp.Part.Text != nil { + contentBlock.Text = bifrostResp.Part.Text + } + } + streamResp.ContentBlock = contentBlock + } + + case schemas.ResponsesStreamResponseTypeOutputTextDelta: + streamResp.Type = AnthropicStreamEventTypeContentBlockDelta + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } + if bifrostResp.Delta != nil { + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeText, + Text: bifrostResp.Delta, + } + } + + case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta: + streamResp.Type = AnthropicStreamEventTypeContentBlockDelta + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } + if bifrostResp.Arguments != nil { + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeInputJSON, + PartialJSON: bifrostResp.Arguments, + } + } + + case schemas.ResponsesStreamResponseTypeReasoningSummaryTextDelta: + streamResp.Type = AnthropicStreamEventTypeContentBlockDelta + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } + if bifrostResp.Delta != nil { + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeThinking, + Thinking: bifrostResp.Delta, + } + } + + case schemas.ResponsesStreamResponseTypeContentPartDone: + streamResp.Type = AnthropicStreamEventTypeContentBlockStop + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } + + case schemas.ResponsesStreamResponseTypeOutputItemDone: + if bifrostResp.Item != nil && + bifrostResp.Item.Type != nil && + *bifrostResp.Item.Type == schemas.ResponsesMessageTypeComputerCall { + + // Computer tool complete - emit content_block_delta with the action, then stop + // Note: We're sending the complete action JSON in one delta + streamResp.Type = AnthropicStreamEventTypeContentBlockDelta + + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } else if bifrostResp.OutputIndex != nil { + streamResp.Index = bifrostResp.OutputIndex + } + + // Convert the action to Anthropic format and marshal to JSON + if bifrostResp.Item.ResponsesToolMessage != nil && + bifrostResp.Item.ResponsesToolMessage.Action != nil && + bifrostResp.Item.ResponsesToolMessage.Action.ResponsesComputerToolCallAction != nil { + + actionInput := convertResponsesToAnthropicComputerAction( + bifrostResp.Item.ResponsesToolMessage.Action.ResponsesComputerToolCallAction, + ) + + // Marshal the action to JSON string + if jsonBytes, err := json.Marshal(actionInput); err == nil { + jsonStr := string(jsonBytes) + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeInputJSON, + PartialJSON: &jsonStr, + } + } + } + } else { + streamResp.Type = AnthropicStreamEventTypeMessageDelta + // Add stop reason if available (this would need to be passed through somehow) + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeText, // Use text delta type for message deltas + // StopReason would be set based on the completion reason + } + } + case schemas.ResponsesStreamResponseTypeCompleted: + streamResp.Type = AnthropicStreamEventTypeMessageStop + + case schemas.ResponsesStreamResponseTypeMCPCallArgumentsDelta: + // MCP call arguments delta - convert to content_block_delta with input_json + streamResp.Type = AnthropicStreamEventTypeContentBlockDelta + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } else if bifrostResp.OutputIndex != nil { + streamResp.Index = bifrostResp.OutputIndex + } + if bifrostResp.Delta != nil { + streamResp.Delta = &AnthropicStreamDelta{ + Type: AnthropicStreamDeltaTypeInputJSON, + PartialJSON: bifrostResp.Delta, + } + } + + case schemas.ResponsesStreamResponseTypeMCPCallCompleted: + // MCP call completed - emit content_block_stop + streamResp.Type = AnthropicStreamEventTypeContentBlockStop + if bifrostResp.ContentIndex != nil { + streamResp.Index = bifrostResp.ContentIndex + } else if bifrostResp.OutputIndex != nil { + streamResp.Index = bifrostResp.OutputIndex + } + + case schemas.ResponsesStreamResponseTypeMCPCallFailed: + // MCP call failed - emit error event + streamResp.Type = AnthropicStreamEventTypeError + errorMsg := "MCP call failed" + if bifrostResp.Message != nil { + errorMsg = *bifrostResp.Message + } + streamResp.Error = &AnthropicStreamError{ + Type: "error", + Message: errorMsg, + } + + case schemas.ResponsesStreamResponseTypeError: + streamResp.Type = AnthropicStreamEventTypeError + if bifrostResp.Message != nil { + streamResp.Error = &AnthropicStreamError{ + Type: "error", + Message: *bifrostResp.Message, + } + } + + default: + // Unknown event type, return empty + return "" + } + + // Marshal to JSON and format as SSE + jsonData, err := json.Marshal(streamResp) + if err != nil { + return "" + } + + // Format as Anthropic SSE + return fmt.Sprintf("event: %s\ndata: %s\n\n", streamResp.Type, jsonData) +} + +// ToAnthropicResponsesStreamError converts a BifrostError to Anthropic responses streaming error in SSE format +func ToAnthropicResponsesStreamError(bifrostErr *schemas.BifrostError) string { + if bifrostErr == nil { + return "" + } + + streamResp := &AnthropicStreamEvent{ + Type: AnthropicStreamEventTypeError, + Error: &AnthropicStreamError{ + Type: "error", + Message: bifrostErr.Error.Message, + }, + } + + // Marshal to JSON + jsonData, err := json.Marshal(streamResp) + if err != nil { + return "" + } + + // Format as Anthropic SSE error event + return fmt.Sprintf("event: error\ndata: %s\n\n", jsonData) +} + +// convertAnthropicMessageToBifrostResponsesMessages converts AnthropicMessage to ChatMessage format +func convertAnthropicMessageToBifrostResponsesMessages(msg *AnthropicMessage) []schemas.ResponsesMessage { + var bifrostMessages []schemas.ResponsesMessage + + // Handle text content + if msg.Content.ContentStr != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesMessageRoleType(msg.Role)), + Content: &schemas.ResponsesMessageContent{ + ContentStr: msg.Content.ContentStr, + }, + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } else if msg.Content.ContentBlocks != nil { + // Handle content blocks + for _, block := range msg.Content.ContentBlocks { + switch block.Type { + case AnthropicContentBlockTypeText: + if block.Text != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesMessageRoleType(msg.Role)), + Content: &schemas.ResponsesMessageContent{ + ContentStr: block.Text, + }, + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } + case AnthropicContentBlockTypeImage: + if block.Source != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesMessageRoleType(msg.Role)), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{block.toBifrostResponsesImageBlock()}, + }, + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } + case AnthropicContentBlockTypeToolUse: + // Convert tool use to function call message + if block.ID != nil && block.Name != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: block.ID, + Name: block.Name, + }, + } + + // here need to check for computer tool use + if block.Name != nil && *block.Name == string(AnthropicToolNameComputer) { + bifrostMsg.Type = schemas.Ptr(schemas.ResponsesMessageTypeComputerCall) + bifrostMsg.ResponsesToolMessage.Name = nil + if inputMap, ok := block.Input.(map[string]interface{}); ok { + bifrostMsg.ResponsesToolMessage.Action = &schemas.ResponsesToolMessageActionStruct{ + ResponsesComputerToolCallAction: convertAnthropicToResponsesComputerAction(inputMap), + } + } + } else { + bifrostMsg.ResponsesToolMessage.Arguments = schemas.Ptr(schemas.JsonifyInput(block.Input)) + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } + case AnthropicContentBlockTypeToolResult: + // Convert tool result to function call output message + if block.ToolUseID != nil { + if block.Content != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: block.ToolUseID, + }, + } + // Initialize the nested struct before any writes + bifrostMsg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{} + + if block.Content.ContentStr != nil { + bifrostMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr = block.Content.ContentStr + } else if block.Content.ContentBlocks != nil { + var toolMsgContentBlocks []schemas.ResponsesMessageContentBlock + for _, contentBlock := range block.Content.ContentBlocks { + switch contentBlock.Type { + case AnthropicContentBlockTypeText: + if contentBlock.Text != nil { + toolMsgContentBlocks = append(toolMsgContentBlocks, schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeText, + Text: contentBlock.Text, + }) + } + case AnthropicContentBlockTypeImage: + if contentBlock.Source != nil { + toolMsgContentBlocks = append(toolMsgContentBlocks, contentBlock.toBifrostResponsesImageBlock()) + } + } + } + bifrostMsg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks = toolMsgContentBlocks + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } + } + case AnthropicContentBlockTypeMCPToolUse: + // Convert MCP tool use to MCP call (assistant's tool call) + if block.ID != nil && block.Name != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPCall), + ID: block.ID, + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + Name: block.Name, + Arguments: schemas.Ptr(schemas.JsonifyInput(block.Input)), + }, + } + if block.ServerName != nil { + bifrostMsg.ResponsesToolMessage.ResponsesMCPToolCall = &schemas.ResponsesMCPToolCall{ + ServerLabel: *block.ServerName, + } + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } + case AnthropicContentBlockTypeMCPToolResult: + // Convert MCP tool result to MCP call (user's tool result) + if block.ToolUseID != nil { + bifrostMsg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: block.ToolUseID, + }, + } + // Initialize the nested struct before any writes + bifrostMsg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{} + + if block.Content != nil { + if block.Content.ContentStr != nil { + bifrostMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr = block.Content.ContentStr + } else if block.Content.ContentBlocks != nil { + var toolMsgContentBlocks []schemas.ResponsesMessageContentBlock + for _, contentBlock := range block.Content.ContentBlocks { + if contentBlock.Type == AnthropicContentBlockTypeText { + if contentBlock.Text != nil { + toolMsgContentBlocks = append(toolMsgContentBlocks, schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeText, + Text: contentBlock.Text, + }) + } + } + } + bifrostMsg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks = toolMsgContentBlocks + } + } + bifrostMessages = append(bifrostMessages, bifrostMsg) + } + + } + } + } + + return bifrostMessages +} + +// convertAnthropicToolToBifrost converts AnthropicTool to schemas.Tool +func convertAnthropicToolToBifrost(tool *AnthropicTool) *schemas.ResponsesTool { + if tool == nil { + return nil + } + + // Handle special tool types first + if tool.Type != nil { + switch *tool.Type { + case AnthropicToolTypeComputer20250124: + bifrostTool := &schemas.ResponsesTool{ + Type: schemas.ResponsesToolTypeComputerUsePreview, + } + if tool.AnthropicToolComputerUse != nil { + bifrostTool.ResponsesToolComputerUsePreview = &schemas.ResponsesToolComputerUsePreview{ + Environment: "browser", // Default environment + } + if tool.AnthropicToolComputerUse.DisplayWidthPx != nil { + bifrostTool.ResponsesToolComputerUsePreview.DisplayWidth = *tool.AnthropicToolComputerUse.DisplayWidthPx + } + if tool.AnthropicToolComputerUse.DisplayHeightPx != nil { + bifrostTool.ResponsesToolComputerUsePreview.DisplayHeight = *tool.AnthropicToolComputerUse.DisplayHeightPx + } + } + return bifrostTool + + case AnthropicToolTypeWebSearch20250305: + bifrostTool := &schemas.ResponsesTool{ + Type: schemas.ResponsesToolTypeWebSearch, + Name: &tool.Name, + } + if tool.AnthropicToolWebSearch != nil { + bifrostTool.ResponsesToolWebSearch = &schemas.ResponsesToolWebSearch{ + Filters: &schemas.ResponsesToolWebSearchFilters{ + AllowedDomains: tool.AnthropicToolWebSearch.AllowedDomains, + }, + } + if tool.AnthropicToolWebSearch.UserLocation != nil { + bifrostTool.ResponsesToolWebSearch.UserLocation = &schemas.ResponsesToolWebSearchUserLocation{ + Type: tool.AnthropicToolWebSearch.UserLocation.Type, + City: tool.AnthropicToolWebSearch.UserLocation.City, + Country: tool.AnthropicToolWebSearch.UserLocation.Country, + Timezone: tool.AnthropicToolWebSearch.UserLocation.Timezone, + } + } + } + return bifrostTool + + case AnthropicToolTypeBash20250124: + return &schemas.ResponsesTool{ + Type: schemas.ResponsesToolTypeLocalShell, + } + + case AnthropicToolTypeTextEditor20250124: + return &schemas.ResponsesTool{ + Type: schemas.ResponsesToolType(AnthropicToolTypeTextEditor20250124), + Name: &tool.Name, + } + case AnthropicToolTypeTextEditor20250429: + return &schemas.ResponsesTool{ + Type: schemas.ResponsesToolType(AnthropicToolTypeTextEditor20250429), + Name: &tool.Name, + } + case AnthropicToolTypeTextEditor20250728: + return &schemas.ResponsesTool{ + Type: schemas.ResponsesToolType(AnthropicToolTypeTextEditor20250728), + Name: &tool.Name, + } + } + } + + // Handle custom/default tool type (function) + bifrostTool := &schemas.ResponsesTool{ + Type: schemas.ResponsesToolTypeFunction, + Name: &tool.Name, + Description: tool.Description, + } + + if tool.InputSchema != nil { + bifrostTool.ResponsesToolFunction = &schemas.ResponsesToolFunction{ + Parameters: tool.InputSchema, + } + } + + return bifrostTool +} + +// convertAnthropicToolChoiceToBifrost converts AnthropicToolChoice to schemas.ToolChoice +func convertAnthropicToolChoiceToBifrost(toolChoice *AnthropicToolChoice) *schemas.ResponsesToolChoice { + if toolChoice == nil { + return nil + } + + bifrostToolChoice := &schemas.ResponsesToolChoice{} + + // Handle string format + if toolChoice.Type != "" { + switch toolChoice.Type { + case "auto": + bifrostToolChoice.ResponsesToolChoiceStr = schemas.Ptr(string(schemas.ResponsesToolChoiceTypeAuto)) + case "any": + bifrostToolChoice.ResponsesToolChoiceStr = schemas.Ptr(string(schemas.ResponsesToolChoiceTypeAny)) + case "none": + bifrostToolChoice.ResponsesToolChoiceStr = schemas.Ptr(string(schemas.ResponsesToolChoiceTypeNone)) + case "tool": + // Handle forced tool choice with specific function name + bifrostToolChoice.ResponsesToolChoiceStruct = &schemas.ResponsesToolChoiceStruct{ + Type: schemas.ResponsesToolChoiceTypeFunction, + Name: &toolChoice.Name, + } + return bifrostToolChoice + default: + bifrostToolChoice.ResponsesToolChoiceStr = schemas.Ptr(string(schemas.ResponsesToolChoiceTypeAuto)) + } + } + + return bifrostToolChoice +} + +// flushPendingToolCalls is a helper that flushes accumulated tool calls into an assistant message +func flushPendingToolCalls( + pendingToolCalls []AnthropicContentBlock, + currentAssistantMessage *AnthropicMessage, + anthropicMessages []AnthropicMessage, +) ([]AnthropicContentBlock, *AnthropicMessage, []AnthropicMessage) { + if len(pendingToolCalls) > 0 && currentAssistantMessage != nil { + // Copy the slice to avoid aliasing issues + copied := make([]AnthropicContentBlock, len(pendingToolCalls)) + copy(copied, pendingToolCalls) + currentAssistantMessage.Content = AnthropicContent{ + ContentBlocks: copied, + } + anthropicMessages = append(anthropicMessages, *currentAssistantMessage) + // Return nil values to indicate flushed state + return nil, nil, anthropicMessages + } + // Return unchanged values if no flush was needed + return pendingToolCalls, currentAssistantMessage, anthropicMessages +} + +// convertToolOutputToAnthropicContent converts tool output to Anthropic content format +func convertToolOutputToAnthropicContent(output *schemas.ResponsesToolMessageOutputStruct) *AnthropicContent { + if output == nil { + return nil + } + + if output.ResponsesToolCallOutputStr != nil { + return &AnthropicContent{ + ContentStr: output.ResponsesToolCallOutputStr, + } + } + + if output.ResponsesFunctionToolCallOutputBlocks != nil { + var resultBlocks []AnthropicContentBlock + for _, block := range output.ResponsesFunctionToolCallOutputBlocks { + if converted := convertContentBlockToAnthropic(block); converted != nil { + resultBlocks = append(resultBlocks, *converted) + } + } + if len(resultBlocks) > 0 { + return &AnthropicContent{ + ContentBlocks: resultBlocks, + } + } + } + + if output.ResponsesComputerToolCallOutput != nil && output.ResponsesComputerToolCallOutput.ImageURL != nil { + imgBlock := ConvertToAnthropicImageBlock(schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: *output.ResponsesComputerToolCallOutput.ImageURL, + }, + }) + return &AnthropicContent{ + ContentBlocks: []AnthropicContentBlock{imgBlock}, + } + } + + return nil +} + +// Helper function to convert ResponsesInputItems back to AnthropicMessages +func convertResponsesMessagesToAnthropicMessages(messages []schemas.ResponsesMessage) ([]AnthropicMessage, *AnthropicContent) { + var anthropicMessages []AnthropicMessage + var systemContent *AnthropicContent + var pendingToolCalls []AnthropicContentBlock + var currentAssistantMessage *AnthropicMessage + + for _, msg := range messages { + // Handle nil Type as regular message + msgType := schemas.ResponsesMessageTypeMessage + if msg.Type != nil { + msgType = *msg.Type + } + + switch msgType { + case schemas.ResponsesMessageTypeMessage: + // Flush any pending tool calls first + pendingToolCalls, currentAssistantMessage, anthropicMessages = flushPendingToolCalls( + pendingToolCalls, currentAssistantMessage, anthropicMessages) + + // Handle system messages separately + if msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleSystem { + if msg.Content != nil { + if msg.Content.ContentStr != nil { + systemContent = &AnthropicContent{ + ContentStr: msg.Content.ContentStr, + } + } else if msg.Content.ContentBlocks != nil { + contentBlocks := convertBifrostContentBlocksToAnthropic(msg.Content.ContentBlocks) + if len(contentBlocks) > 0 { + systemContent = &AnthropicContent{ + ContentBlocks: contentBlocks, + } + } + } + } + continue + } + + // Regular user/assistant message + anthropicMsg := AnthropicMessage{} + + // Set role + if msg.Role != nil { + switch *msg.Role { + case schemas.ResponsesInputMessageRoleUser: + anthropicMsg.Role = AnthropicMessageRoleUser + case schemas.ResponsesInputMessageRoleAssistant: + anthropicMsg.Role = AnthropicMessageRoleAssistant + default: + anthropicMsg.Role = AnthropicMessageRoleUser // Default fallback + } + } else { + anthropicMsg.Role = AnthropicMessageRoleUser // Default fallback + } + + // Convert content + if msg.Content != nil { + if msg.Content.ContentStr != nil { + anthropicMsg.Content = AnthropicContent{ + ContentStr: msg.Content.ContentStr, + } + } else if msg.Content.ContentBlocks != nil { + contentBlocks := convertBifrostContentBlocksToAnthropic(msg.Content.ContentBlocks) + if len(contentBlocks) > 0 { + anthropicMsg.Content = AnthropicContent{ + ContentBlocks: contentBlocks, + } + } + } + } + + anthropicMessages = append(anthropicMessages, anthropicMsg) + + case schemas.ResponsesMessageTypeReasoning: + // Handle reasoning as thinking content + if msg.ResponsesReasoning != nil && len(msg.ResponsesReasoning.Summary) > 0 { + // Find the last assistant message or create one + var targetMsg *AnthropicMessage + if len(anthropicMessages) > 0 && anthropicMessages[len(anthropicMessages)-1].Role == AnthropicMessageRoleAssistant { + targetMsg = &anthropicMessages[len(anthropicMessages)-1] + } else { + // Create new assistant message for reasoning + newMsg := AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } + anthropicMessages = append(anthropicMessages, newMsg) + targetMsg = &anthropicMessages[len(anthropicMessages)-1] + } + + // Add thinking blocks + var contentBlocks []AnthropicContentBlock + if targetMsg.Content.ContentBlocks != nil { + contentBlocks = targetMsg.Content.ContentBlocks + } + + for _, reasoningContent := range msg.ResponsesReasoning.Summary { + thinkingBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeThinking, + Thinking: &reasoningContent.Text, + } + contentBlocks = append(contentBlocks, thinkingBlock) + } + + targetMsg.Content = AnthropicContent{ + ContentBlocks: contentBlocks, + } + } + + case schemas.ResponsesMessageTypeFunctionCall: + // Start accumulating tool calls for assistant message + if currentAssistantMessage == nil { + currentAssistantMessage = &AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } + } + + if msg.ResponsesToolMessage != nil { + toolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + } + + if msg.ResponsesToolMessage.CallID != nil { + toolUseBlock.ID = msg.ResponsesToolMessage.CallID + } + if msg.ResponsesToolMessage.Name != nil { + toolUseBlock.Name = msg.ResponsesToolMessage.Name + } + + // Parse arguments as JSON input + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments) + } + + pendingToolCalls = append(pendingToolCalls, toolUseBlock) + } + + case schemas.ResponsesMessageTypeFunctionCallOutput: + // Flush any pending tool calls first before processing tool results + pendingToolCalls, currentAssistantMessage, anthropicMessages = flushPendingToolCalls( + pendingToolCalls, currentAssistantMessage, anthropicMessages) + + // Handle tool call output - convert to user message with tool_result + if msg.ResponsesToolMessage != nil { + toolResultBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolResult, + ToolUseID: msg.ResponsesToolMessage.CallID, + } + + if msg.ResponsesToolMessage.Output != nil { + toolResultBlock.Content = convertToolOutputToAnthropicContent(msg.ResponsesToolMessage.Output) + } + + toolResultMsg := AnthropicMessage{ + Role: AnthropicMessageRoleUser, + Content: AnthropicContent{ + ContentBlocks: []AnthropicContentBlock{toolResultBlock}, + }, + } + + anthropicMessages = append(anthropicMessages, toolResultMsg) + } + + case schemas.ResponsesMessageTypeItemReference: + // Handle item reference as regular text message + if msg.Content != nil && msg.Content.ContentStr != nil { + referenceMsg := AnthropicMessage{ + Role: AnthropicMessageRoleUser, // Default to user for references + } + if msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleAssistant { + referenceMsg.Role = AnthropicMessageRoleAssistant + } + + referenceMsg.Content = AnthropicContent{ + ContentStr: msg.Content.ContentStr, + } + + anthropicMessages = append(anthropicMessages, referenceMsg) + } + case schemas.ResponsesMessageTypeComputerCall: + // Start accumulating tool calls for assistant message + if currentAssistantMessage == nil { + currentAssistantMessage = &AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } + } + + if msg.ResponsesToolMessage != nil { + toolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + Name: schemas.Ptr(string(AnthropicToolNameComputer)), + } + if msg.ResponsesToolMessage.CallID != nil { + toolUseBlock.ID = msg.ResponsesToolMessage.CallID + } + if msg.ResponsesToolMessage.Name != nil { + toolUseBlock.Name = msg.ResponsesToolMessage.Name + } + + if msg.ResponsesToolMessage.Action != nil && msg.ResponsesToolMessage.Action.ResponsesComputerToolCallAction != nil { + toolUseBlock.Input = convertResponsesToAnthropicComputerAction(msg.ResponsesToolMessage.Action.ResponsesComputerToolCallAction) + } + + pendingToolCalls = append(pendingToolCalls, toolUseBlock) + } + + case schemas.ResponsesMessageTypeMCPCall: + // Check if this is a tool use (from assistant) or tool result (from user) + // Tool use: has Name and Arguments but no Output + // Tool result: has CallID and Output + if msg.ResponsesToolMessage != nil { + // This is a tool use call (assistant calling a tool) + if msg.ResponsesToolMessage.Name != nil { + // Start accumulating MCP tool calls for assistant message + if currentAssistantMessage == nil { + currentAssistantMessage = &AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } + } + + toolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeMCPToolUse, + } + + if msg.ID != nil { + toolUseBlock.ID = msg.ID + } + toolUseBlock.Name = msg.ResponsesToolMessage.Name + + // Set server name if present + if msg.ResponsesToolMessage.ResponsesMCPToolCall != nil && msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel != "" { + toolUseBlock.ServerName = &msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel + } + + // Parse arguments as JSON input + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments) + } + + pendingToolCalls = append(pendingToolCalls, toolUseBlock) + } else if msg.ResponsesToolMessage.CallID != nil { + // This is a tool result (user providing result of tool execution) + toolResultBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeMCPToolResult, + ID: msg.ResponsesToolMessage.CallID, + } + + if msg.ResponsesToolMessage.Output != nil { + toolResultBlock.Content = convertToolOutputToAnthropicContent(msg.ResponsesToolMessage.Output) + } + + toolResultMsg := AnthropicMessage{ + Role: AnthropicMessageRoleUser, + Content: AnthropicContent{ + ContentBlocks: []AnthropicContentBlock{toolResultBlock}, + }, + } + + anthropicMessages = append(anthropicMessages, toolResultMsg) + } + } + + case schemas.ResponsesMessageTypeMCPApprovalRequest: + // MCP approval request is OpenAI-specific for human-in-the-loop workflows + // Convert to Anthropic's mcp_tool_use format (same as regular MCP calls) + if currentAssistantMessage == nil { + currentAssistantMessage = &AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } + } + + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Name != nil { + toolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeMCPToolUse, + } + + if msg.ID != nil { + toolUseBlock.ID = msg.ID + } + toolUseBlock.Name = msg.ResponsesToolMessage.Name + + // Set server name if present + if msg.ResponsesToolMessage.ResponsesMCPToolCall != nil && msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel != "" { + toolUseBlock.ServerName = &msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel + } + + // Parse arguments as JSON input + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments) + } + + pendingToolCalls = append(pendingToolCalls, toolUseBlock) + } + + // Handle other tool call types that are not natively supported by Anthropic + case schemas.ResponsesMessageTypeFileSearchCall, + schemas.ResponsesMessageTypeCodeInterpreterCall, + schemas.ResponsesMessageTypeWebSearchCall, + schemas.ResponsesMessageTypeLocalShellCall, + schemas.ResponsesMessageTypeCustomToolCall, + schemas.ResponsesMessageTypeImageGenerationCall: + // Convert unsupported tool calls to regular text messages + if msg.ResponsesToolMessage != nil { + toolCallMsg := AnthropicMessage{ + Role: AnthropicMessageRoleAssistant, + } + + var description string + if msg.ResponsesToolMessage.Name != nil { + description = fmt.Sprintf("Tool call: %s", *msg.ResponsesToolMessage.Name) + if msg.ResponsesToolMessage.Arguments != nil { + description += fmt.Sprintf(" with arguments: %s", *msg.ResponsesToolMessage.Arguments) + } + } else { + description = fmt.Sprintf("Tool call of type: %s", msgType) + } + + toolCallMsg.Content = AnthropicContent{ + ContentStr: &description, + } + + anthropicMessages = append(anthropicMessages, toolCallMsg) + } + + case schemas.ResponsesMessageTypeComputerCallOutput: + // Flush any pending tool calls first before processing tool results + pendingToolCalls, currentAssistantMessage, anthropicMessages = flushPendingToolCalls( + pendingToolCalls, currentAssistantMessage, anthropicMessages) + + // Handle computer call output - convert to user message with tool_result + if msg.ResponsesToolMessage != nil { + toolResultBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolResult, + ToolUseID: msg.ResponsesToolMessage.CallID, + } + + if msg.ResponsesToolMessage.Output != nil { + toolResultBlock.Content = convertToolOutputToAnthropicContent(msg.ResponsesToolMessage.Output) + } + + toolResultMsg := AnthropicMessage{ + Role: AnthropicMessageRoleUser, + Content: AnthropicContent{ + ContentBlocks: []AnthropicContentBlock{toolResultBlock}, + }, + } + + anthropicMessages = append(anthropicMessages, toolResultMsg) + } + + case schemas.ResponsesMessageTypeLocalShellCallOutput, + schemas.ResponsesMessageTypeCustomToolCallOutput: + // Handle tool outputs as user messages + if msg.ResponsesToolMessage != nil { + toolOutputMsg := AnthropicMessage{ + Role: AnthropicMessageRoleUser, + } + + var outputText string + // Try to extract output text based on tool type + if msg.ResponsesToolMessage.Output != nil && msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + outputText = *msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr + } + + if outputText != "" { + toolOutputMsg.Content = AnthropicContent{ + ContentStr: &outputText, + } + anthropicMessages = append(anthropicMessages, toolOutputMsg) + } + } + + default: + // Skip unknown message types or log them for debugging + continue + } + } + + // Flush any remaining pending tool calls + pendingToolCalls, currentAssistantMessage, anthropicMessages = flushPendingToolCalls( + pendingToolCalls, currentAssistantMessage, anthropicMessages) + + return anthropicMessages, systemContent +} + +// Helper function to convert Tool back to AnthropicTool +func convertBifrostToolToAnthropic(tool *schemas.ResponsesTool) *AnthropicTool { + if tool == nil { + return nil + } + + switch tool.Type { + case schemas.ResponsesToolTypeComputerUsePreview: + if tool.ResponsesToolComputerUsePreview != nil { + return &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeComputer20250124), + Name: string(AnthropicToolNameComputer), + AnthropicToolComputerUse: &AnthropicToolComputerUse{ + DisplayWidthPx: schemas.Ptr(tool.ResponsesToolComputerUsePreview.DisplayWidth), + DisplayHeightPx: schemas.Ptr(tool.ResponsesToolComputerUsePreview.DisplayHeight), + DisplayNumber: schemas.Ptr(1), + }, + } + } + case schemas.ResponsesToolTypeWebSearch: + anthropicTool := &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeWebSearch20250305), + Name: string(AnthropicToolNameWebSearch), + AnthropicToolWebSearch: &AnthropicToolWebSearch{}, + } + if tool.ResponsesToolWebSearch != nil { + if tool.ResponsesToolWebSearch.Filters != nil { + anthropicTool.AnthropicToolWebSearch.AllowedDomains = tool.ResponsesToolWebSearch.Filters.AllowedDomains + } + if tool.ResponsesToolWebSearch.UserLocation != nil { + anthropicTool.AnthropicToolWebSearch.UserLocation = &AnthropicToolWebSearchUserLocation{ + Type: tool.ResponsesToolWebSearch.UserLocation.Type, + City: tool.ResponsesToolWebSearch.UserLocation.City, + Country: tool.ResponsesToolWebSearch.UserLocation.Country, + Timezone: tool.ResponsesToolWebSearch.UserLocation.Timezone, + } + } + } + + return anthropicTool + case schemas.ResponsesToolTypeLocalShell: + return &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeBash20250124), + Name: string(AnthropicToolNameBash), + } + case schemas.ResponsesToolType(AnthropicToolTypeTextEditor20250124): + return &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeTextEditor20250124), + Name: string(AnthropicToolNameTextEditor), + } + case schemas.ResponsesToolType(AnthropicToolTypeTextEditor20250429): + return &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeTextEditor20250429), + Name: string(AnthropicToolNameTextEditor), + } + case schemas.ResponsesToolType(AnthropicToolTypeTextEditor20250728): + return &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeTextEditor20250728), + Name: string(AnthropicToolNameTextEditor), + } + } + + anthropicTool := &AnthropicTool{ + Type: schemas.Ptr(AnthropicToolTypeCustom), + } + + if tool.Name != nil { + anthropicTool.Name = *tool.Name + } + + if tool.Description != nil { + anthropicTool.Description = tool.Description + } + + // Convert parameters from ToolFunction + if tool.ResponsesToolFunction != nil { + anthropicTool.InputSchema = tool.ResponsesToolFunction.Parameters + } + + return anthropicTool +} + +// Helper function to convert ResponsesToolChoice back to AnthropicToolChoice +func convertResponsesToolChoiceToAnthropic(toolChoice *schemas.ResponsesToolChoice) *AnthropicToolChoice { + if toolChoice == nil { + return nil + } + // String-form choices (auto/any/none/required) have no struct payload. + if toolChoice.ResponsesToolChoiceStruct == nil && toolChoice.ResponsesToolChoiceStr != nil { + switch schemas.ResponsesToolChoiceType(*toolChoice.ResponsesToolChoiceStr) { + case schemas.ResponsesToolChoiceTypeAuto: + return &AnthropicToolChoice{Type: "auto"} + case schemas.ResponsesToolChoiceTypeAny, schemas.ResponsesToolChoiceTypeRequired: + return &AnthropicToolChoice{Type: "any"} + case schemas.ResponsesToolChoiceTypeNone: + return &AnthropicToolChoice{Type: "none"} + default: + return nil + } + } + + if toolChoice.ResponsesToolChoiceStruct == nil { + return nil + } + + anthropicChoice := &AnthropicToolChoice{} + + var toolChoiceType *string + if toolChoice.ResponsesToolChoiceStruct != nil { + toolChoiceType = schemas.Ptr(string(toolChoice.ResponsesToolChoiceStruct.Type)) + } else { + toolChoiceType = toolChoice.ResponsesToolChoiceStr + } + + switch *toolChoiceType { + case "auto": + anthropicChoice.Type = "auto" + case "required": + anthropicChoice.Type = "any" + case "function": + // Handle function type - set as "tool" with specific function name + if toolChoice.ResponsesToolChoiceStruct != nil && toolChoice.ResponsesToolChoiceStruct.Name != nil { + anthropicChoice.Type = "tool" + anthropicChoice.Name = *toolChoice.ResponsesToolChoiceStruct.Name + } + return anthropicChoice + } + + // Legacy fallback: also check for Name field (for backward compatibility) + if toolChoice.ResponsesToolChoiceStruct != nil && toolChoice.ResponsesToolChoiceStruct.Name != nil { + anthropicChoice.Type = "tool" + anthropicChoice.Name = *toolChoice.ResponsesToolChoiceStruct.Name + } + + return anthropicChoice +} + +// Helper function to convert Anthropic content blocks to Responses output messages +func convertAnthropicContentBlocksToResponsesMessages(content []AnthropicContentBlock) []schemas.ResponsesMessage { + var messages []schemas.ResponsesMessage + + for _, block := range content { + switch block.Type { + case AnthropicContentBlockTypeText: + if block.Text != nil { + // Append text to existing message + messages = append(messages, schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: block.Text, + }, + }, + }, + }) + } + + case AnthropicContentBlockTypeImage: + if block.Source != nil { + messages = append(messages, schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + block.toBifrostResponsesImageBlock(), + }, + }, + }) + } + + case AnthropicContentBlockTypeThinking: + if block.Thinking != nil { + // Create reasoning message + messages = append(messages, schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeReasoning, + Text: block.Thinking, + }, + }, + }, + ResponsesReasoning: &schemas.ResponsesReasoning{ + Summary: []schemas.ResponsesReasoningContent{ + { + Text: *block.Thinking, + Type: schemas.ResponsesReasoningContentBlockTypeSummaryText, + }, + }, + EncryptedContent: block.Signature, + }, + }) + } + + case AnthropicContentBlockTypeToolUse: + if block.ID != nil && block.Name != nil { + // Create function call message + message := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: block.ID, + Name: block.Name, + }, + } + + if block.Name != nil && *block.Name == string(AnthropicToolNameComputer) { + message.Type = schemas.Ptr(schemas.ResponsesMessageTypeComputerCall) + message.ResponsesToolMessage.Name = nil + if inputMap, ok := block.Input.(map[string]interface{}); ok { + message.ResponsesToolMessage.Action = &schemas.ResponsesToolMessageActionStruct{ + ResponsesComputerToolCallAction: convertAnthropicToResponsesComputerAction(inputMap), + } + } + } else { + message.ResponsesToolMessage.Arguments = schemas.Ptr(schemas.JsonifyInput(block.Input)) + } + + messages = append(messages, message) + } + case AnthropicContentBlockTypeToolResult: + if block.ToolUseID != nil { + // Create function call output message + msg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: block.ToolUseID, + }, + } + // Initialize nested output struct + msg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{} + if block.Content != nil { + if block.Content.ContentStr != nil { + msg.ResponsesToolMessage.Output. + ResponsesToolCallOutputStr = block.Content.ContentStr + } else if block.Content.ContentBlocks != nil { + var outBlocks []schemas.ResponsesMessageContentBlock + for _, cb := range block.Content.ContentBlocks { + switch cb.Type { + case AnthropicContentBlockTypeText: + if cb.Text != nil { + outBlocks = append(outBlocks, schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeText, + Text: cb.Text, + }) + } + case AnthropicContentBlockTypeImage: + if cb.Source != nil { + outBlocks = append(outBlocks, cb.toBifrostResponsesImageBlock()) + } + } + } + msg.ResponsesToolMessage.Output. + ResponsesFunctionToolCallOutputBlocks = outBlocks + } + } + messages = append(messages, msg) + } + + case AnthropicContentBlockTypeMCPToolUse: + if block.ID != nil && block.Name != nil { + // Create MCP call message (tool invocation from assistant) + message := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPCall), + ID: block.ID, + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + Name: block.Name, + Arguments: schemas.Ptr(schemas.JsonifyInput(block.Input)), + }, + } + + // Set server name if present + if block.ServerName != nil { + message.ResponsesToolMessage.ResponsesMCPToolCall = &schemas.ResponsesMCPToolCall{ + ServerLabel: *block.ServerName, + } + } + + messages = append(messages, message) + } + + case AnthropicContentBlockTypeMCPToolResult: + if block.ToolUseID != nil { + // Create MCP call message (tool result) + msg := schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMCPCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: block.ToolUseID, + }, + } + // Initialize nested output struct + msg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{} + if block.Content != nil { + if block.Content.ContentStr != nil { + msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr = block.Content.ContentStr + } else if block.Content.ContentBlocks != nil { + var outBlocks []schemas.ResponsesMessageContentBlock + for _, cb := range block.Content.ContentBlocks { + if cb.Type == AnthropicContentBlockTypeText { + if cb.Text != nil { + outBlocks = append(outBlocks, schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: cb.Text, + }) + } + } + } + msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks = outBlocks + } + } + messages = append(messages, msg) + } + + default: + // Handle other block types if needed + } + } + return messages +} + +// Helper function to convert ChatMessage output to Anthropic content blocks +func convertBifrostMessagesToAnthropicContent(messages []schemas.ResponsesMessage) []AnthropicContentBlock { + var contentBlocks []AnthropicContentBlock + + for _, msg := range messages { + // Handle different message types based on Responses structure + if msg.Type != nil { + switch *msg.Type { + case schemas.ResponsesMessageTypeMessage: + // Regular text message + if msg.Content != nil { + if msg.Content.ContentStr != nil { + contentBlocks = append(contentBlocks, AnthropicContentBlock{ + Type: "text", + Text: msg.Content.ContentStr, + }) + } else if msg.Content.ContentBlocks != nil { + // Convert content blocks + for _, block := range msg.Content.ContentBlocks { + anthropicBlock := convertContentBlockToAnthropic(block) + if anthropicBlock != nil { + contentBlocks = append(contentBlocks, *anthropicBlock) + } + } + } + } + + case schemas.ResponsesMessageTypeFunctionCall: + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.CallID != nil { + toolBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + ID: msg.ResponsesToolMessage.CallID, + } + if msg.ResponsesToolMessage.Name != nil { + toolBlock.Name = msg.ResponsesToolMessage.Name + } + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + toolBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments) + } + contentBlocks = append(contentBlocks, toolBlock) + } + + case schemas.ResponsesMessageTypeFunctionCallOutput: + // Tool result block - need to extract from ToolMessage + resultBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolResult, + } + + if msg.ResponsesToolMessage != nil { + resultBlock.ToolUseID = msg.ResponsesToolMessage.CallID + // Try content from msg.Content first, then Output + if msg.Content != nil && msg.Content.ContentStr != nil { + resultBlock.Content = &AnthropicContent{ + ContentStr: msg.Content.ContentStr, + } + } else if msg.ResponsesToolMessage.Output != nil { + resultBlock.Content = convertToolOutputToAnthropicContent(msg.ResponsesToolMessage.Output) + } + } else if msg.Content != nil && msg.Content.ContentStr != nil { + // Fallback to msg.Content when ResponsesToolMessage is nil + resultBlock.Content = &AnthropicContent{ + ContentStr: msg.Content.ContentStr, + } + } + + contentBlocks = append(contentBlocks, resultBlock) + + case schemas.ResponsesMessageTypeReasoning: + // Build thinking from ResponsesReasoning summary, else from reasoning content blocks + var thinking string + var signature *string + if msg.ResponsesReasoning != nil && msg.ResponsesReasoning.Summary != nil { + for _, b := range msg.ResponsesReasoning.Summary { + thinking += b.Text + } + signature = msg.ResponsesReasoning.EncryptedContent + } else if msg.Content != nil && msg.Content.ContentBlocks != nil { + for _, b := range msg.Content.ContentBlocks { + if b.Type == schemas.ResponsesOutputMessageContentTypeReasoning && b.Text != nil { + thinking += *b.Text + } + } + } + if thinking != "" { + contentBlocks = append(contentBlocks, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeThinking, + Thinking: &thinking, + Signature: signature, + }) + } + + case schemas.ResponsesMessageTypeComputerCall: + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.CallID != nil { + toolBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeToolUse, + ID: msg.ResponsesToolMessage.CallID, + Name: schemas.Ptr(string(AnthropicToolNameComputer)), + } + + // Convert computer action to Anthropic input format + if msg.ResponsesToolMessage.Action != nil && msg.ResponsesToolMessage.Action.ResponsesComputerToolCallAction != nil { + toolBlock.Input = convertResponsesToAnthropicComputerAction(msg.ResponsesToolMessage.Action.ResponsesComputerToolCallAction) + } + contentBlocks = append(contentBlocks, toolBlock) + } + + case schemas.ResponsesMessageTypeMCPCall: + // Check if this is a tool use (from assistant) or tool result (from user) + // Tool use: has Name and Arguments but no Output + // Tool result: has CallID and Output + if msg.ResponsesToolMessage != nil { + if msg.ResponsesToolMessage.Name != nil { + // This is a tool use call (assistant calling a tool) + toolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeMCPToolUse, + } + + if msg.ID != nil { + toolUseBlock.ID = msg.ID + } + + if msg.ResponsesToolMessage.Name != nil { + toolUseBlock.Name = msg.ResponsesToolMessage.Name + } + + // Set server name if present + if msg.ResponsesToolMessage.ResponsesMCPToolCall != nil && msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel != "" { + toolUseBlock.ServerName = &msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel + } + + // Parse arguments as JSON input + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments) + } + + contentBlocks = append(contentBlocks, toolUseBlock) + } else if msg.ResponsesToolMessage.CallID != nil { + // This is a tool result (user providing result of tool execution) + resultBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeMCPToolResult, + ToolUseID: msg.ResponsesToolMessage.CallID, + } + + if msg.ResponsesToolMessage.Output != nil { + resultBlock.Content = convertToolOutputToAnthropicContent(msg.ResponsesToolMessage.Output) + } + + contentBlocks = append(contentBlocks, resultBlock) + } + } + + case schemas.ResponsesMessageTypeMCPApprovalRequest: + // MCP approval request is OpenAI-specific for human-in-the-loop workflows + // Convert to Anthropic's mcp_tool_use format (same as regular MCP calls) + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Name != nil { + toolUseBlock := AnthropicContentBlock{ + Type: AnthropicContentBlockTypeMCPToolUse, + } + + if msg.ID != nil { + toolUseBlock.ID = msg.ID + } + toolUseBlock.Name = msg.ResponsesToolMessage.Name + + // Set server name if present + if msg.ResponsesToolMessage.ResponsesMCPToolCall != nil && msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel != "" { + toolUseBlock.ServerName = &msg.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel + } + + // Parse arguments as JSON input + if msg.ResponsesToolMessage.Arguments != nil && *msg.ResponsesToolMessage.Arguments != "" { + toolUseBlock.Input = parseJSONInput(*msg.ResponsesToolMessage.Arguments) + } + + contentBlocks = append(contentBlocks, toolUseBlock) + } + + default: + // Handle other types as text if they have content + if msg.Content != nil && msg.Content.ContentStr != nil { + contentBlocks = append(contentBlocks, AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: msg.Content.ContentStr, + }) + } + } + } + } + + return contentBlocks +} + +// Helper function to convert ContentBlock to AnthropicContentBlock +func convertContentBlockToAnthropic(block schemas.ResponsesMessageContentBlock) *AnthropicContentBlock { + switch block.Type { + case schemas.ResponsesInputMessageContentBlockTypeText, schemas.ResponsesOutputMessageContentTypeText: + if block.Text != nil { + return &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeText, + Text: block.Text, + } + } + case schemas.ResponsesInputMessageContentBlockTypeImage: + if block.ResponsesInputMessageContentBlockImage != nil && block.ResponsesInputMessageContentBlockImage.ImageURL != nil { + // Convert using the same logic as ConvertToAnthropicImageBlock + chatBlock := schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: *block.ResponsesInputMessageContentBlockImage.ImageURL, + }, + } + anthropicBlock := ConvertToAnthropicImageBlock(chatBlock) + return &anthropicBlock + } + case schemas.ResponsesOutputMessageContentTypeReasoning: + if block.Text != nil { + return &AnthropicContentBlock{ + Type: AnthropicContentBlockTypeThinking, + Thinking: block.Text, + } + } + } + return nil +} + +// Helper to convert Bifrost content blocks slice to Anthropic content blocks +func convertBifrostContentBlocksToAnthropic(blocks []schemas.ResponsesMessageContentBlock) []AnthropicContentBlock { + if len(blocks) == 0 { + return nil + } + var result []AnthropicContentBlock + for _, block := range blocks { + if converted := convertContentBlockToAnthropic(block); converted != nil { + result = append(result, *converted) + } + } + if len(result) > 0 { + return result + } + return nil +} + +func (block AnthropicContentBlock) toBifrostResponsesImageBlock() schemas.ResponsesMessageContentBlock { + return schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeImage, + ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{ + ImageURL: schemas.Ptr(getImageURLFromBlock(block)), + }, + } +} + +// Helper functions for MCP tool/server conversion +// convertAnthropicMCPServerToBifrostTool converts a single Anthropic MCP server to a Bifrost ResponsesTool +func convertAnthropicMCPServerToBifrostTool(mcpServer *AnthropicMCPServer) *schemas.ResponsesTool { + if mcpServer == nil { + return nil + } + + bifrostTool := &schemas.ResponsesTool{ + Type: schemas.ResponsesToolTypeMCP, + ResponsesToolMCP: &schemas.ResponsesToolMCP{ + ServerLabel: mcpServer.Name, + }, + } + + // Set server URL if present + if mcpServer.URL != "" { + bifrostTool.ResponsesToolMCP.ServerURL = schemas.Ptr(mcpServer.URL) + } + + // Set authorization token if present + if mcpServer.AuthorizationToken != nil { + bifrostTool.ResponsesToolMCP.Authorization = mcpServer.AuthorizationToken + } + + // Set allowed tools from tool configuration + if mcpServer.ToolConfiguration != nil && len(mcpServer.ToolConfiguration.AllowedTools) > 0 { + bifrostTool.ResponsesToolMCP.AllowedTools = &schemas.ResponsesToolMCPAllowedTools{ + ToolNames: mcpServer.ToolConfiguration.AllowedTools, + } + } + + return bifrostTool +} + +// convertBifrostMCPToolToAnthropicServer converts a Bifrost MCP tool back to an Anthropic MCP server +func convertBifrostMCPToolToAnthropicServer(tool *schemas.ResponsesTool) *AnthropicMCPServer { + if tool == nil || tool.Type != schemas.ResponsesToolTypeMCP || tool.ResponsesToolMCP == nil { + return nil + } + + mcpServer := &AnthropicMCPServer{ + Type: "url", + Name: tool.ResponsesToolMCP.ServerLabel, + ToolConfiguration: &AnthropicMCPToolConfig{ + Enabled: true, + }, + } + + // Set server URL if present + if tool.ResponsesToolMCP.ServerURL != nil { + mcpServer.URL = *tool.ResponsesToolMCP.ServerURL + } + + // Set allowed tools if present + if tool.ResponsesToolMCP.AllowedTools != nil && len(tool.ResponsesToolMCP.AllowedTools.ToolNames) > 0 { + mcpServer.ToolConfiguration.AllowedTools = tool.ResponsesToolMCP.AllowedTools.ToolNames + } + + // Set authorization token if present + if tool.ResponsesToolMCP.Authorization != nil { + mcpServer.AuthorizationToken = tool.ResponsesToolMCP.Authorization + } + + return mcpServer +} + +// convertResponsesToAnthropicComputerAction converts ResponsesComputerToolCallAction to Anthropic input map +func convertResponsesToAnthropicComputerAction(action *schemas.ResponsesComputerToolCallAction) map[string]any { + input := map[string]any{} + var actionStr string + + // Map action type from OpenAI to Anthropic format + switch action.Type { + case "screenshot": + actionStr = "screenshot" + + case "click": + // Map click with button variants + if action.Button != nil { + switch *action.Button { + case "right": + actionStr = "right_click" + case "wheel": + actionStr = "middle_click" + default: // "left", "back", "forward" or others + actionStr = "left_click" + } + } else { + actionStr = "left_click" + } + // Add coordinates + if action.X != nil && action.Y != nil { + input["coordinate"] = []int{*action.X, *action.Y} + } + + case "double_click": + actionStr = "double_click" + if action.X != nil && action.Y != nil { + input["coordinate"] = []int{*action.X, *action.Y} + } + + case "move": + actionStr = "mouse_move" + if action.X != nil && action.Y != nil { + input["coordinate"] = []int{*action.X, *action.Y} + } + + case "type": + actionStr = "type" + if action.Text != nil { + input["text"] = *action.Text + } + + case "keypress": + actionStr = "key" + if len(action.Keys) > 0 { + // Convert array of keys to "key1+key2+..." format + text := "" + for i, key := range action.Keys { + if i > 0 { + text += "+" + } + text += key + } + input["text"] = text + } + + case "scroll": + actionStr = "scroll" + if action.X != nil && action.Y != nil { + input["coordinate"] = []int{*action.X, *action.Y} + } + + // Handle scroll direction - Anthropic supports one direction at a time + // If both ScrollX and ScrollY are present, use the one with larger absolute value + scrollX := 0 + scrollY := 0 + if action.ScrollX != nil { + scrollX = *action.ScrollX + } + if action.ScrollY != nil { + scrollY = *action.ScrollY + } + + if math.Abs(float64(scrollY)) >= math.Abs(float64(scrollX)) && scrollY != 0 { + // Vertical scroll is dominant or only one present + if scrollY > 0 { + input["scroll_direction"] = "down" + input["scroll_amount"] = scrollY / 100 + } else { + input["scroll_direction"] = "up" + input["scroll_amount"] = (-scrollY) / 100 + } + } else if scrollX != 0 { + // Horizontal scroll is dominant or only one present + if scrollX > 0 { + input["scroll_direction"] = "right" + input["scroll_amount"] = scrollX / 100 + } else { + input["scroll_direction"] = "left" + input["scroll_amount"] = (-scrollX) / 100 + } + } + + case "drag": + actionStr = "left_click_drag" + if len(action.Path) >= 2 { + // Map first and last points as start and end coordinates + input["start_coordinate"] = []int{action.Path[0].X, action.Path[0].Y} + input["end_coordinate"] = []int{action.Path[len(action.Path)-1].X, action.Path[len(action.Path)-1].Y} + } + + case "wait": + actionStr = "wait" + input["duration"] = 2 + + default: + // Pass through any unknown action types + actionStr = action.Type + } + + input["action"] = actionStr + + return input +} + +// convertAnthropicToResponsesComputerAction converts Anthropic input map to ResponsesComputerToolCallAction +func convertAnthropicToResponsesComputerAction(inputMap map[string]interface{}) *schemas.ResponsesComputerToolCallAction { + action := &schemas.ResponsesComputerToolCallAction{} + + // Extract action type + actionStr, ok := inputMap["action"].(string) + if !ok { + return action + } + + // Map action type from Anthropic to OpenAI format + switch actionStr { + case "screenshot": + action.Type = "screenshot" + + case "left_click": + action.Type = "click" + action.Button = schemas.Ptr("left") + + case "right_click": + action.Type = "click" + action.Button = schemas.Ptr("right") + + case "middle_click": + action.Type = "click" + action.Button = schemas.Ptr("wheel") + + case "double_click": + action.Type = "double_click" + + case "mouse_move": + action.Type = "move" + + case "type": + action.Type = "type" + if text, ok := inputMap["text"].(string); ok { + action.Text = schemas.Ptr(text) + } + + case "key": + action.Type = "keypress" + if text, ok := inputMap["text"].(string); ok { + // Convert "key1+key2+..." format to array of keys + keys := strings.Split(text, "+") + action.Keys = keys + } + + case "scroll": + action.Type = "scroll" + // Convert scroll_direction and scroll_amount to pixel values + if direction, ok := inputMap["scroll_direction"].(string); ok { + amount := 100 // Default scroll amount in pixels + if scrollAmount, ok := inputMap["scroll_amount"].(float64); ok { + amount = int(scrollAmount) * 100 // Convert scroll units to pixels + } + switch direction { + case "down": + action.ScrollY = schemas.Ptr(amount) + action.ScrollX = schemas.Ptr(0) + case "up": + action.ScrollY = schemas.Ptr(-amount) + action.ScrollX = schemas.Ptr(0) + case "right": + action.ScrollX = schemas.Ptr(amount) + action.ScrollY = schemas.Ptr(0) + case "left": + action.ScrollX = schemas.Ptr(-amount) + action.ScrollY = schemas.Ptr(0) + } + } + + case "left_click_drag": + action.Type = "drag" + // Extract start and end coordinates + if startCoord, ok := inputMap["start_coordinate"].([]interface{}); ok && len(startCoord) == 2 { + if endCoord, ok := inputMap["end_coordinate"].([]interface{}); ok && len(endCoord) == 2 { + // JSON unmarshaling produces float64 for numbers, so convert them + startX, startXOk := startCoord[0].(float64) + startY, startYOk := startCoord[1].(float64) + endX, endXOk := endCoord[0].(float64) + endY, endYOk := endCoord[1].(float64) + if startXOk && startYOk && endXOk && endYOk { + action.Path = []schemas.ResponsesComputerToolCallActionPath{ + {X: int(startX), Y: int(startY)}, + {X: int(endX), Y: int(endY)}, + } + } + } + } + + case "wait": + action.Type = "wait" + + default: + // Pass through any unknown action types + action.Type = actionStr + } + + // Extract coordinates for all actions that use them (click, double_click, move, scroll, etc.) + if coordinate, ok := inputMap["coordinate"].([]interface{}); ok && len(coordinate) == 2 { + // JSON unmarshaling produces float64 for numbers, so convert them + if x, xOk := coordinate[0].(float64); xOk { + if y, yOk := coordinate[1].(float64); yOk { + action.X = schemas.Ptr(int(x)) + action.Y = schemas.Ptr(int(y)) + } + } + } + + return action +} diff --git a/core/providers/anthropic/text.go b/core/providers/anthropic/text.go new file mode 100644 index 000000000..212eb1f90 --- /dev/null +++ b/core/providers/anthropic/text.go @@ -0,0 +1,137 @@ +package anthropic + +import ( + "fmt" + "strings" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ToAnthropicTextCompletionRequest converts a Bifrost text completion request to Anthropic format +func ToAnthropicTextCompletionRequest(bifrostReq *schemas.BifrostTextCompletionRequest) *AnthropicTextRequest { + if bifrostReq == nil { + return nil + } + + prompt := "" + if bifrostReq.Input.PromptStr != nil { + prompt = *bifrostReq.Input.PromptStr + } else if len(bifrostReq.Input.PromptArray) > 0 { + prompt = strings.Join(bifrostReq.Input.PromptArray, "\n\n") + } + + anthropicReq := &AnthropicTextRequest{ + Model: bifrostReq.Model, + Prompt: fmt.Sprintf("\n\nHuman: %s\n\nAssistant:", prompt), + MaxTokensToSample: AnthropicDefaultMaxTokens, // Default value + } + + // Convert parameters + if bifrostReq.Params != nil { + if bifrostReq.Params.MaxTokens != nil { + anthropicReq.MaxTokensToSample = *bifrostReq.Params.MaxTokens + } + anthropicReq.Temperature = bifrostReq.Params.Temperature + anthropicReq.TopP = bifrostReq.Params.TopP + anthropicReq.StopSequences = bifrostReq.Params.Stop + + if bifrostReq.Params.ExtraParams != nil { + if topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]); ok { + anthropicReq.TopK = topK + } + } + } + + return anthropicReq +} + +// ToBifrostTextCompletionRequest converts an Anthropic text request back to Bifrost format +func (request *AnthropicTextRequest) ToBifrostTextCompletionRequest() *schemas.BifrostTextCompletionRequest { + if request == nil { + return nil + } + + provider, model := schemas.ParseModelString(request.Model, schemas.Anthropic) + + bifrostReq := &schemas.BifrostTextCompletionRequest{ + Provider: provider, + Model: model, + Input: &schemas.TextCompletionInput{ + PromptStr: &request.Prompt, + }, + Params: &schemas.TextCompletionParameters{ + MaxTokens: &request.MaxTokensToSample, + Temperature: request.Temperature, + TopP: request.TopP, + Stop: request.StopSequences, + }, + } + + // Add extra params if present + if request.TopK != nil { + bifrostReq.Params.ExtraParams = map[string]interface{}{ + "top_k": *request.TopK, + } + } + + return bifrostReq +} + +// ToBifrostTextCompletionResponse converts an Anthropic text response back to Bifrost format +func (response *AnthropicTextResponse) ToBifrostTextCompletionResponse() *schemas.BifrostTextCompletionResponse { + if response == nil { + return nil + } + return &schemas.BifrostTextCompletionResponse{ + ID: response.ID, + Object: "text_completion", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{ + Text: &response.Completion, + }, + }, + }, + Usage: &schemas.BifrostLLMUsage{ + PromptTokens: response.Usage.InputTokens, + CompletionTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, + }, + Model: response.Model, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.TextCompletionRequest, + Provider: schemas.Anthropic, + }, + } +} + +// ToAnthropicTextCompletionResponse converts a BifrostResponse back to Anthropic text completion format +func ToAnthropicTextCompletionResponse(bifrostResp *schemas.BifrostTextCompletionResponse) *AnthropicTextResponse { + if bifrostResp == nil { + return nil + } + + anthropicResp := &AnthropicTextResponse{ + ID: bifrostResp.ID, + Type: "completion", + Model: bifrostResp.Model, + } + + // Convert choices to completion text + if len(bifrostResp.Choices) > 0 { + choice := bifrostResp.Choices[0] // Anthropic text API typically returns one choice + + if choice.TextCompletionResponseChoice != nil && choice.TextCompletionResponseChoice.Text != nil { + anthropicResp.Completion = *choice.TextCompletionResponseChoice.Text + } + } + + // Convert usage information + if bifrostResp.Usage != nil { + anthropicResp.Usage.InputTokens = bifrostResp.Usage.PromptTokens + anthropicResp.Usage.OutputTokens = bifrostResp.Usage.CompletionTokens + } + + return anthropicResp +} diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go new file mode 100644 index 000000000..ab0ba6d79 --- /dev/null +++ b/core/providers/anthropic/types.go @@ -0,0 +1,386 @@ +package anthropic + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// Since Anthropic always needs to have a max_tokens parameter, we set a default value if not provided. +const ( + AnthropicDefaultMaxTokens = 4096 +) + +// ==================== REQUEST TYPES ==================== + +// AnthropicTextRequest represents an Anthropic text completion request +type AnthropicTextRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + MaxTokensToSample int `json:"max_tokens_to_sample"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Stream *bool `json:"stream,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` +} + +// IsStreamingRequested implements the StreamingRequest interface +func (r *AnthropicTextRequest) IsStreamingRequested() bool { + return r.Stream != nil && *r.Stream +} + +// AnthropicMessageRequest represents an Anthropic messages API request +type AnthropicMessageRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []AnthropicMessage `json:"messages"` + Metadata *AnthropicMetaData `json:"metadata,omitempty"` + System *AnthropicContent `json:"system,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream *bool `json:"stream,omitempty"` + Tools []AnthropicTool `json:"tools,omitempty"` + ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"` + MCPServers []AnthropicMCPServer `json:"mcp_servers,omitempty"` // This feature requires the beta header: "anthropic-beta": "mcp-client-2025-04-04" + Thinking *AnthropicThinking `json:"thinking,omitempty"` +} + +type AnthropicMetaData struct { + UserID *string `json:"user_id"` +} + +type AnthropicThinking struct { + Type string `json:"type"` // "enabled" or "disabled" + BudgetTokens *int `json:"budget_tokens,omitempty"` +} + +// IsStreamingRequested implements the StreamingRequest interface +func (mr *AnthropicMessageRequest) IsStreamingRequested() bool { + return mr.Stream != nil && *mr.Stream +} + +type AnthropicMessageRole string + +const ( + AnthropicMessageRoleUser AnthropicMessageRole = "user" + AnthropicMessageRoleAssistant AnthropicMessageRole = "assistant" +) + +// AnthropicMessage represents a message in Anthropic format +type AnthropicMessage struct { + Role AnthropicMessageRole `json:"role"` // "user", "assistant" + Content AnthropicContent `json:"content"` // Array of content blocks +} + +// AnthropicContent represents content that can be either string or array of blocks +type AnthropicContent struct { + ContentStr *string + ContentBlocks []AnthropicContentBlock +} + +// MarshalJSON implements custom JSON marshalling for AnthropicContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (mc AnthropicContent) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if mc.ContentStr != nil && mc.ContentBlocks != nil { + return nil, fmt.Errorf("both ContentStr and ContentBlocks are set; only one should be non-nil") + } + + if mc.ContentStr != nil { + return json.Marshal(*mc.ContentStr) + } + if mc.ContentBlocks != nil { + return json.Marshal(mc.ContentBlocks) + } + // If both are nil, return null + return json.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for AnthropicContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +func (mc *AnthropicContent) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var stringContent string + if err := json.Unmarshal(data, &stringContent); err == nil { + mc.ContentStr = &stringContent + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var arrayContent []AnthropicContentBlock + if err := json.Unmarshal(data, &arrayContent); err == nil { + mc.ContentBlocks = arrayContent + return nil + } + + return fmt.Errorf("content field is neither a string nor an array of ContentBlock") +} + +type AnthropicContentBlockType string + +const ( + AnthropicContentBlockTypeText AnthropicContentBlockType = "text" + AnthropicContentBlockTypeImage AnthropicContentBlockType = "image" + AnthropicContentBlockTypeToolUse AnthropicContentBlockType = "tool_use" + AnthropicContentBlockTypeServerToolUse AnthropicContentBlockType = "server_tool_use" + AnthropicContentBlockTypeToolResult AnthropicContentBlockType = "tool_result" + AnthropicContentBlockTypeWebSearchResult AnthropicContentBlockType = "web_search_result" + AnthropicContentBlockTypeMCPToolUse AnthropicContentBlockType = "mcp_tool_use" + AnthropicContentBlockTypeMCPToolResult AnthropicContentBlockType = "mcp_tool_result" + AnthropicContentBlockTypeThinking AnthropicContentBlockType = "thinking" +) + +// AnthropicContentBlock represents content in Anthropic message format +type AnthropicContentBlock struct { + Type AnthropicContentBlockType `json:"type"` // "text", "image", "tool_use", "tool_result", "thinking" + Text *string `json:"text,omitempty"` // For text content + Thinking *string `json:"thinking,omitempty"` // For thinking content + Signature *string `json:"signature,omitempty"` // For signature content + ToolUseID *string `json:"tool_use_id,omitempty"` // For tool_result content + ID *string `json:"id,omitempty"` // For tool_use content + Name *string `json:"name,omitempty"` // For tool_use content + Input any `json:"input,omitempty"` // For tool_use content + ServerName *string `json:"server_name,omitempty"` // For mcp_tool_use content + Content *AnthropicContent `json:"content,omitempty"` // For tool_result content + Source *AnthropicImageSource `json:"source,omitempty"` // For image content +} + +// AnthropicImageSource represents image source in Anthropic format +type AnthropicImageSource struct { + Type string `json:"type"` // "base64" or "url" + MediaType *string `json:"media_type,omitempty"` // "image/jpeg", "image/png", etc. + Data *string `json:"data,omitempty"` // Base64-encoded image data + URL *string `json:"url,omitempty"` // URL of the image +} + +// AnthropicImageContent represents image content in Anthropic format +type AnthropicImageContent struct { + Type schemas.ImageContentType `json:"type"` + URL string `json:"url"` + MediaType string `json:"media_type,omitempty"` +} + +type AnthropicToolType string + +const ( + AnthropicToolTypeCustom AnthropicToolType = "custom" + AnthropicToolTypeBash20250124 AnthropicToolType = "bash_20250124" + AnthropicToolTypeComputer20250124 AnthropicToolType = "computer_20250124" + AnthropicToolTypeCodeExecution AnthropicToolType = "code_execution_20250825" + AnthropicToolTypeTextEditor20250124 AnthropicToolType = "text_editor_20250124" + AnthropicToolTypeTextEditor20250429 AnthropicToolType = "text_editor_20250429" + AnthropicToolTypeTextEditor20250728 AnthropicToolType = "text_editor_20250728" + AnthropicToolTypeWebSearch20250305 AnthropicToolType = "web_search_20250305" +) + +type AnthropicToolName string + +const ( + AnthropicToolNameComputer AnthropicToolName = "computer" + AnthropicToolNameWebSearch AnthropicToolName = "web_search" + AnthropicToolNameBash AnthropicToolName = "bash" + AnthropicToolNameTextEditor AnthropicToolName = "str_replace_based_edit_tool" +) + +type AnthropicToolComputerUse struct { + DisplayWidthPx *int `json:"display_width_px,omitempty"` + DisplayHeightPx *int `json:"display_height_px,omitempty"` + DisplayNumber *int `json:"display_number,omitempty"` +} + +type AnthropicToolWebSearchUserLocation struct { + Type *string `json:"type,omitempty"` // "approximate" + City *string `json:"city,omitempty"` + Country *string `json:"country,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +type AnthropicToolWebSearch struct { + MaxUses *int `json:"max_uses,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` + BlockedDomains []string `json:"blocked_domains,omitempty"` + UserLocation *AnthropicToolWebSearchUserLocation `json:"user_location,omitempty"` +} + +// AnthropicTool represents a tool in Anthropic format +type AnthropicTool struct { + Name string `json:"name"` + Type *AnthropicToolType `json:"type,omitempty"` + Description *string `json:"description,omitempty"` + InputSchema *schemas.ToolFunctionParameters `json:"input_schema,omitempty"` + + *AnthropicToolComputerUse + *AnthropicToolWebSearch +} + +// AnthropicToolChoice represents tool choice in Anthropic format +type AnthropicToolChoice struct { + Type string `json:"type"` // "auto", "any", "tool" + Name string `json:"name,omitempty"` // For type "tool" + DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` // Whether to disable parallel tool use +} + +// AnthropicToolContent represents content within tool result blocks +type AnthropicToolContent struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + EncryptedContent string `json:"encrypted_content,omitempty"` + PageAge *string `json:"page_age,omitempty"` +} + +type AnthropicMCPServer struct { + Type string `json:"type"` + URL string `json:"url"` + Name string `json:"name"` + AuthorizationToken *string `json:"authorization_token,omitempty"` + ToolConfiguration *AnthropicMCPToolConfig `json:"tool_configuration,omitempty"` +} + +type AnthropicMCPToolConfig struct { + Enabled bool `json:"enabled"` + AllowedTools []string `json:"allowed_tools,omitempty"` +} + +// ==================== RESPONSE TYPES ==================== + +type AnthropicStopReason string + +const ( + AnthropicStopReasonEndTurn AnthropicStopReason = "end_turn" + AnthropicStopReasonMaxTokens AnthropicStopReason = "max_tokens" + AnthropicStopReasonStopSequence AnthropicStopReason = "stop_sequence" + AnthropicStopReasonToolUse AnthropicStopReason = "tool_use" + AnthropicStopReasonPauseTurn AnthropicStopReason = "pause_turn" + AnthropicStopReasonRefusal AnthropicStopReason = "refusal" + AnthropicStopReasonModelContextWindowExceeded AnthropicStopReason = "model_context_window_exceeded" +) + +// AnthropicMessageResponse represents an Anthropic messages API response +type AnthropicMessageResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []AnthropicContentBlock `json:"content"` + Model string `json:"model"` + StopReason AnthropicStopReason `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage *AnthropicUsage `json:"usage,omitempty"` +} + +// AnthropicTextResponse represents the response structure from Anthropic's text completion API +type AnthropicTextResponse struct { + ID string `json:"id"` // Unique identifier for the completion + Type string `json:"type"` // Type of completion + Completion string `json:"completion"` // Generated completion text + Model string `json:"model"` // Model used for the completion + Usage struct { + InputTokens int `json:"input_tokens"` // Number of input tokens used + OutputTokens int `json:"output_tokens"` // Number of output tokens generated + } `json:"usage"` // Token usage statistics +} + +// AnthropicUsage represents usage information in Anthropic format +type AnthropicUsage struct { + InputTokens int `json:"input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` + OutputTokens int `json:"output_tokens"` +} + +// ==================== STREAMING TYPES ==================== + +type AnthropicStreamEventType string + +const ( + AnthropicStreamEventTypeMessageStart AnthropicStreamEventType = "message_start" + AnthropicStreamEventTypeMessageStop AnthropicStreamEventType = "message_stop" + AnthropicStreamEventTypeContentBlockStart AnthropicStreamEventType = "content_block_start" + AnthropicStreamEventTypeContentBlockDelta AnthropicStreamEventType = "content_block_delta" + AnthropicStreamEventTypeContentBlockStop AnthropicStreamEventType = "content_block_stop" + AnthropicStreamEventTypeMessageDelta AnthropicStreamEventType = "message_delta" + AnthropicStreamEventTypePing AnthropicStreamEventType = "ping" + AnthropicStreamEventTypeError AnthropicStreamEventType = "error" +) + +// AnthropicStreamEvent represents a single event in the Anthropic streaming response +type AnthropicStreamEvent struct { + ID *string `json:"id,omitempty"` + Type AnthropicStreamEventType `json:"type"` + Message *AnthropicMessageResponse `json:"message,omitempty"` + Index *int `json:"index,omitempty"` + ContentBlock *AnthropicContentBlock `json:"content_block,omitempty"` + Delta *AnthropicStreamDelta `json:"delta,omitempty"` + Usage *AnthropicUsage `json:"usage,omitempty"` + Error *AnthropicStreamError `json:"error,omitempty"` +} + +type AnthropicStreamDeltaType string + +const ( + AnthropicStreamDeltaTypeText AnthropicStreamDeltaType = "text_delta" + AnthropicStreamDeltaTypeInputJSON AnthropicStreamDeltaType = "input_json_delta" + AnthropicStreamDeltaTypeThinking AnthropicStreamDeltaType = "thinking_delta" + AnthropicStreamDeltaTypeSignature AnthropicStreamDeltaType = "signature_delta" +) + +// AnthropicStreamDelta represents incremental updates to content blocks during streaming (legacy) +type AnthropicStreamDelta struct { + Type AnthropicStreamDeltaType `json:"type"` + Text *string `json:"text,omitempty"` + PartialJSON *string `json:"partial_json,omitempty"` + Thinking *string `json:"thinking,omitempty"` + Signature *string `json:"signature,omitempty"` + StopReason *AnthropicStopReason `json:"stop_reason,omitempty"` // only not present in "message_start" events + StopSequence *string `json:"stop_sequence,omitempty"` +} + +// ==================== MODEL TYPES ==================== + +type AnthropicModel struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + CreatedAt time.Time `json:"created_at"` + Type string `json:"type"` +} + +type AnthropicListModelsResponse struct { + Data []AnthropicModel `json:"data"` + FirstID *string `json:"first_id,omitempty"` + HasMore bool `json:"has_more"` + LastID *string `json:"last_id,omitempty"` +} + +// ==================== ERROR TYPES ==================== + +// AnthropicMessageError represents an Anthropic messages API error response +type AnthropicMessageError struct { + Type string `json:"type"` // always "error" + Error AnthropicMessageErrorStruct `json:"error"` // Error details +} + +// AnthropicMessageErrorStruct represents the error structure of an Anthropic messages API error response +type AnthropicMessageErrorStruct struct { + Type string `json:"type"` // Error type + Message string `json:"message"` // Error message +} + +// AnthropicError represents the error response structure from Anthropic's API (legacy) +type AnthropicError struct { + Type string `json:"type"` // always "error" + Error struct { + Type string `json:"type"` // Error type + Message string `json:"message"` // Error message + } `json:"error"` // Error details +} + +// AnthropicStreamError represents error events in the streaming response +type AnthropicStreamError struct { + Type string `json:"type"` + Message string `json:"message"` +} diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go new file mode 100644 index 000000000..1f10a365a --- /dev/null +++ b/core/providers/anthropic/utils.go @@ -0,0 +1,142 @@ +package anthropic + +import ( + "encoding/json" + + "github.com/maximhq/bifrost/core/schemas" +) + +var ( + // Maps provider-specific finish reasons to Bifrost format + anthropicFinishReasonToBifrost = map[AnthropicStopReason]string{ + AnthropicStopReasonEndTurn: "stop", + AnthropicStopReasonMaxTokens: "length", + AnthropicStopReasonStopSequence: "stop", + AnthropicStopReasonToolUse: "tool_calls", + } + + // Maps Bifrost finish reasons to provider-specific format + bifrostToAnthropicFinishReason = map[string]AnthropicStopReason{ + "stop": AnthropicStopReasonEndTurn, // canonical default + "length": AnthropicStopReasonMaxTokens, + "tool_calls": AnthropicStopReasonToolUse, + } +) + +// ConvertAnthropicFinishReasonToBifrost converts provider finish reasons to Bifrost format +func ConvertAnthropicFinishReasonToBifrost(providerReason AnthropicStopReason) string { + if bifrostReason, ok := anthropicFinishReasonToBifrost[providerReason]; ok { + return bifrostReason + } + return string(providerReason) +} + +// ConvertBifrostFinishReasonToAnthropic converts Bifrost finish reasons to provider format +func ConvertBifrostFinishReasonToAnthropic(bifrostReason string) AnthropicStopReason { + if providerReason, ok := bifrostToAnthropicFinishReason[bifrostReason]; ok { + return providerReason + } + return AnthropicStopReason(bifrostReason) +} + +// ConvertToAnthropicImageBlock converts a Bifrost image block to Anthropic format +// Uses the same pattern as the original buildAnthropicImageSourceMap function +func ConvertToAnthropicImageBlock(block schemas.ChatContentBlock) AnthropicContentBlock { + imageBlock := AnthropicContentBlock{ + Type: "image", + Source: &AnthropicImageSource{}, + } + + if block.ImageURLStruct == nil { + return imageBlock + } + + // Use the centralized utility functions from schemas package + sanitizedURL, err := schemas.SanitizeImageURL(block.ImageURLStruct.URL) + if err != nil { + // Best-effort: treat as a regular URL without sanitization + imageBlock.Source.Type = "url" + imageBlock.Source.URL = &block.ImageURLStruct.URL + return imageBlock + } + urlTypeInfo := schemas.ExtractURLTypeInfo(sanitizedURL) + + formattedImgContent := &AnthropicImageContent{ + Type: urlTypeInfo.Type, + } + + if urlTypeInfo.MediaType != nil { + formattedImgContent.MediaType = *urlTypeInfo.MediaType + } + + if urlTypeInfo.DataURLWithoutPrefix != nil { + formattedImgContent.URL = *urlTypeInfo.DataURLWithoutPrefix + } else { + formattedImgContent.URL = sanitizedURL + } + + // Convert to Anthropic source format + if formattedImgContent.Type == schemas.ImageContentTypeURL { + imageBlock.Source.Type = "url" + imageBlock.Source.URL = &formattedImgContent.URL + } else { + if formattedImgContent.MediaType != "" { + imageBlock.Source.MediaType = &formattedImgContent.MediaType + } + imageBlock.Source.Type = "base64" + // Use the base64 data without the data URL prefix + if urlTypeInfo.DataURLWithoutPrefix != nil { + imageBlock.Source.Data = urlTypeInfo.DataURLWithoutPrefix + } else { + imageBlock.Source.Data = &formattedImgContent.URL + } + } + + return imageBlock +} + +func (block AnthropicContentBlock) ToBifrostContentImageBlock() schemas.ChatContentBlock { + return schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: getImageURLFromBlock(block), + }, + } +} + +func getImageURLFromBlock(block AnthropicContentBlock) string { + if block.Source == nil { + return "" + } + + // Handle base64 data - convert to data URL + if block.Source.Data != nil { + mime := "image/png" + if block.Source.MediaType != nil && *block.Source.MediaType != "" { + mime = *block.Source.MediaType + } + return "data:" + mime + ";base64," + *block.Source.Data + } + + // Handle regular URLs + if block.Source.URL != nil { + return *block.Source.URL + } + + return "" +} + +// Helper function to parse JSON input arguments back to interface{} +func parseJSONInput(jsonStr string) interface{} { + if jsonStr == "" || jsonStr == "{}" { + return map[string]interface{}{} + } + + var result interface{} + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + // If parsing fails, return as string + return jsonStr + } + + return result +} diff --git a/core/providers/azure.go b/core/providers/azure.go deleted file mode 100644 index 13e8e2ee1..000000000 --- a/core/providers/azure.go +++ /dev/null @@ -1,342 +0,0 @@ -// Package providers implements various LLM providers and their utility functions. -// This file contains the Azure OpenAI provider implementation. -package providers - -import ( - "fmt" - "sync" - "time" - - "github.com/goccy/go-json" - - schemas "github.com/maximhq/bifrost/core/schemas" - "github.com/valyala/fasthttp" -) - -// AzureTextResponse represents the response structure from Azure's text completion API. -// It includes completion choices, model information, and usage statistics. -type AzureTextResponse struct { - ID string `json:"id"` // Unique identifier for the completion - Object string `json:"object"` // Type of completion (always "text.completion") - Choices []struct { - FinishReason *string `json:"finish_reason,omitempty"` // Reason for completion termination - Index int `json:"index"` // Index of the choice - Text string `json:"text"` // Generated text - LogProbs schemas.TextCompletionLogProb `json:"logprobs"` // Log probabilities - } `json:"choices"` - Model string `json:"model"` // Model used for the completion - Created int `json:"created"` // Unix timestamp of completion creation - SystemFingerprint *string `json:"system_fingerprint"` // System fingerprint for the request - Usage schemas.LLMUsage `json:"usage"` // Token usage statistics -} - -// AzureChatResponse represents the response structure from Azure's chat completion API. -// It includes completion choices, model information, and usage statistics. -type AzureChatResponse struct { - ID string `json:"id"` // Unique identifier for the completion - Object string `json:"object"` // Type of completion (always "chat.completion") - Choices []schemas.BifrostResponseChoice `json:"choices"` // Array of completion choices - Model string `json:"model"` // Model used for the completion - Created int `json:"created"` // Unix timestamp of completion creation - SystemFingerprint *string `json:"system_fingerprint"` // System fingerprint for the request - Usage schemas.LLMUsage `json:"usage"` // Token usage statistics -} - -// AzureError represents the error response structure from Azure's API. -// It includes error code and message information. -type AzureError struct { - Error struct { - Code string `json:"code"` // Error code - Message string `json:"message"` // Error message - } `json:"error"` -} - -// azureTextCompletionResponsePool provides a pool for Azure text completion response objects. -var azureTextCompletionResponsePool = sync.Pool{ - New: func() interface{} { - return &AzureTextResponse{} - }, -} - -// azureChatResponsePool provides a pool for Azure chat response objects. -var azureChatResponsePool = sync.Pool{ - New: func() interface{} { - return &AzureChatResponse{} - }, -} - -// acquireAzureChatResponse gets an Azure chat response from the pool and resets it. -func acquireAzureChatResponse() *AzureChatResponse { - resp := azureChatResponsePool.Get().(*AzureChatResponse) - *resp = AzureChatResponse{} // Reset the struct - return resp -} - -// releaseAzureChatResponse returns an Azure chat response to the pool. -func releaseAzureChatResponse(resp *AzureChatResponse) { - if resp != nil { - azureChatResponsePool.Put(resp) - } -} - -// acquireAzureTextResponse gets an Azure text completion response from the pool and resets it. -func acquireAzureTextResponse() *AzureTextResponse { - resp := azureTextCompletionResponsePool.Get().(*AzureTextResponse) - *resp = AzureTextResponse{} // Reset the struct - return resp -} - -// releaseAzureTextResponse returns an Azure text completion response to the pool. -func releaseAzureTextResponse(resp *AzureTextResponse) { - if resp != nil { - azureTextCompletionResponsePool.Put(resp) - } -} - -// AzureProvider implements the Provider interface for Azure's OpenAI API. -type AzureProvider struct { - logger schemas.Logger // Logger for provider operations - client *fasthttp.Client // HTTP client for API requests - meta schemas.MetaConfig // Azure-specific configuration -} - -// NewAzureProvider creates a new Azure provider instance. -// It initializes the HTTP client with the provided configuration and sets up response pools. -// The client is configured with timeouts, concurrency limits, and optional proxy settings. -func NewAzureProvider(config *schemas.ProviderConfig, logger schemas.Logger) *AzureProvider { - setConfigDefaults(config) - - client := &fasthttp.Client{ - ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - MaxConnsPerHost: config.ConcurrencyAndBufferSize.BufferSize, - } - - // Pre-warm response pools - for range config.ConcurrencyAndBufferSize.Concurrency { - azureChatResponsePool.Put(&AzureChatResponse{}) - azureTextCompletionResponsePool.Put(&AzureTextResponse{}) - bifrostResponsePool.Put(&schemas.BifrostResponse{}) - } - - // Configure proxy if provided - client = configureProxy(client, config.ProxyConfig, logger) - - return &AzureProvider{ - logger: logger, - client: client, - meta: config.MetaConfig, - } -} - -// GetProviderKey returns the provider identifier for Azure. -func (provider *AzureProvider) GetProviderKey() schemas.ModelProvider { - return schemas.Azure -} - -// completeRequest sends a request to Azure's API and handles the response. -// It constructs the API URL, sets up authentication, and processes the response. -// Returns the response body or an error if the request fails. -func (provider *AzureProvider) completeRequest(requestBody map[string]interface{}, path string, key string, model string) ([]byte, *schemas.BifrostError) { - // Marshal the request body - jsonData, err := json.Marshal(requestBody) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderJSONMarshaling, - Error: err, - }, - } - } - - if provider.meta.GetEndpoint() == nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "endpoint not set", - }, - } - } - - url := *provider.meta.GetEndpoint() - - if provider.meta.GetDeployments() != nil { - deployment := provider.meta.GetDeployments()[model] - if deployment == "" { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: fmt.Sprintf("deployment if not found for model %s", model), - }, - } - } - - apiVersion := provider.meta.GetAPIVersion() - if apiVersion == nil { - apiVersion = StrPtr("2024-02-01") - } - - url = fmt.Sprintf("%s/openai/deployments/%s/%s?api-version=%s", url, deployment, path, *apiVersion) - } else { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "deployments not set", - }, - } - } - - // Create the request with the JSON body - req := fasthttp.AcquireRequest() - resp := fasthttp.AcquireResponse() - defer fasthttp.ReleaseRequest(req) - defer fasthttp.ReleaseResponse(resp) - - req.SetRequestURI(url) - req.Header.SetMethod("POST") - req.Header.SetContentType("application/json") - req.Header.Set("api-key", key) - req.SetBody(jsonData) - - // Send the request - if err := provider.client.Do(req, resp); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderRequest, - Error: err, - }, - } - } - - // Handle error response - if resp.StatusCode() != fasthttp.StatusOK { - var errorResp AzureError - - bifrostErr := handleProviderAPIError(resp, &errorResp) - bifrostErr.Error.Type = &errorResp.Error.Code - bifrostErr.Error.Message = errorResp.Error.Message - - return nil, bifrostErr - } - - // Read the response body - body := resp.Body() - - return body, nil -} - -// TextCompletion performs a text completion request to Azure's API. -// It formats the request, sends it to Azure, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *AzureProvider) TextCompletion(model, key, text string, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - preparedParams := prepareParams(params) - - // Merge additional parameters - requestBody := mergeConfig(map[string]interface{}{ - "model": model, - "prompt": text, - }, preparedParams) - - responseBody, err := provider.completeRequest(requestBody, "completions", key, model) - if err != nil { - return nil, err - } - - // Create response object from pool - response := acquireAzureTextResponse() - defer releaseAzureTextResponse(response) - - // Create Bifrost response from pool - bifrostResponse := acquireBifrostResponse() - defer releaseBifrostResponse(bifrostResponse) - - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - choices := []schemas.BifrostResponseChoice{} - - // Create the completion result - if len(response.Choices) > 0 { - choices = append(choices, schemas.BifrostResponseChoice{ - Index: 0, - Message: schemas.BifrostResponseChoiceMessage{ - Role: schemas.RoleAssistant, - Content: &response.Choices[0].Text, - }, - FinishReason: response.Choices[0].FinishReason, - LogProbs: &schemas.LogProbs{ - Text: response.Choices[0].LogProbs, - }, - }) - } - - bifrostResponse.ID = response.ID - bifrostResponse.Choices = choices - bifrostResponse.Model = response.Model - bifrostResponse.Created = response.Created - bifrostResponse.SystemFingerprint = response.SystemFingerprint - bifrostResponse.Usage = response.Usage - bifrostResponse.ExtraFields = schemas.BifrostResponseExtraFields{ - Provider: schemas.Azure, - RawResponse: rawResponse, - } - - return bifrostResponse, nil -} - -// ChatCompletion performs a chat completion request to Azure's API. -// It formats the request, sends it to Azure, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *AzureProvider) ChatCompletion(model, key string, messages []schemas.Message, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - preparedParams := prepareParams(params) - - // Format messages for Azure API - var formattedMessages []map[string]interface{} - for _, msg := range messages { - formattedMessages = append(formattedMessages, map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - }) - } - - // Merge additional parameters - requestBody := mergeConfig(map[string]interface{}{ - "model": model, - "messages": formattedMessages, - }, preparedParams) - - responseBody, err := provider.completeRequest(requestBody, "chat/completions", key, model) - if err != nil { - return nil, err - } - - // Create response object from pool - response := acquireAzureChatResponse() - defer releaseAzureChatResponse(response) - - // Create Bifrost response from pool - bifrostResponse := acquireBifrostResponse() - defer releaseBifrostResponse(bifrostResponse) - - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - bifrostResponse.ID = response.ID - bifrostResponse.Choices = response.Choices - bifrostResponse.Model = response.Model - bifrostResponse.Created = response.Created - bifrostResponse.SystemFingerprint = response.SystemFingerprint - bifrostResponse.Usage = response.Usage - bifrostResponse.ExtraFields = schemas.BifrostResponseExtraFields{ - Provider: schemas.Azure, - RawResponse: rawResponse, - } - - return bifrostResponse, nil -} diff --git a/core/providers/azure/azure.go b/core/providers/azure/azure.go new file mode 100644 index 000000000..fc4d945aa --- /dev/null +++ b/core/providers/azure/azure.go @@ -0,0 +1,631 @@ +// Package azure implements the Azure OpenAI provider. +package azure + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + + "github.com/valyala/fasthttp" +) + +// AzureAuthorizationTokenKey is the context key for the Azure authentication token. +const AzureAuthorizationTokenKey schemas.BifrostContextKey = "azure-authorization-token" + +// AzureProvider implements the Provider interface for Azure's OpenAI API. +type AzureProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewAzureProvider creates a new Azure provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewAzureProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*AzureProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + return &AzureProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Azure. +func (provider *AzureProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Azure +} + +// completeRequest sends a request to Azure's API and handles the response. +// It constructs the API URL, sets up authentication, and processes the response. +// Returns the response body, request latency, or an error if the request fails. +func (provider *AzureProvider) completeRequest(ctx context.Context, jsonData []byte, path string, key schemas.Key, model string, requestType schemas.RequestType) ([]byte, string, time.Duration, *schemas.BifrostError) { + var deployment string + var ok bool + if deployment, ok = key.AzureKeyConfig.Deployments[model]; !ok || deployment == "" { + return nil, "", 0, providerUtils.NewConfigurationError(fmt.Sprintf("deployment not found for model %s", model), provider.GetProviderKey()) + } + + apiVersion := key.AzureKeyConfig.APIVersion + if apiVersion == nil { + apiVersion = schemas.Ptr(AzureAPIVersionDefault) + } + + url := fmt.Sprintf("%s/openai/deployments/%s/%s?api-version=%s", key.AzureKeyConfig.Endpoint, deployment, path, *apiVersion) + + // Create the request with the JSON body + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if authToken, ok := ctx.Value(AzureAuthorizationTokenKey).(string); ok { + // TODO: Shift this to key.Value like in bedrock and vertex + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + // Ensure api-key is not accidentally present (from extra headers, etc.) + req.Header.Del("api-key") + } else { + req.Header.Set("api-key", key.Value) + } + + req.SetBody(jsonData) + + // Send the request and measure latency + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, deployment, latency, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + return nil, deployment, latency, openai.ParseOpenAIError(resp, requestType, provider.GetProviderKey(), model) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, deployment, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + // Read the response body and copy it before releasing the response + // to avoid use-after-free since body references fasthttp's internal buffer + bodyCopy := append([]byte(nil), body...) + + return bodyCopy, deployment, latency, nil +} + +// listModelsForKey performs a list models request for a single key. + +// Returns the response and latency, or an error if the request fails. +func (provider *AzureProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + // Validate Azure key configuration + if key.AzureKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("azure key config not set", schemas.Azure) + } + + if key.AzureKeyConfig.Endpoint == "" { + return nil, providerUtils.NewConfigurationError("endpoint not set", schemas.Azure) + } + + // Get API version + apiVersion := key.AzureKeyConfig.APIVersion + if apiVersion == nil { + apiVersion = schemas.Ptr(AzureAPIVersionDefault) + } + + // Create the request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(key.AzureKeyConfig.Endpoint + providerUtils.GetPathFromContext(ctx, fmt.Sprintf("/openai/models?api-version=%s", *apiVersion))) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + + // Set Azure authentication - either Bearer token or api-key + if authToken, ok := ctx.Value(AzureAuthorizationTokenKey).(string); ok { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + // Ensure api-key is not accidentally present (from extra headers, etc.) + req.Header.Del("api-key") + } else { + req.Header.Set("api-key", key.Value) + } + + // Send the request and measure latency + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + return nil, openai.ParseOpenAIError(resp, schemas.ListModelsRequest, provider.GetProviderKey(), "") + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + // Read the response body and copy it before releasing the response + // to avoid use-after-free since resp.Body() references fasthttp's internal buffer + responseBody := append([]byte(nil), body...) + + // Parse Azure-specific response + azureResponse := &AzureListModelsResponse{} + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, azureResponse, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Convert to Bifrost response + response := azureResponse.ToBifrostListModelsResponse() + if response == nil { + return nil, providerUtils.NewBifrostOperationError("failed to convert Azure model list response", nil, schemas.Azure) + } + response.ExtraFields.Latency = latency.Milliseconds() + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ListModels performs a list models request to Azure's API. +// It retrieves all models accessible by the Azure OpenAI resource +// Requests are made concurrently for improved performance. +func (provider *AzureProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion performs a text completion request to Azure's API. +// It formats the request, sends it to Azure, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *AzureProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + // Use centralized OpenAI text converter (Azure is OpenAI-compatible) + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return openai.ToOpenAITextCompletionRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + responseBody, deployment, latency, err := provider.completeRequest(ctx, jsonData, "completions", key, request.Model, schemas.TextCompletionRequest) + if err != nil { + return nil, err + } + + response := &schemas.BifrostTextCompletionResponse{} + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ModelDeployment = deployment + response.ExtraFields.RequestType = schemas.TextCompletionRequest + response.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// TextCompletionStream performs a streaming text completion request to Azure's API. +// It formats the request, sends it to Azure, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *AzureProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + deployment := key.AzureKeyConfig.Deployments[request.Model] + if deployment == "" { + return nil, providerUtils.NewConfigurationError(fmt.Sprintf("deployment not found for model %s", request.Model), provider.GetProviderKey()) + } + + apiVersion := key.AzureKeyConfig.APIVersion + if apiVersion == nil { + apiVersion = schemas.Ptr(AzureAPIVersionDefault) + } + + url := fmt.Sprintf("%s/openai/deployments/%s/completions?api-version=%s", key.AzureKeyConfig.Endpoint, deployment, *apiVersion) + + // Prepare Azure-specific headers + authHeader := make(map[string]string) + + // Set Azure authentication - either Bearer token or api-key + if authToken, ok := ctx.Value(AzureAuthorizationTokenKey).(string); ok { + authHeader["Authorization"] = fmt.Sprintf("Bearer %s", authToken) + } else { + authHeader["api-key"] = key.Value + } + + customPostResponseConverter := func(response *schemas.BifrostTextCompletionResponse) *schemas.BifrostTextCompletionResponse { + response.ExtraFields.ModelDeployment = deployment + return response + } + + return openai.HandleOpenAITextCompletionStreaming( + ctx, + provider.client, + url, + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + customPostResponseConverter, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// ChatCompletion performs a chat completion request to Azure's API. +// It formats the request, sends it to Azure, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *AzureProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + // Use centralized OpenAI converter since Azure is OpenAI-compatible + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return openai.ToOpenAIChatRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + responseBody, deployment, latency, err := provider.completeRequest(ctx, jsonData, "chat/completions", key, request.Model, schemas.ChatCompletionRequest) + if err != nil { + return nil, err + } + + response := &schemas.BifrostChatResponse{} + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ModelDeployment = deployment + response.ExtraFields.Latency = latency.Milliseconds() + response.ExtraFields.RequestType = schemas.ChatCompletionRequest + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ChatCompletionStream performs a streaming chat completion request to Azure's OpenAI API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Azure-specific URL construction with deployments and supports both api-key and Bearer token authentication. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *AzureProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + deployment := key.AzureKeyConfig.Deployments[request.Model] + if deployment == "" { + return nil, providerUtils.NewConfigurationError(fmt.Sprintf("deployment not found for model %s", request.Model), provider.GetProviderKey()) + } + + apiVersion := key.AzureKeyConfig.APIVersion + if apiVersion == nil { + apiVersion = schemas.Ptr(AzureAPIVersionDefault) + } + + url := fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", key.AzureKeyConfig.Endpoint, deployment, *apiVersion) + + // Prepare Azure-specific headers + authHeader := make(map[string]string) + + // Set Azure authentication - either Bearer token or api-key + if authToken, ok := ctx.Value(AzureAuthorizationTokenKey).(string); ok { + authHeader["Authorization"] = fmt.Sprintf("Bearer %s", authToken) + } else { + authHeader["api-key"] = key.Value + } + + customPostResponseConverter := func(response *schemas.BifrostChatResponse) *schemas.BifrostChatResponse { + response.ExtraFields.ModelDeployment = deployment + return response + } + + // Use shared streaming logic from OpenAI + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + url, + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + customPostResponseConverter, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to Azure's API. +// It formats the request, sends it to Azure, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *AzureProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + deployment := key.AzureKeyConfig.Deployments[request.Model] + if deployment == "" { + return nil, providerUtils.NewConfigurationError(fmt.Sprintf("deployment not found for model %s", request.Model), provider.GetProviderKey()) + } + + // Use centralized OpenAI converter since Azure is OpenAI-compatible + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := openai.ToOpenAIResponsesRequest(request) + if reqBody != nil { + reqBody.Model = deployment + } + return reqBody, nil + }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create the request with the JSON body + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(key.AzureKeyConfig.Endpoint + providerUtils.GetPathFromContext(ctx, "/openai/v1/responses?api-version=preview")) + req.Header.SetMethod("POST") + req.Header.SetContentType("application/json") + if authToken, ok := ctx.Value(AzureAuthorizationTokenKey).(string); ok { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + // Ensure api-key is not accidentally present (from extra headers, etc.) + req.Header.Del("api-key") + } else { + req.Header.Set("api-key", key.Value) + } + + req.SetBody(jsonData) + + // Send the request and measure latency + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + return nil, openai.ParseOpenAIError(resp, schemas.ResponsesRequest, provider.GetProviderKey(), request.Model) + } + + response := &schemas.BifrostResponsesResponse{} + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.Latency = latency.Milliseconds() + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ModelDeployment = deployment + response.ExtraFields.RequestType = schemas.ResponsesRequest + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ResponsesStream performs a streaming responses request to Azure's API. +func (provider *AzureProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + deployment := key.AzureKeyConfig.Deployments[request.Model] + if deployment == "" { + return nil, providerUtils.NewConfigurationError(fmt.Sprintf("deployment not found for model %s", request.Model), provider.GetProviderKey()) + } + apiVersion := key.AzureKeyConfig.APIVersion + if apiVersion == nil { + apiVersion = schemas.Ptr(AzureAPIVersionPreview) + } + url := fmt.Sprintf("%s/openai/v1/responses?api-version=%s", key.AzureKeyConfig.Endpoint, *apiVersion) + + // Prepare Azure-specific headers + authHeader := make(map[string]string) + + // Set Azure authentication - either Bearer token or api-key + if authToken, ok := ctx.Value(AzureAuthorizationTokenKey).(string); ok { + authHeader["Authorization"] = fmt.Sprintf("Bearer %s", authToken) + } else { + authHeader["api-key"] = key.Value + } + + postRequestConverter := func(req *openai.OpenAIResponsesRequest) *openai.OpenAIResponsesRequest { + req.Model = deployment + return req + } + + postResponseConverter := func(response *schemas.BifrostResponsesStreamResponse) *schemas.BifrostResponsesStreamResponse { + response.ExtraFields.ModelDeployment = deployment + return response + } + + // Use shared streaming logic from OpenAI + return openai.HandleOpenAIResponsesStreaming( + ctx, + provider.client, + url, + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + postRequestConverter, + postResponseConverter, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Embedding generates embeddings for the given input text(s) using Azure OpenAI. +// The input can be either a single string or a slice of strings for batch embedding. +// Returns a BifrostResponse containing the embedding(s) and any error that occurred. +func (provider *AzureProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + if err := provider.validateKeyConfig(key); err != nil { + return nil, err + } + + // Use centralized converter + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return openai.ToOpenAIEmbeddingRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + responseBody, deployment, latency, err := provider.completeRequest(ctx, jsonData, "embeddings", key, request.Model, schemas.EmbeddingRequest) + if err != nil { + return nil, err + } + + response := &schemas.BifrostEmbeddingResponse{} + + // Use enhanced response handler with pre-allocated response + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.Latency = latency.Milliseconds() + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ModelDeployment = deployment + response.ExtraFields.RequestType = schemas.EmbeddingRequest + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// Speech is not supported by the Azure provider. +func (provider *AzureProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Azure provider. +func (provider *AzureProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Azure provider. +func (provider *AzureProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Azure provider. +func (provider *AzureProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} + +// validateKeyConfig validates the key configuration. +// It checks if the key config is set, the endpoint is set, and the deployments are set. +// Returns an error if any of the checks fail. +func (provider *AzureProvider) validateKeyConfig(key schemas.Key) *schemas.BifrostError { + if key.AzureKeyConfig == nil { + return providerUtils.NewConfigurationError("azure key config not set", provider.GetProviderKey()) + } + + if key.AzureKeyConfig.Endpoint == "" { + return providerUtils.NewConfigurationError("endpoint not set", provider.GetProviderKey()) + } + + if key.AzureKeyConfig.Deployments == nil { + return providerUtils.NewConfigurationError("deployments not set", provider.GetProviderKey()) + } + + return nil +} diff --git a/core/providers/azure/models.go b/core/providers/azure/models.go new file mode 100644 index 000000000..aa4eeca2f --- /dev/null +++ b/core/providers/azure/models.go @@ -0,0 +1,22 @@ +package azure + +import "github.com/maximhq/bifrost/core/schemas" + +func (response *AzureListModelsResponse) ToBifrostListModelsResponse() *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Data)), + } + + for _, model := range response.Data { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(schemas.Azure) + "/" + model.ID, + Created: schemas.Ptr(model.CreatedAt), + Name: schemas.Ptr(model.Model), + }) + } + return bifrostResponse +} diff --git a/core/providers/azure/types.go b/core/providers/azure/types.go new file mode 100644 index 000000000..1bc3971f7 --- /dev/null +++ b/core/providers/azure/types.go @@ -0,0 +1,35 @@ +package azure + +// AzureAPIVersionDefault is the default Azure OpenAI API version to use when not specified. +const AzureAPIVersionDefault = "2024-10-21" +const AzureAPIVersionPreview = "preview" + +type AzureModelCapabilities struct { + FineTune bool `json:"fine_tune"` + Inference bool `json:"inference"` + Completion bool `json:"completion"` + ChatCompletion bool `json:"chat_completion"` + Embeddings bool `json:"embeddings"` +} + +type AzureModelDeprecation struct { + FineTune int64 `json:"fine_tune,omitempty"` + Inference int64 `json:"inference,omitempty"` +} + +type AzureModel struct { + Status string `json:"status"` + Model string `json:"model,omitempty"` + FineTune string `json:"fine_tune,omitempty"` + Capabilities AzureModelCapabilities `json:"capabilities,omitempty"` + LifecycleStatus string `json:"lifecycle_status"` + Deprecation *AzureModelDeprecation `json:"deprecation,omitempty"` + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + Object string `json:"object"` +} + +type AzureListModelsResponse struct { + Object string `json:"object"` + Data []AzureModel `json:"data"` +} diff --git a/core/providers/bedrock.go b/core/providers/bedrock.go deleted file mode 100644 index 3a9b35c39..000000000 --- a/core/providers/bedrock.go +++ /dev/null @@ -1,753 +0,0 @@ -// Package providers implements various LLM providers and their utility functions. -// This file contains the AWS Bedrock provider implementation. -package providers - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/goccy/go-json" - - "github.com/aws/aws-sdk-go-v2/aws" - v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" - "github.com/aws/aws-sdk-go-v2/config" - schemas "github.com/maximhq/bifrost/core/schemas" -) - -// BedrockAnthropicTextResponse represents the response structure from Bedrock's Anthropic text completion API. -// It includes the completion text and stop reason information. -type BedrockAnthropicTextResponse struct { - Completion string `json:"completion"` // Generated completion text - StopReason string `json:"stop_reason"` // Reason for completion termination - Stop string `json:"stop"` // Stop sequence that caused completion to stop -} - -// BedrockMistralTextResponse represents the response structure from Bedrock's Mistral text completion API. -// It includes multiple output choices with their text and stop reasons. -type BedrockMistralTextResponse struct { - Outputs []struct { - Text string `json:"text"` // Generated text - StopReason string `json:"stop_reason"` // Reason for completion termination - } `json:"outputs"` // Array of output choices -} - -// BedrockChatResponse represents the response structure from Bedrock's chat completion API. -// It includes message content, metrics, and token usage statistics. -type BedrockChatResponse struct { - Metrics struct { - Latency int `json:"latencyMs"` // Response latency in milliseconds - } `json:"metrics"` // Performance metrics - Output struct { - Message struct { - Content []struct { - Text string `json:"text"` // Message content - } `json:"content"` // Array of message content - Role string `json:"role"` // Role of the message sender - } `json:"message"` // Message structure - } `json:"output"` // Output structure - StopReason string `json:"stopReason"` // Reason for completion termination - Usage struct { - InputTokens int `json:"inputTokens"` // Number of input tokens used - OutputTokens int `json:"outputTokens"` // Number of output tokens generated - TotalTokens int `json:"totalTokens"` // Total number of tokens used - } `json:"usage"` // Token usage statistics -} - -// BedrockAnthropicSystemMessage represents a system message for Anthropic models. -type BedrockAnthropicSystemMessage struct { - Text string `json:"text"` // System message text -} - -// BedrockAnthropicTextMessage represents a text message for Anthropic models. -type BedrockAnthropicTextMessage struct { - Type string `json:"type"` // Type of message - Text string `json:"text"` // Message text -} - -// BedrockMistralContent represents content for Mistral models. -type BedrockMistralContent struct { - Text string `json:"text"` // Content text -} - -// BedrockMistralChatMessage represents a chat message for Mistral models. -type BedrockMistralChatMessage struct { - Role schemas.ModelChatMessageRole `json:"role"` // Role of the message sender - Content []BedrockMistralContent `json:"content"` // Array of message content - ToolCalls *[]BedrockMistralToolCall `json:"tool_calls,omitempty"` // Optional tool calls - ToolCallID *string `json:"tool_call_id,omitempty"` // Optional tool call ID -} - -// BedrockAnthropicImageMessage represents an image message for Anthropic models. -type BedrockAnthropicImageMessage struct { - Type string `json:"type"` // Type of message - Image BedrockAnthropicImage `json:"image"` // Image data -} - -// BedrockAnthropicImage represents image data for Anthropic models. -type BedrockAnthropicImage struct { - Format string `json:"string"` // Image format - Source BedrockAnthropicImageSource `json:"source"` // Image source -} - -// BedrockAnthropicImageSource represents the source of an image for Anthropic models. -type BedrockAnthropicImageSource struct { - Bytes string `json:"bytes"` // Base64 encoded image data -} - -// BedrockMistralToolCall represents a tool call for Mistral models. -type BedrockMistralToolCall struct { - ID string `json:"id"` // Tool call ID - Function schemas.Function `json:"function"` // Function to call -} - -// BedrockAnthropicToolCall represents a tool call for Anthropic models. -type BedrockAnthropicToolCall struct { - ToolSpec BedrockAnthropicToolSpec `json:"toolSpec"` // Tool specification -} - -// BedrockAnthropicToolSpec represents a tool specification for Anthropic models. -type BedrockAnthropicToolSpec struct { - Name string `json:"name"` // Tool name - Description string `json:"description"` // Tool description - InputSchema struct { - Json interface{} `json:"json"` // Input schema in JSON format - } `json:"inputSchema"` // Input schema structure -} - -// BedrockError represents the error response structure from Bedrock's API. -type BedrockError struct { - Message string `json:"message"` // Error message -} - -// BedrockProvider implements the Provider interface for AWS Bedrock. -type BedrockProvider struct { - logger schemas.Logger // Logger for provider operations - client *http.Client // HTTP client for API requests - meta schemas.MetaConfig // AWS-specific configuration -} - -// bedrockChatResponsePool provides a pool for Bedrock response objects. -var bedrockChatResponsePool = sync.Pool{ - New: func() interface{} { - return &BedrockChatResponse{} - }, -} - -// acquireBedrockChatResponse gets a Bedrock response from the pool and resets it. -func acquireBedrockChatResponse() *BedrockChatResponse { - resp := bedrockChatResponsePool.Get().(*BedrockChatResponse) - *resp = BedrockChatResponse{} // Reset the struct - return resp -} - -// releaseBedrockChatResponse returns a Bedrock response to the pool. -func releaseBedrockChatResponse(resp *BedrockChatResponse) { - if resp != nil { - bedrockChatResponsePool.Put(resp) - } -} - -// NewBedrockProvider creates a new Bedrock provider instance. -// It initializes the HTTP client with the provided configuration and sets up response pools. -// The client is configured with timeouts and AWS-specific settings. -func NewBedrockProvider(config *schemas.ProviderConfig, logger schemas.Logger) *BedrockProvider { - setConfigDefaults(config) - - client := &http.Client{Timeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds)} - - // Pre-warm response pools - for range config.ConcurrencyAndBufferSize.Concurrency { - bedrockChatResponsePool.Put(&BedrockChatResponse{}) - bifrostResponsePool.Put(&schemas.BifrostResponse{}) - } - - return &BedrockProvider{ - logger: logger, - client: client, - meta: config.MetaConfig, - } -} - -// GetProviderKey returns the provider identifier for Bedrock. -func (provider *BedrockProvider) GetProviderKey() schemas.ModelProvider { - return schemas.Bedrock -} - -// CompleteRequest sends a request to Bedrock's API and handles the response. -// It constructs the API URL, sets up AWS authentication, and processes the response. -// Returns the response body or an error if the request fails. -func (provider *BedrockProvider) completeRequest(requestBody map[string]interface{}, path string, accessKey string) ([]byte, *schemas.BifrostError) { - if provider.meta == nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "meta config for bedrock is not provided", - }, - } - } - - region := "us-east-1" - if provider.meta.GetRegion() != nil { - region = *provider.meta.GetRegion() - } - - jsonBody, err := json.Marshal(requestBody) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderJSONMarshaling, - Error: err, - }, - } - } - - // Create the request with the JSON body - req, err := http.NewRequest("POST", fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s", region, path), bytes.NewBuffer(jsonBody)) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "error creating request", - Error: err, - }, - } - } - - if err := signAWSRequest(req, accessKey, *provider.meta.GetSecretAccessKey(), provider.meta.GetSessionToken(), region, "bedrock"); err != nil { - return nil, err - } - - // Execute the request - resp, err := provider.client.Do(req) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderRequest, - Error: err, - }, - } - } - defer resp.Body.Close() - - // Read response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "error reading request", - Error: err, - }, - } - } - - if resp.StatusCode != http.StatusOK { - var errorResp BedrockError - - if err := json.Unmarshal(body, &errorResp); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderResponseUnmarshal, - Error: err, - }, - } - } - - return nil, &schemas.BifrostError{ - StatusCode: &resp.StatusCode, - Error: schemas.ErrorField{ - Message: errorResp.Message, - }, - } - } - - return body, nil -} - -// GetTextCompletionResult processes the text completion response from Bedrock. -// It handles different model types (Anthropic and Mistral) and formats the response. -// Returns a BifrostResponse containing the completion results or an error if processing fails. -func (provider *BedrockProvider) getTextCompletionResult(result []byte, model string) (*schemas.BifrostResponse, *schemas.BifrostError) { - switch model { - case "anthropic.claude-instant-v1:2": - fallthrough - case "anthropic.claude-v2": - fallthrough - case "anthropic.claude-v2:1": - var response BedrockAnthropicTextResponse - if err := json.Unmarshal(result, &response); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "error parsing response", - Error: err, - }, - } - } - - return &schemas.BifrostResponse{ - Choices: []schemas.BifrostResponseChoice{ - { - Index: 0, - Message: schemas.BifrostResponseChoiceMessage{ - Role: schemas.RoleAssistant, - Content: &response.Completion, - }, - FinishReason: &response.StopReason, - StopString: &response.Stop, - }, - }, - Model: model, - ExtraFields: schemas.BifrostResponseExtraFields{ - Provider: schemas.Bedrock, - }, - }, nil - - case "mistral.mixtral-8x7b-instruct-v0:1": - fallthrough - case "mistral.mistral-7b-instruct-v0:2": - fallthrough - case "mistral.mistral-large-2402-v1:0": - fallthrough - case "mistral.mistral-large-2407-v1:0": - fallthrough - case "mistral.mistral-small-2402-v1:0": - var response BedrockMistralTextResponse - if err := json.Unmarshal(result, &response); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "error parsing response", - Error: err, - }, - } - } - - var choices []schemas.BifrostResponseChoice - for i, output := range response.Outputs { - choices = append(choices, schemas.BifrostResponseChoice{ - Index: i, - Message: schemas.BifrostResponseChoiceMessage{ - Role: schemas.RoleAssistant, - Content: &output.Text, - }, - FinishReason: &output.StopReason, - }) - } - - return &schemas.BifrostResponse{ - Choices: choices, - Model: model, - ExtraFields: schemas.BifrostResponseExtraFields{ - Provider: schemas.Bedrock, - }, - }, nil - } - - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: fmt.Sprintf("invalid model choice: %s", model), - }, - } -} - -// PrepareChatCompletionMessages formats chat messages for Bedrock's API. -// It handles different model types (Anthropic and Mistral) and formats messages accordingly. -// Returns a map containing the formatted messages and any system messages, or an error if formatting fails. -func (provider *BedrockProvider) prepareChatCompletionMessages(messages []schemas.Message, model string) (map[string]interface{}, *schemas.BifrostError) { - switch model { - case "anthropic.claude-instant-v1:2": - fallthrough - case "anthropic.claude-v2": - fallthrough - case "anthropic.claude-v2:1": - fallthrough - case "anthropic.claude-3-sonnet-20240229-v1:0": - fallthrough - case "anthropic.claude-3-5-sonnet-20240620-v1:0": - fallthrough - case "anthropic.claude-3-5-sonnet-20241022-v2:0": - fallthrough - case "anthropic.claude-3-5-haiku-20241022-v1:0": - fallthrough - case "anthropic.claude-3-opus-20240229-v1:0": - fallthrough - case "anthropic.claude-3-7-sonnet-20250219-v1:0": - // Add system messages if present - var systemMessages []BedrockAnthropicSystemMessage - for _, msg := range messages { - if msg.Role == schemas.RoleSystem { - //TODO handling image inputs here - systemMessages = append(systemMessages, BedrockAnthropicSystemMessage{ - Text: *msg.Content, - }) - } - } - - // Format messages for Bedrock API - var bedrockMessages []map[string]interface{} - for _, msg := range messages { - if msg.Role != schemas.RoleSystem { - var content any - if msg.Content != nil { - content = BedrockAnthropicTextMessage{ - Type: "text", - Text: *msg.Content, - } - } else if msg.ImageContent != nil { - content = BedrockAnthropicImageMessage{ - Type: "image", - Image: BedrockAnthropicImage{ - Format: *msg.ImageContent.Type, - Source: BedrockAnthropicImageSource{ - Bytes: msg.ImageContent.URL, - }, - }, - } - } - - bedrockMessages = append(bedrockMessages, map[string]interface{}{ - "role": msg.Role, - "content": []interface{}{content}, - }) - } - } - - body := map[string]interface{}{ - "messages": bedrockMessages, - } - - if len(systemMessages) > 0 { - var messages []string - for _, message := range systemMessages { - messages = append(messages, message.Text) - } - - body["system"] = strings.Join(messages, " ") - } - - return body, nil - - case "mistral.mistral-large-2402-v1:0": - fallthrough - case "mistral.mistral-large-2407-v1:0": - var bedrockMessages []BedrockMistralChatMessage - for _, msg := range messages { - var filteredToolCalls []BedrockMistralToolCall - if msg.ToolCalls != nil { - for _, toolCall := range *msg.ToolCalls { - filteredToolCalls = append(filteredToolCalls, BedrockMistralToolCall{ - ID: *toolCall.ID, - Function: toolCall.Function, - }) - } - } - - message := BedrockMistralChatMessage{ - Role: msg.Role, - Content: []BedrockMistralContent{ - {Text: *msg.Content}, - }, - } - - if len(filteredToolCalls) > 0 { - message.ToolCalls = &filteredToolCalls - } - - bedrockMessages = append(bedrockMessages, message) - } - - body := map[string]interface{}{ - "messages": bedrockMessages, - } - - return body, nil - } - - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: fmt.Sprintf("invalid model choice: %s", model), - }, - } -} - -// GetChatCompletionTools prepares tool specifications for Bedrock's API. -// It formats tool definitions for different model types (Anthropic and Mistral). -// Returns an array of tool specifications for the given model. -func (provider *BedrockProvider) getChatCompletionTools(params *schemas.ModelParameters, model string) []BedrockAnthropicToolCall { - var tools []BedrockAnthropicToolCall - - switch model { - case "anthropic.claude-instant-v1:2": - fallthrough - case "anthropic.claude-v2": - fallthrough - case "anthropic.claude-v2:1": - fallthrough - case "anthropic.claude-3-sonnet-20240229-v1:0": - fallthrough - case "anthropic.claude-3-5-sonnet-20240620-v1:0": - fallthrough - case "anthropic.claude-3-5-sonnet-20241022-v2:0": - fallthrough - case "anthropic.claude-3-5-haiku-20241022-v1:0": - fallthrough - case "anthropic.claude-3-opus-20240229-v1:0": - fallthrough - case "anthropic.claude-3-7-sonnet-20250219-v1:0": - for _, tool := range *params.Tools { - tools = append(tools, BedrockAnthropicToolCall{ - ToolSpec: BedrockAnthropicToolSpec{ - Name: tool.Function.Name, - Description: tool.Function.Description, - InputSchema: struct { - Json interface{} `json:"json"` - }{ - Json: tool.Function.Parameters, - }, - }, - }) - } - } - - return tools -} - -// prepareTextCompletionParams prepares text completion parameters for Bedrock's API. -// It handles parameter mapping and conversion for different model types. -// Returns the modified parameters map with model-specific adjustments. -func (provider *BedrockProvider) prepareTextCompletionParams(params map[string]interface{}, model string) map[string]interface{} { - switch model { - case "anthropic.claude-instant-v1:2": - fallthrough - case "anthropic.claude-v2": - fallthrough - case "anthropic.claude-v2:1": - // Check if there is a key entry for max_tokens - if maxTokens, exists := params["max_tokens"]; exists { - // Check if max_tokens_to_sample is already present - if _, exists := params["max_tokens_to_sample"]; !exists { - // If max_tokens_to_sample is not present, rename max_tokens to max_tokens_to_sample - params["max_tokens_to_sample"] = maxTokens - } - delete(params, "max_tokens") - } - } - return params -} - -// TextCompletion performs a text completion request to Bedrock's API. -// It formats the request, sends it to Bedrock, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *BedrockProvider) TextCompletion(model, key, text string, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - preparedParams := provider.prepareTextCompletionParams(prepareParams(params), model) - - requestBody := mergeConfig(map[string]interface{}{ - "prompt": text, - }, preparedParams) - - body, err := provider.completeRequest(requestBody, fmt.Sprintf("%s/invoke", model), key) - if err != nil { - return nil, err - } - - result, err := provider.getTextCompletionResult(body, model) - if err != nil { - return nil, err - } - - // Parse raw response - var rawResponse interface{} - if err := json.Unmarshal(body, &rawResponse); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "error parsing raw response", - Error: err, - }, - } - } - - result.ExtraFields.RawResponse = rawResponse - - return result, nil -} - -// ChatCompletion performs a chat completion request to Bedrock's API. -// It formats the request, sends it to Bedrock, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *BedrockProvider) ChatCompletion(model, key string, messages []schemas.Message, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - messageBody, err := provider.prepareChatCompletionMessages(messages, model) - if err != nil { - return nil, err - } - - preparedParams := prepareParams(params) - - // Transform tools if present - if params != nil && params.Tools != nil && len(*params.Tools) > 0 { - preparedParams["tools"] = provider.getChatCompletionTools(params, model) - } - - requestBody := mergeConfig(messageBody, preparedParams) - - // Format the path with proper model identifier - path := fmt.Sprintf("%s/converse", model) - - if provider.meta != nil && provider.meta.GetInferenceProfiles() != nil { - if inferenceProfileId, ok := provider.meta.GetInferenceProfiles()[model]; ok { - if provider.meta.GetARN() != nil { - encodedModelIdentifier := url.PathEscape(fmt.Sprintf("%s/%s", *provider.meta.GetARN(), inferenceProfileId)) - path = fmt.Sprintf("%s/converse", encodedModelIdentifier) - } - } - } - - // Create the signed request - responseBody, err := provider.completeRequest(requestBody, path, key) - if err != nil { - return nil, err - } - - // Create response object from pool - response := acquireBedrockChatResponse() - defer releaseBedrockChatResponse(response) - - // Create Bifrost response from pool - bifrostResponse := acquireBifrostResponse() - defer releaseBifrostResponse(bifrostResponse) - - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - var choices []schemas.BifrostResponseChoice - for i, choice := range response.Output.Message.Content { - choices = append(choices, schemas.BifrostResponseChoice{ - Index: i, - Message: schemas.BifrostResponseChoiceMessage{ - Role: schemas.RoleAssistant, - Content: &choice.Text, - }, - FinishReason: &response.StopReason, - }) - } - - latency := float64(response.Metrics.Latency) - - bifrostResponse.Choices = choices - bifrostResponse.Usage = schemas.LLMUsage{ - PromptTokens: response.Usage.InputTokens, - CompletionTokens: response.Usage.OutputTokens, - TotalTokens: response.Usage.TotalTokens, - } - bifrostResponse.Model = model - bifrostResponse.ExtraFields = schemas.BifrostResponseExtraFields{ - Latency: &latency, - Provider: schemas.Bedrock, - RawResponse: rawResponse, - } - - return bifrostResponse, nil -} - -// signAWSRequest signs an HTTP request using AWS Signature Version 4. -// It is used in providers like Bedrock. -// It sets required headers, calculates the request body hash, and signs the request -// using the provided AWS credentials. -// Returns a BifrostError if signing fails. -func signAWSRequest(req *http.Request, accessKey, secretKey string, sessionToken *string, region, service string) *schemas.BifrostError { - // Set required headers before signing - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - // Calculate SHA256 hash of the request body - var bodyHash string - if req.Body != nil { - bodyBytes, err := io.ReadAll(req.Body) - if err != nil { - return &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "error reading request body", - Error: err, - }, - } - } - // Restore the body for subsequent reads - req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - hash := sha256.Sum256(bodyBytes) - bodyHash = hex.EncodeToString(hash[:]) - } else { - // For empty body, use the hash of an empty string - hash := sha256.Sum256([]byte{}) - bodyHash = hex.EncodeToString(hash[:]) - } - - cfg, err := config.LoadDefaultConfig(context.TODO(), - config.WithRegion(region), - config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) { - creds := aws.Credentials{ - AccessKeyID: accessKey, - SecretAccessKey: secretKey, - } - if sessionToken != nil { - creds.SessionToken = *sessionToken - } - return creds, nil - })), - ) - if err != nil { - return &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "failed to load aws config", - Error: err, - }, - } - } - - // Create the AWS signer - signer := v4.NewSigner() - - // Get credentials - creds, err := cfg.Credentials.Retrieve(context.TODO()) - if err != nil { - return &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "failed to retrieve aws credentials", - Error: err, - }, - } - } - - // Sign the request with AWS Signature V4 - if err := signer.SignHTTP(context.TODO(), creds, req, bodyHash, service, region, time.Now()); err != nil { - return &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: "failed to sign request", - Error: err, - }, - } - } - - return nil -} diff --git a/core/providers/bedrock/bedrock.go b/core/providers/bedrock/bedrock.go new file mode 100644 index 000000000..c13404e85 --- /dev/null +++ b/core/providers/bedrock/bedrock.go @@ -0,0 +1,1116 @@ +package bedrock + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/providers/cohere" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// BedrockProvider implements the Provider interface for AWS Bedrock. +type BedrockProvider struct { + logger schemas.Logger // Logger for provider operations + client *http.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + customProviderConfig *schemas.CustomProviderConfig // Custom provider config + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// bedrockChatResponsePool provides a pool for Bedrock response objects. +var bedrockChatResponsePool = sync.Pool{ + New: func() interface{} { + return &BedrockConverseResponse{} + }, +} + +// acquireBedrockChatResponse gets a Bedrock response from the pool and resets it. +func acquireBedrockChatResponse() *BedrockConverseResponse { + resp := bedrockChatResponsePool.Get().(*BedrockConverseResponse) + *resp = BedrockConverseResponse{} // Reset the struct + return resp +} + +// releaseBedrockChatResponse returns a Bedrock response to the pool. +func releaseBedrockChatResponse(resp *BedrockConverseResponse) { + if resp != nil { + bedrockChatResponsePool.Put(resp) + } +} + +// NewBedrockProvider creates a new Bedrock provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts and AWS-specific settings. +func NewBedrockProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*BedrockProvider, error) { + config.CheckAndSetDefaults() + + client := &http.Client{Timeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds)} + + // Pre-warm response pools + for range config.ConcurrencyAndBufferSize.Concurrency { + for i := 0; i < config.ConcurrencyAndBufferSize.Concurrency; i++ { + bedrockChatResponsePool.Put(&BedrockConverseResponse{}) + } + + } + + return &BedrockProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + customProviderConfig: config.CustomProviderConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Bedrock. +func (provider *BedrockProvider) GetProviderKey() schemas.ModelProvider { + return providerUtils.GetProviderName(schemas.Bedrock, provider.customProviderConfig) +} + +// completeRequest sends a request to Bedrock's API and handles the response. +// It constructs the API URL, sets up AWS authentication, and processes the response. +// Returns the response body, request latency, or an error if the request fails. +func (provider *BedrockProvider) completeRequest(ctx context.Context, jsonData []byte, path string, key schemas.Key) ([]byte, time.Duration, *schemas.BifrostError) { + config := key.BedrockKeyConfig + + region := DefaultBedrockRegion + if config.Region != nil { + region = *config.Region + } + + // Create the request with the JSON body + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s", region, path), bytes.NewBuffer(jsonData)) + if err != nil { + return nil, 0, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "error creating request", + Error: err, + }, + } + } + + // Set any extra headers from network config + providerUtils.SetExtraHeadersHTTP(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // If Value is set, use API Key authentication - else use IAM role authentication + if key.Value != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key.Value)) + } else { + // Sign the request using either explicit credentials or IAM role authentication + if err := signAWSRequest(ctx, req, config.AccessKey, config.SecretKey, config.SessionToken, region, "bedrock", provider.GetProviderKey()); err != nil { + return nil, 0, err + } + } + + // Execute the request and measure latency + startTime := time.Now() + resp, err := provider.client.Do(req) + latency := time.Since(startTime) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, latency, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, http.ErrHandlerTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, provider.GetProviderKey()) + } + return nil, latency, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderDoRequest, + Error: err, + }, + } + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, latency, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "error reading request", + Error: err, + }, + } + } + + if resp.StatusCode != http.StatusOK { + var errorResp BedrockError + + if err := sonic.Unmarshal(body, &errorResp); err != nil { + return nil, latency, &schemas.BifrostError{ + IsBifrostError: true, + StatusCode: &resp.StatusCode, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderResponseUnmarshal, + Error: err, + }, + } + } + + return nil, latency, &schemas.BifrostError{ + StatusCode: &resp.StatusCode, + Error: &schemas.ErrorField{ + Message: errorResp.Message, + }, + } + } + + return body, latency, nil +} + +// makeStreamingRequest creates a streaming request to Bedrock's API. +// It formats the request, sends it to Bedrock, and returns the response. +// Returns the response body and an error if the request fails. +func (provider *BedrockProvider) makeStreamingRequest(ctx context.Context, jsonData []byte, key schemas.Key, model string) (*http.Response, string, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + if key.BedrockKeyConfig == nil { + return nil, "", providerUtils.NewConfigurationError("bedrock key config is not provided", providerName) + } + + // Format the path with proper model identifier for streaming + path, deployment := provider.getModelPath("converse-stream", model, key) + + region := DefaultBedrockRegion + if key.BedrockKeyConfig.Region != nil { + region = *key.BedrockKeyConfig.Region + } + + // Create HTTP request for streaming + req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s", region, path), bytes.NewReader(jsonData)) + if reqErr != nil { + return nil, deployment, providerUtils.NewBifrostOperationError("error creating request", reqErr, providerName) + } + + // Set any extra headers from network config + providerUtils.SetExtraHeadersHTTP(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // If Value is set, use API Key authentication - else use IAM role authentication + req.Header.Set("Accept", "application/vnd.amazon.eventstream") + if key.Value != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key.Value)) + } else { + req.Header.Set("Accept", "application/vnd.amazon.eventstream") + // Sign the request using either explicit credentials or IAM role authentication + if err := signAWSRequest(ctx, req, key.BedrockKeyConfig.AccessKey, key.BedrockKeyConfig.SecretKey, key.BedrockKeyConfig.SessionToken, region, "bedrock", providerName); err != nil { + return nil, deployment, err + } + } + + // Make the request + resp, respErr := provider.client.Do(req) + if respErr != nil { + if errors.Is(respErr, context.Canceled) { + return nil, deployment, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: respErr, + }, + } + } + return nil, deployment, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, respErr, providerName) + } + + // Check for HTTP errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, deployment, providerUtils.NewProviderAPIError(fmt.Sprintf("HTTP error from %s: %d", providerName, resp.StatusCode), fmt.Errorf("%s", string(body)), resp.StatusCode, providerName, nil, nil) + } + + return resp, deployment, nil +} + +// signAWSRequest signs an HTTP request using AWS Signature Version 4. +// It is used in providers like Bedrock. +// It sets required headers, calculates the request body hash, and signs the request +// using the provided AWS credentials. +// Returns a BifrostError if signing fails. +func signAWSRequest(ctx context.Context, req *http.Request, accessKey, secretKey string, sessionToken *string, region, service string, providerName schemas.ModelProvider) *schemas.BifrostError { + // Set required headers before signing + req.Header.Set("Content-Type", "application/json") + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } + + // Calculate SHA256 hash of the request body + var bodyHash string + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return providerUtils.NewBifrostOperationError("error reading request body", err, providerName) + } + // Restore the body for subsequent reads + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + hash := sha256.Sum256(bodyBytes) + bodyHash = hex.EncodeToString(hash[:]) + } else { + // For empty body, use the hash of an empty string + hash := sha256.Sum256([]byte{}) + bodyHash = hex.EncodeToString(hash[:]) + } + + var cfg aws.Config + var err error + + // If both accessKey and secretKey are empty, use the default credential provider chain + // This will automatically use IAM roles, environment variables, shared credentials, etc. + if accessKey == "" && secretKey == "" { + cfg, err = config.LoadDefaultConfig(ctx, + config.WithRegion(region), + ) + } else { + // Use explicit credentials when provided + cfg, err = config.LoadDefaultConfig(ctx, + config.WithRegion(region), + config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) { + creds := aws.Credentials{ + AccessKeyID: accessKey, + SecretAccessKey: secretKey, + } + if sessionToken != nil && *sessionToken != "" { + creds.SessionToken = *sessionToken + } + return creds, nil + })), + ) + } + if err != nil { + return providerUtils.NewBifrostOperationError("failed to load aws config", err, providerName) + } + + // Create the AWS signer + signer := v4.NewSigner() + + // Get credentials + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return providerUtils.NewBifrostOperationError("failed to retrieve aws credentials", err, providerName) + } + + // Sign the request with AWS Signature V4 + if err := signer.SignHTTP(ctx, creds, req, bodyHash, service, region, time.Now()); err != nil { + return providerUtils.NewBifrostOperationError("failed to sign request", err, providerName) + } + + return nil +} + +// listModelsByKey performs a list models request to Bedrock's API for a single key. +// It retrieves all foundation models available in Amazon Bedrock for a specific key. +func (provider *BedrockProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + if key.BedrockKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("bedrock key config is not provided", providerName) + } + + config := key.BedrockKeyConfig + + region := DefaultBedrockRegion + if config.Region != nil { + region = *config.Region + } + + // Build query parameters + params := url.Values{} + if request.ExtraParams != nil { + if byCustomizationType, ok := request.ExtraParams["byCustomizationType"].(string); ok && byCustomizationType != "" { + params.Set("byCustomizationType", byCustomizationType) + } + if byInferenceType, ok := request.ExtraParams["byInferenceType"].(string); ok && byInferenceType != "" { + params.Set("byInferenceType", byInferenceType) + } + if byOutputModality, ok := request.ExtraParams["byOutputModality"].(string); ok && byOutputModality != "" { + params.Set("byOutputModality", byOutputModality) + } + if byProvider, ok := request.ExtraParams["byProvider"].(string); ok && byProvider != "" { + params.Set("byProvider", byProvider) + } + } + + // List models endpoint uses the bedrock service (not bedrock-runtime) + url := fmt.Sprintf("https://bedrock.%s.amazonaws.com/foundation-models?%s", region, params.Encode()) + + // Create the GET request without a body + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "error creating request", + Error: err, + }, + } + } + + // Set any extra headers from network config + providerUtils.SetExtraHeadersHTTP(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // If Value is set, use API Key authentication - else use IAM role authentication + if key.Value != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key.Value)) + } else { + // Sign the request using either explicit credentials or IAM role authentication + if err := signAWSRequest(ctx, req, config.AccessKey, config.SecretKey, config.SessionToken, region, "bedrock", providerName); err != nil { + return nil, err + } + } + + startTime := time.Now() + + // Execute the request + resp, err := provider.client.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } else if errors.Is(err, http.ErrHandlerTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderDoRequest, + Error: err, + }, + } + } + + // Read response body and close + responseBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "error reading request", + Error: err, + }, + } + } + + if resp.StatusCode != http.StatusOK { + var errorResp BedrockError + + if err := sonic.Unmarshal(responseBody, &errorResp); err != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + StatusCode: &resp.StatusCode, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderResponseUnmarshal, + Error: err, + }, + } + } + return nil, &schemas.BifrostError{ + StatusCode: &resp.StatusCode, + Error: &schemas.ErrorField{ + Message: errorResp.Message, + }, + } + } + + // Parse Bedrock-specific response + bedrockResponse := &BedrockListModelsResponse{} + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, bedrockResponse, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Convert to Bifrost response + response := bedrockResponse.ToBifrostListModelsResponse(providerName) + if response == nil { + return nil, providerUtils.NewBifrostOperationError("failed to convert Bedrock model list response", nil, providerName) + } + + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ListModels performs a list models request to Bedrock's API. +// It retrieves all foundation models available in Amazon Bedrock. +// Requests are made concurrently for improved performance. +func (provider *BedrockProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.ListModelsRequest); err != nil { + return nil, err + } + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion performs a text completion request to Bedrock's API. +// It formats the request, sends it to Bedrock, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *BedrockProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.TextCompletionRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + if key.BedrockKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("bedrock key config is not provided", providerName) + } + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockTextCompletionRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + path, deployment := provider.getModelPath("invoke", request.Model, key) + body, latency, err := provider.completeRequest(ctx, jsonData, path, key) + if err != nil { + return nil, err + } + + // Handle model-specific response conversion + var bifrostResponse *schemas.BifrostTextCompletionResponse + switch { + case strings.Contains(request.Model, "anthropic.") || strings.Contains(request.Model, "claude"): + var response BedrockAnthropicTextResponse + if err := sonic.Unmarshal(body, &response); err != nil { + return nil, providerUtils.NewBifrostOperationError("error parsing anthropic response", err, providerName) + } + bifrostResponse = response.ToBifrostTextCompletionResponse() + + case strings.Contains(request.Model, "mistral."): + var response BedrockMistralTextResponse + if err := sonic.Unmarshal(body, &response); err != nil { + return nil, providerUtils.NewBifrostOperationError("error parsing mistral response", err, providerName) + } + bifrostResponse = response.ToBifrostTextCompletionResponse() + + default: + return nil, providerUtils.NewConfigurationError(fmt.Sprintf("unsupported model type for text completion: %s", request.Model), providerName) + } + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = providerName + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.ModelDeployment = deployment + bifrostResponse.ExtraFields.RequestType = schemas.TextCompletionRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Parse raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + var rawResponse interface{} + if err := sonic.Unmarshal(body, &rawResponse); err != nil { + return nil, providerUtils.NewBifrostOperationError("error parsing raw response", err, providerName) + } + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// TextCompletionStream performs a streaming text completion request to Bedrock's API. +// It formats the request, sends it to Bedrock, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *BedrockProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, schemas.Bedrock) +} + +// ChatCompletion performs a chat completion request to Bedrock's API. +// It formats the request, sends it to Bedrock, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *BedrockProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.ChatCompletionRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + if key.BedrockKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("bedrock key config is not provided", providerName) + } + + // Use centralized Bedrock converter + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockChatCompletionRequest(request) }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Format the path with proper model identifier + path, deployment := provider.getModelPath("converse", request.Model, key) + + // Create the signed request + responseBody, latency, bifrostErr := provider.completeRequest(ctx, jsonData, path, key) + if bifrostErr != nil { + return nil, bifrostErr + } + + // pool the response + bedrockResponse := acquireBedrockChatResponse() + defer releaseBedrockChatResponse(bedrockResponse) + + // Parse the response using the new Bedrock type + if err := sonic.Unmarshal(responseBody, bedrockResponse); err != nil { + return nil, providerUtils.NewBifrostOperationError("failed to parse bedrock response", err, providerName) + } + + // Convert using the new response converter + bifrostResponse, err := bedrockResponse.ToBifrostChatResponse(request.Model) + if err != nil { + return nil, providerUtils.NewBifrostOperationError("failed to convert bedrock response", err, providerName) + } + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = providerName + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.ModelDeployment = deployment + bifrostResponse.ExtraFields.RequestType = schemas.ChatCompletionRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + var rawResponse interface{} + if err := sonic.Unmarshal(responseBody, &rawResponse); err == nil { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + } + + return bifrostResponse, nil +} + +// ChatCompletionStream performs a streaming chat completion request to Bedrock's API. +// It formats the request, sends it to Bedrock, and processes the streaming response. +// Returns a channel for streaming BifrostResponse objects or an error if the request fails. +func (provider *BedrockProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.ChatCompletionStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockChatCompletionRequest(request) }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + resp, deployment, bifrostErr := provider.makeStreamingRequest(ctx, jsonData, key, request.Model) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer close(responseChan) + defer resp.Body.Close() + + // Process AWS Event Stream format + var messageID string + var usage *schemas.BifrostLLMUsage + var finishReason *string + chunkIndex := 0 + + // Process AWS Event Stream format using proper decoder + startTime := time.Now() + lastChunkTime := startTime + decoder := eventstream.NewDecoder() + payloadBuf := make([]byte, 0, 1024*1024) // 1MB payload buffer + + for { + // Decode a single EventStream message + message, err := decoder.Decode(resp.Body, payloadBuf) + if err != nil { + if err == io.EOF { + // End of stream - this is normal + break + } + provider.logger.Warn(fmt.Sprintf("Error decoding %s EventStream message: %v", providerName, err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ChatCompletionStreamRequest, providerName, request.Model, provider.logger) + return + } + + // Process the decoded message payload (contains JSON for normal events) + if len(message.Payload) > 0 { + if msgTypeHeader := message.Headers.Get(":message-type"); msgTypeHeader != nil { + if msgType := msgTypeHeader.String(); msgType != "event" { + excType := msgType + if excHeader := message.Headers.Get(":exception-type"); excHeader != nil { + if v := excHeader.String(); v != "" { + excType = v + } + } + errMsg := string(message.Payload) + err := fmt.Errorf("%s stream %s: %s", providerName, excType, errMsg) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ChatCompletionStreamRequest, providerName, request.Model, provider.logger) + return + } + } + + // Parse the JSON event into our typed structure + var streamEvent BedrockStreamEvent + if err := sonic.Unmarshal(message.Payload, &streamEvent); err != nil { + provider.logger.Debug(fmt.Sprintf("Failed to parse JSON from event buffer: %v, data: %s", err, string(message.Payload))) + return + } + + if streamEvent.Usage != nil { + usage = &schemas.BifrostLLMUsage{ + PromptTokens: streamEvent.Usage.InputTokens, + CompletionTokens: streamEvent.Usage.OutputTokens, + TotalTokens: streamEvent.Usage.TotalTokens, + } + } + + if streamEvent.StopReason != nil { + finishReason = schemas.Ptr(anthropic.ConvertAnthropicFinishReasonToBifrost(anthropic.AnthropicStopReason(*streamEvent.StopReason))) + } + + response, bifrostErr, _ := streamEvent.ToBifrostChatCompletionStream() + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ChatCompletionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + return + } + if response != nil { + response.ID = messageID + response.Model = request.Model + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ModelDeployment: deployment, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + chunkIndex++ + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = string(message.Payload) + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + } + } + } + + // Send final response + response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finishReason, chunkIndex, schemas.ChatCompletionStreamRequest, providerName, request.Model) + response.ExtraFields.ModelDeployment = deployment + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + }() + + return responseChan, nil +} + +// Responses performs a chat completion request to Anthropic's API. +// It formats the request, sends it to Anthropic, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *BedrockProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.ResponsesRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + if key.BedrockKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("bedrock key config is not provided", providerName) + } + + // Use centralized Bedrock converter + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockResponsesRequest(request) }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Format the path with proper model identifier + path, deployment := provider.getModelPath("converse", request.Model, key) + + // Create the signed request + responseBody, latency, bifrostErr := provider.completeRequest(ctx, jsonData, path, key) + if bifrostErr != nil { + return nil, bifrostErr + } + + // pool the response + bedrockResponse := acquireBedrockChatResponse() + defer releaseBedrockChatResponse(bedrockResponse) + + // Parse the response using the new Bedrock type + if err := sonic.Unmarshal(responseBody, bedrockResponse); err != nil { + return nil, providerUtils.NewBifrostOperationError("failed to parse bedrock response", err, providerName) + } + + // Convert using the new response converter + bifrostResponse, err := bedrockResponse.ToBifrostResponsesResponse() + if err != nil { + return nil, providerUtils.NewBifrostOperationError("failed to convert bedrock response", err, providerName) + } + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = providerName + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.ModelDeployment = deployment + bifrostResponse.ExtraFields.RequestType = schemas.ResponsesRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + var rawResponse interface{} + if err := sonic.Unmarshal(responseBody, &rawResponse); err == nil { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + } + + return bifrostResponse, nil +} + +// ResponsesStream performs a streaming chat completion request to Bedrock's API. +// It formats the request, sends it to Bedrock, and processes the streaming response. +// Returns a channel for streaming BifrostResponse objects or an error if the request fails. +func (provider *BedrockProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.ResponsesStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockResponsesRequest(request) }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + resp, deployment, bifrostErr := provider.makeStreamingRequest(ctx, jsonData, key, request.Model) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer close(responseChan) + defer resp.Body.Close() + + // Process AWS Event Stream format + var usage *schemas.ResponsesResponseUsage + chunkIndex := 0 + + // Create stream state for stateful conversions + streamState := acquireBedrockResponsesStreamState() + streamState.Model = &request.Model + defer releaseBedrockResponsesStreamState(streamState) + + // Process AWS Event Stream format using proper decoder + startTime := time.Now() + lastChunkTime := startTime + decoder := eventstream.NewDecoder() + payloadBuf := make([]byte, 0, 1024*1024) // 1MB payload buffer + + for { + // Decode a single EventStream message + message, err := decoder.Decode(resp.Body, payloadBuf) + if err != nil { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + if err == io.EOF { + // End of stream - finalize any open items + finalResponses := FinalizeBedrockStream(streamState, chunkIndex, usage) + for _, finalResponse := range finalResponses { + finalResponse.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ModelDeployment: deployment, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + chunkIndex++ + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + finalResponse.ExtraFields.RawResponse = "{}" // Final event has no payload + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, finalResponse, nil, nil), responseChan) + } + break + } + provider.logger.Warn(fmt.Sprintf("Error decoding %s EventStream message: %v", providerName, err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ResponsesStreamRequest, providerName, request.Model, provider.logger) + return + } + + // Process the decoded message payload (contains JSON for normal events) + if len(message.Payload) > 0 { + if msgTypeHeader := message.Headers.Get(":message-type"); msgTypeHeader != nil { + if msgType := msgTypeHeader.String(); msgType != "event" { + excType := msgType + if excHeader := message.Headers.Get(":exception-type"); excHeader != nil { + if v := excHeader.String(); v != "" { + excType = v + } + } + errMsg := string(message.Payload) + err := fmt.Errorf("%s stream %s: %s", providerName, excType, errMsg) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ResponsesStreamRequest, providerName, request.Model, provider.logger) + return + } + } + + // Parse the JSON event into our typed structure + var streamEvent BedrockStreamEvent + if err := sonic.Unmarshal(message.Payload, &streamEvent); err != nil { + provider.logger.Debug(fmt.Sprintf("Failed to parse JSON from event buffer: %v, data: %s", err, string(message.Payload))) + return + } + + if streamEvent.Usage != nil { + usage = &schemas.ResponsesResponseUsage{ + InputTokens: streamEvent.Usage.InputTokens, + OutputTokens: streamEvent.Usage.OutputTokens, + TotalTokens: streamEvent.Usage.TotalTokens, + } + } + + responses, bifrostErr, _ := streamEvent.ToBifrostResponsesStream(chunkIndex, streamState) + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + return + } + for _, response := range responses { + if response != nil { + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ModelDeployment: deployment, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + chunkIndex++ + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = string(message.Payload) + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + } + } + } + } + }() + + return responseChan, nil +} + +// Embedding generates embeddings for the given input text(s) using Amazon Bedrock. +// Supports Titan and Cohere embedding models. Returns a BifrostResponse containing the embedding(s) and any error that occurred. +func (provider *BedrockProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Bedrock, provider.customProviderConfig, schemas.EmbeddingRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + if key.BedrockKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("bedrock key config is not provided", providerName) + } + + // Determine model type + modelType, err := DetermineEmbeddingModelType(request.Model) + if err != nil { + return nil, providerUtils.NewConfigurationError(err.Error(), providerName) + } + + // Convert request and execute based on model type + var rawResponse []byte + var bifrostError *schemas.BifrostError + var latency time.Duration + var path string + var deployment string + + switch modelType { + case "titan": + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockTitanEmbeddingRequest(request) }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + path, deployment = provider.getModelPath("invoke", request.Model, key) + rawResponse, latency, bifrostError = provider.completeRequest(ctx, jsonData, path, key) + + case "cohere": + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToBedrockCohereEmbeddingRequest(request) }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + path, deployment = provider.getModelPath("invoke", request.Model, key) + rawResponse, latency, bifrostError = provider.completeRequest(ctx, jsonData, path, key) + + default: + return nil, providerUtils.NewConfigurationError("unsupported embedding model type", providerName) + } + + if bifrostError != nil { + return nil, bifrostError + } + + // Parse response based on model type + var bifrostResponse *schemas.BifrostEmbeddingResponse + switch modelType { + case "titan": + var titanResp BedrockTitanEmbeddingResponse + if err := sonic.Unmarshal(rawResponse, &titanResp); err != nil { + return nil, providerUtils.NewBifrostOperationError("error parsing Titan embedding response", err, providerName) + } + bifrostResponse = titanResp.ToBifrostEmbeddingResponse() + bifrostResponse.Model = request.Model + + case "cohere": + var cohereResp cohere.CohereEmbeddingResponse + if err := sonic.Unmarshal(rawResponse, &cohereResp); err != nil { + return nil, providerUtils.NewBifrostOperationError("error parsing Cohere embedding response", err, providerName) + } + bifrostResponse = cohereResp.ToBifrostEmbeddingResponse() + bifrostResponse.Model = request.Model + } + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = providerName + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.ModelDeployment = deployment + bifrostResponse.ExtraFields.RequestType = schemas.EmbeddingRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + var rawResponseData interface{} + if err := sonic.Unmarshal(rawResponse, &rawResponseData); err == nil { + bifrostResponse.ExtraFields.RawResponse = rawResponseData + } + } + + return bifrostResponse, nil +} + +// Speech is not supported by the Bedrock provider. +func (provider *BedrockProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, schemas.Bedrock) +} + +// SpeechStream is not supported by the Bedrock provider. +func (provider *BedrockProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, schemas.Bedrock) +} + +// Transcription is not supported by the Bedrock provider. +func (provider *BedrockProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, schemas.Bedrock) +} + +// TranscriptionStream is not supported by the Bedrock provider. +func (provider *BedrockProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, schemas.Bedrock) +} + +func (provider *BedrockProvider) getModelPath(basePath string, model string, key schemas.Key) (string, string) { + // Format the path with proper model identifier for streaming + path := fmt.Sprintf("%s/%s", model, basePath) + + var deployment string + if key.BedrockKeyConfig.Deployments != nil { + if inferenceProfileID, ok := key.BedrockKeyConfig.Deployments[model]; ok { + if key.BedrockKeyConfig.ARN != nil { + deployment = inferenceProfileID + encodedModelIdentifier := url.PathEscape(fmt.Sprintf("%s/%s", *key.BedrockKeyConfig.ARN, inferenceProfileID)) + path = fmt.Sprintf("%s/%s", encodedModelIdentifier, basePath) + } + } + } + return path, deployment +} diff --git a/core/providers/bedrock/chat.go b/core/providers/bedrock/chat.go new file mode 100644 index 000000000..dfb4f8425 --- /dev/null +++ b/core/providers/bedrock/chat.go @@ -0,0 +1,267 @@ +package bedrock + +import ( + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/google/uuid" + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBedrockChatCompletionRequest converts a Bifrost request to Bedrock Converse API format +func ToBedrockChatCompletionRequest(bifrostReq *schemas.BifrostChatRequest) (*BedrockConverseRequest, error) { + if bifrostReq == nil { + return nil, fmt.Errorf("bifrost request is nil") + } + + if bifrostReq.Input == nil { + return nil, fmt.Errorf("only chat completion requests are supported for Bedrock Converse API") + } + + bedrockReq := &BedrockConverseRequest{ + ModelID: bifrostReq.Model, + } + + // Convert messages and system messages + messages, systemMessages, err := convertMessages(bifrostReq.Input) + if err != nil { + return nil, fmt.Errorf("failed to convert messages: %w", err) + } + bedrockReq.Messages = messages + if len(systemMessages) > 0 { + bedrockReq.System = systemMessages + } + + // Convert parameters and configurations + convertChatParameters(bifrostReq, bedrockReq) + + // Ensure tool config is present when needed + ensureChatToolConfigForConversation(bifrostReq, bedrockReq) + + return bedrockReq, nil +} + +// ToBifrostChatResponse converts a Bedrock Converse API response to Bifrost format +func (response *BedrockConverseResponse) ToBifrostChatResponse(model string) (*schemas.BifrostChatResponse, error) { + if response == nil { + return nil, fmt.Errorf("bedrock response is nil") + } + + // Convert content blocks and tool calls + var contentStr *string + var contentBlocks []schemas.ChatContentBlock + var toolCalls []schemas.ChatAssistantMessageToolCall + + if response.Output.Message != nil { + if len(response.Output.Message.Content) == 1 && response.Output.Message.Content[0].Text != nil { + contentStr = response.Output.Message.Content[0].Text + } else { + for _, contentBlock := range response.Output.Message.Content { + // Handle text content + if contentBlock.Text != nil && *contentBlock.Text != "" { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: contentBlock.Text, + }) + } + + // Handle tool use + if contentBlock.ToolUse != nil { + // Marshal the tool input to JSON string + var arguments string + if contentBlock.ToolUse.Input != nil { + if argBytes, err := sonic.Marshal(contentBlock.ToolUse.Input); err == nil { + arguments = string(argBytes) + } else { + arguments = fmt.Sprintf("%v", contentBlock.ToolUse.Input) + } + } else { + arguments = "{}" + } + + // Create copies of the values to avoid range loop variable capture + toolUseID := contentBlock.ToolUse.ToolUseID + toolUseName := contentBlock.ToolUse.Name + + toolCalls = append(toolCalls, schemas.ChatAssistantMessageToolCall{ + Index: uint16(len(toolCalls)), + Type: schemas.Ptr("function"), + ID: &toolUseID, + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: &toolUseName, + Arguments: arguments, + }, + }) + } + } + } + } + + // Create the message content + messageContent := schemas.ChatMessageContent{ + ContentStr: contentStr, + ContentBlocks: contentBlocks, + } + + // Create assistant message if we have tool calls + var assistantMessage *schemas.ChatAssistantMessage + if len(toolCalls) > 0 { + assistantMessage = &schemas.ChatAssistantMessage{ + ToolCalls: toolCalls, + } + } + + // Create the response choice + choices := []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &messageContent, + ChatAssistantMessage: assistantMessage, + }, + }, + FinishReason: schemas.Ptr(anthropic.ConvertAnthropicFinishReasonToBifrost(anthropic.AnthropicStopReason(response.StopReason))), + }, + } + var usage *schemas.BifrostLLMUsage + if response.Usage != nil { + // Convert usage information + usage = &schemas.BifrostLLMUsage{ + PromptTokens: response.Usage.InputTokens, + CompletionTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.TotalTokens, + } + } + + // Create the final Bifrost response + bifrostResponse := &schemas.BifrostChatResponse{ + ID: uuid.New().String(), + Model: model, + Object: "chat.completion", + Choices: choices, + Usage: usage, + Created: int(time.Now().Unix()), + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: schemas.Bedrock, + }, + } + + return bifrostResponse, nil +} + +func (chunk *BedrockStreamEvent) ToBifrostChatCompletionStream() (*schemas.BifrostChatResponse, *schemas.BifrostError, bool) { + // event with metrics/usage is the last and with stop reason is the second last + switch { + case chunk.Role != nil: + // Send empty response to signal start + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Role: chunk.Role, + }, + }, + }, + }, + } + + return streamResponse, nil, false + + case chunk.Start != nil && chunk.Start.ToolUse != nil: + // Handle tool use start event + contentBlockIndex := 0 + if chunk.ContentBlockIndex != nil { + contentBlockIndex = *chunk.ContentBlockIndex + } + + toolUseStart := chunk.Start.ToolUse + + // Create tool call structure for start event + var toolCall schemas.ChatAssistantMessageToolCall + toolCall.ID = schemas.Ptr(toolUseStart.ToolUseID) + toolCall.Type = schemas.Ptr("function") + toolCall.Function.Name = schemas.Ptr(toolUseStart.Name) + toolCall.Function.Arguments = "{}" // Start with empty arguments + + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: contentBlockIndex, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + ToolCalls: []schemas.ChatAssistantMessageToolCall{toolCall}, + }, + }, + }, + }, + } + + return streamResponse, nil, false + + case chunk.ContentBlockIndex != nil && chunk.Delta != nil: + // Handle contentBlockDelta event + contentBlockIndex := *chunk.ContentBlockIndex + + switch { + case chunk.Delta.Text != nil: + // Handle text delta + text := *chunk.Delta.Text + if text != "" { + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: contentBlockIndex, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Content: &text, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case chunk.Delta.ToolUse != nil: + // Handle tool use delta + toolUseDelta := chunk.Delta.ToolUse + + // Create tool call structure + var toolCall schemas.ChatAssistantMessageToolCall + toolCall.Type = schemas.Ptr("function") + + // For streaming, we need to accumulate tool use data + // This is a simplified approach - in practice, you'd need to track tool calls across chunks + toolCall.Function.Arguments = toolUseDelta.Input + + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: contentBlockIndex, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + ToolCalls: []schemas.ChatAssistantMessageToolCall{toolCall}, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + } + + return nil, nil, false +} diff --git a/core/providers/bedrock/embedding.go b/core/providers/bedrock/embedding.go new file mode 100644 index 000000000..f8eaf9cf2 --- /dev/null +++ b/core/providers/bedrock/embedding.go @@ -0,0 +1,95 @@ +package bedrock + +import ( + "fmt" + "strings" + + "github.com/maximhq/bifrost/core/providers/cohere" + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBedrockTitanEmbeddingRequest converts a Bifrost embedding request to Bedrock Titan format +func ToBedrockTitanEmbeddingRequest(bifrostReq *schemas.BifrostEmbeddingRequest) (*BedrockTitanEmbeddingRequest, error) { + if bifrostReq == nil { + return nil, fmt.Errorf("bifrost embedding request is nil") + } + + // Validate that only single text input is provided for Titan models + if bifrostReq.Input.Text == nil && len(bifrostReq.Input.Texts) == 0 { + return nil, fmt.Errorf("no input text provided for embedding") + } + + // Validate dimensions parameter - Titan models do not support it + if bifrostReq.Params != nil && bifrostReq.Params.Dimensions != nil { + return nil, fmt.Errorf("amazon Titan embedding models do not support custom dimensions parameter") + } + + titanReq := &BedrockTitanEmbeddingRequest{} + + // Set input text + if bifrostReq.Input.Text != nil { + titanReq.InputText = *bifrostReq.Input.Text + } else if len(bifrostReq.Input.Texts) > 0 { + var embeddingText string + for _, text := range bifrostReq.Input.Texts { + embeddingText += text + " \n" + } + titanReq.InputText = embeddingText + } + + return titanReq, nil +} + +// ToBifrostEmbeddingResponse converts a Bedrock Titan embedding response to Bifrost format +func (response *BedrockTitanEmbeddingResponse) ToBifrostEmbeddingResponse() *schemas.BifrostEmbeddingResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostEmbeddingResponse{ + Object: "list", + Data: []schemas.EmbeddingData{ + { + Index: 0, + Object: "embedding", + Embedding: schemas.EmbeddingStruct{ + EmbeddingArray: response.Embedding, + }, + }, + }, + Usage: &schemas.BifrostLLMUsage{ + PromptTokens: response.InputTextTokenCount, + TotalTokens: response.InputTextTokenCount, + }, + } + + return bifrostResponse +} + +// ToBedrockCohereEmbeddingRequest converts a Bifrost embedding request to Bedrock Cohere format +// Reuses the Cohere converter since the format is identical +func ToBedrockCohereEmbeddingRequest(bifrostReq *schemas.BifrostEmbeddingRequest) (*cohere.CohereEmbeddingRequest, error) { + if bifrostReq == nil { + return nil, fmt.Errorf("bifrost embedding request is nil") + } + + // Reuse Cohere's converter - the format is identical for Bedrock + cohereReq := cohere.ToCohereEmbeddingRequest(bifrostReq) + if cohereReq == nil { + return nil, fmt.Errorf("failed to convert to Cohere embedding request") + } + + return cohereReq, nil +} + +// DetermineEmbeddingModelType determines the embedding model type from the model name +func DetermineEmbeddingModelType(model string) (string, error) { + switch { + case strings.Contains(model, "amazon.titan-embed-text"): + return "titan", nil + case strings.Contains(model, "cohere.embed"): + return "cohere", nil + default: + return "", fmt.Errorf("unsupported embedding model: %s", model) + } +} diff --git a/core/providers/bedrock/models.go b/core/providers/bedrock/models.go new file mode 100644 index 000000000..8e82a81de --- /dev/null +++ b/core/providers/bedrock/models.go @@ -0,0 +1,27 @@ +package bedrock + +import "github.com/maximhq/bifrost/core/schemas" + +func (response *BedrockListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider) *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.ModelSummaries)), + } + + for _, model := range response.ModelSummaries { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(providerKey) + "/" + model.ModelID, + Name: schemas.Ptr(model.ModelName), + OwnedBy: schemas.Ptr(model.ProviderName), + Architecture: &schemas.Architecture{ + InputModalities: model.InputModalities, + OutputModalities: model.OutputModalities, + }, + }) + } + + return bifrostResponse +} diff --git a/core/providers/bedrock/responses.go b/core/providers/bedrock/responses.go new file mode 100644 index 000000000..d432b870c --- /dev/null +++ b/core/providers/bedrock/responses.go @@ -0,0 +1,966 @@ +package bedrock + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// BedrockResponsesStreamState tracks state during streaming conversion for responses API +type BedrockResponsesStreamState struct { + ContentIndexToOutputIndex map[int]int // Maps Bedrock contentBlockIndex to OpenAI output_index + ToolArgumentBuffers map[int]string // Maps output_index to accumulated tool argument JSON + ItemIDs map[int]string // Maps output_index to item ID for stable IDs + ToolCallIDs map[int]string // Maps output_index to tool call ID (callID) + ToolCallNames map[int]string // Maps output_index to tool call name + CurrentOutputIndex int // Current output index counter + MessageID *string // Message ID (generated) + Model *string // Model name + CreatedAt int // Timestamp for created_at consistency + HasEmittedCreated bool // Whether we've emitted response.created + HasEmittedInProgress bool // Whether we've emitted response.in_progress + TextItemClosed bool // Whether text item has been closed +} + +// bedrockResponsesStreamStatePool provides a pool for Bedrock responses stream state objects. +var bedrockResponsesStreamStatePool = sync.Pool{ + New: func() interface{} { + return &BedrockResponsesStreamState{ + ContentIndexToOutputIndex: make(map[int]int), + ToolArgumentBuffers: make(map[int]string), + ItemIDs: make(map[int]string), + ToolCallIDs: make(map[int]string), + ToolCallNames: make(map[int]string), + CurrentOutputIndex: 0, + CreatedAt: int(time.Now().Unix()), + HasEmittedCreated: false, + HasEmittedInProgress: false, + TextItemClosed: false, + } + }, +} + +// acquireBedrockResponsesStreamState gets a Bedrock responses stream state from the pool. +func acquireBedrockResponsesStreamState() *BedrockResponsesStreamState { + state := bedrockResponsesStreamStatePool.Get().(*BedrockResponsesStreamState) + // Clear maps (they're already initialized from New or previous flush) + // Only initialize if nil (shouldn't happen, but defensive) + if state.ContentIndexToOutputIndex == nil { + state.ContentIndexToOutputIndex = make(map[int]int) + } else { + clear(state.ContentIndexToOutputIndex) + } + if state.ToolArgumentBuffers == nil { + state.ToolArgumentBuffers = make(map[int]string) + } else { + clear(state.ToolArgumentBuffers) + } + if state.ItemIDs == nil { + state.ItemIDs = make(map[int]string) + } else { + clear(state.ItemIDs) + } + if state.ToolCallIDs == nil { + state.ToolCallIDs = make(map[int]string) + } else { + clear(state.ToolCallIDs) + } + if state.ToolCallNames == nil { + state.ToolCallNames = make(map[int]string) + } else { + clear(state.ToolCallNames) + } + // Reset other fields + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + state.TextItemClosed = false + return state +} + +// releaseBedrockResponsesStreamState returns a Bedrock responses stream state to the pool. +func releaseBedrockResponsesStreamState(state *BedrockResponsesStreamState) { + if state != nil { + state.flush() // Clean before returning to pool + bedrockResponsesStreamStatePool.Put(state) + } +} + +func (state *BedrockResponsesStreamState) flush() { + // Clear maps (reuse if already initialized, otherwise initialize) + if state.ContentIndexToOutputIndex == nil { + state.ContentIndexToOutputIndex = make(map[int]int) + } else { + clear(state.ContentIndexToOutputIndex) + } + if state.ToolArgumentBuffers == nil { + state.ToolArgumentBuffers = make(map[int]string) + } else { + clear(state.ToolArgumentBuffers) + } + if state.ItemIDs == nil { + state.ItemIDs = make(map[int]string) + } else { + clear(state.ItemIDs) + } + if state.ToolCallIDs == nil { + state.ToolCallIDs = make(map[int]string) + } else { + clear(state.ToolCallIDs) + } + if state.ToolCallNames == nil { + state.ToolCallNames = make(map[int]string) + } else { + clear(state.ToolCallNames) + } + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + state.TextItemClosed = false +} + +// ToBedrockResponsesRequest converts a BifrostRequest (Responses structure) back to BedrockConverseRequest +func ToBedrockResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) (*BedrockConverseRequest, error) { + if bifrostReq == nil { + return nil, fmt.Errorf("bifrost request is nil") + } + + bedrockReq := &BedrockConverseRequest{ + ModelID: bifrostReq.Model, + } + + // map bifrost messages to bedrock messages + if bifrostReq.Input != nil { + messages, systemMessages, err := convertResponsesItemsToBedrockMessages(bifrostReq.Input) + if err != nil { + return nil, fmt.Errorf("failed to convert Responses messages: %w", err) + } + bedrockReq.Messages = messages + if len(systemMessages) > 0 { + bedrockReq.System = systemMessages + } + } + + // Map basic parameters to inference config + if bifrostReq.Params != nil { + inferenceConfig := &BedrockInferenceConfig{} + + if bifrostReq.Params.MaxOutputTokens != nil { + inferenceConfig.MaxTokens = bifrostReq.Params.MaxOutputTokens + } + if bifrostReq.Params.Temperature != nil { + inferenceConfig.Temperature = bifrostReq.Params.Temperature + } + if bifrostReq.Params.TopP != nil { + inferenceConfig.TopP = bifrostReq.Params.TopP + } + if bifrostReq.Params.ExtraParams != nil { + if stop, ok := schemas.SafeExtractStringSlice(bifrostReq.Params.ExtraParams["stop"]); ok { + inferenceConfig.StopSequences = stop + } + } + + bedrockReq.InferenceConfig = inferenceConfig + } + + // Convert tools + if bifrostReq.Params != nil && bifrostReq.Params.Tools != nil { + var bedrockTools []BedrockTool + for _, tool := range bifrostReq.Params.Tools { + if tool.ResponsesToolFunction != nil { + // Create the complete schema object that Bedrock expects + var schemaObject interface{} + if tool.ResponsesToolFunction.Parameters != nil { + schemaObject = tool.ResponsesToolFunction.Parameters + } else { + // Fallback to empty object schema if no parameters + schemaObject = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } + } + + if tool.Name == nil || *tool.Name == "" { + return nil, fmt.Errorf("responses tool is missing required name for Bedrock function conversion") + } + name := *tool.Name + + // Use the tool description if available, otherwise use a generic description + description := "Function tool" + if tool.Description != nil { + description = *tool.Description + } + + bedrockTool := BedrockTool{ + ToolSpec: &BedrockToolSpec{ + Name: name, + Description: &description, + InputSchema: BedrockToolInputSchema{ + JSON: schemaObject, + }, + }, + } + bedrockTools = append(bedrockTools, bedrockTool) + } + } + + if len(bedrockTools) > 0 { + bedrockReq.ToolConfig = &BedrockToolConfig{ + Tools: bedrockTools, + } + } + } + + // Convert tool choice + if bifrostReq.Params != nil && bifrostReq.Params.ToolChoice != nil { + bedrockToolChoice := convertResponsesToolChoice(*bifrostReq.Params.ToolChoice) + if bedrockToolChoice != nil { + if bedrockReq.ToolConfig == nil { + bedrockReq.ToolConfig = &BedrockToolConfig{} + } + bedrockReq.ToolConfig.ToolChoice = bedrockToolChoice + } + } + + // Ensure tool config is present when tool content exists (similar to Chat Completions) + ensureResponsesToolConfigForConversation(bifrostReq, bedrockReq) + + return bedrockReq, nil +} + +// ensureResponsesToolConfigForConversation ensures toolConfig is present when tool content exists +func ensureResponsesToolConfigForConversation(bifrostReq *schemas.BifrostResponsesRequest, bedrockReq *BedrockConverseRequest) { + if bedrockReq.ToolConfig != nil { + return // Already has tool config + } + + hasToolContent, tools := extractToolsFromResponsesConversationHistory(bifrostReq.Input) + if hasToolContent && len(tools) > 0 { + bedrockReq.ToolConfig = &BedrockToolConfig{Tools: tools} + } +} + +// extractToolsFromResponsesConversationHistory extracts tools from Responses conversation history +func extractToolsFromResponsesConversationHistory(messages []schemas.ResponsesMessage) (bool, []BedrockTool) { + var hasToolContent bool + toolMap := make(map[string]*schemas.ResponsesTool) // Use map to deduplicate by name + + for _, msg := range messages { + // Check if message contains tool use or tool result + if msg.Type != nil { + switch *msg.Type { + case schemas.ResponsesMessageTypeFunctionCall, schemas.ResponsesMessageTypeFunctionCallOutput: + hasToolContent = true + // Try to infer tool definition from tool call/result + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Name != nil { + toolName := *msg.ResponsesToolMessage.Name + if _, exists := toolMap[toolName]; !exists { + // Create a minimal tool definition + toolMap[toolName] = &schemas.ResponsesTool{ + Type: "function", + Name: &toolName, + ResponsesToolFunction: &schemas.ResponsesToolFunction{ + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{}, + }, + }, + } + } + } + } + } + } + + // Convert map to slice + var tools []BedrockTool + for _, tool := range toolMap { + if tool.Name != nil && tool.ResponsesToolFunction != nil { + schemaObject := tool.ResponsesToolFunction.Parameters + if schemaObject == nil { + schemaObject = &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{}, + } + } + + description := "Function tool" + if tool.Description != nil { + description = *tool.Description + } + + bedrockTool := BedrockTool{ + ToolSpec: &BedrockToolSpec{ + Name: *tool.Name, + Description: &description, + InputSchema: BedrockToolInputSchema{ + JSON: schemaObject, + }, + }, + } + tools = append(tools, bedrockTool) + } + } + + return hasToolContent, tools +} + +// ToBifrostResponsesResponse converts BedrockConverseResponse to BifrostResponsesResponse +func (response *BedrockConverseResponse) ToBifrostResponsesResponse() (*schemas.BifrostResponsesResponse, error) { + if response == nil { + return nil, fmt.Errorf("bedrock response is nil") + } + + bifrostResp := &schemas.BifrostResponsesResponse{ + CreatedAt: int(time.Now().Unix()), + } + + if response.Usage != nil { + // Convert usage information + bifrostResp.Usage = &schemas.ResponsesResponseUsage{ + InputTokens: response.Usage.InputTokens, + OutputTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.TotalTokens, + } + } + + // Convert output message to Responses format + if response.Output != nil && response.Output.Message != nil { + outputMessages := convertBedrockMessageToResponsesMessages(*response.Output.Message) + bifrostResp.Output = outputMessages + } + + return bifrostResp, nil +} + +// Helper functions + +func convertResponsesToolChoice(toolChoice schemas.ResponsesToolChoice) *BedrockToolChoice { + // Check if it's a string choice + if toolChoice.ResponsesToolChoiceStr != nil { + switch schemas.ResponsesToolChoiceType(*toolChoice.ResponsesToolChoiceStr) { + case schemas.ResponsesToolChoiceTypeAny, schemas.ResponsesToolChoiceTypeRequired: + return &BedrockToolChoice{ + Any: &BedrockToolChoiceAny{}, + } + case schemas.ResponsesToolChoiceTypeNone: + // Bedrock doesn't have explicit "none" - just don't include tools + return nil + } + } + + // Check if it's a struct choice + if toolChoice.ResponsesToolChoiceStruct != nil { + switch toolChoice.ResponsesToolChoiceStruct.Type { + case schemas.ResponsesToolChoiceTypeFunction: + // Extract the actual function name from the struct + if toolChoice.ResponsesToolChoiceStruct.Name != nil && *toolChoice.ResponsesToolChoiceStruct.Name != "" { + return &BedrockToolChoice{ + Tool: &BedrockToolChoiceTool{ + Name: *toolChoice.ResponsesToolChoiceStruct.Name, + }, + } + } + // If Name is nil or empty, return nil as we can't construct a valid tool choice + return nil + case schemas.ResponsesToolChoiceTypeAuto, schemas.ResponsesToolChoiceTypeAny, schemas.ResponsesToolChoiceTypeRequired: + return &BedrockToolChoice{ + Any: &BedrockToolChoiceAny{}, + } + case schemas.ResponsesToolChoiceTypeNone: + return nil + } + } + + return nil +} + +// convertResponsesItemsToBedrockMessages converts Responses items back to Bedrock messages +func convertResponsesItemsToBedrockMessages(messages []schemas.ResponsesMessage) ([]BedrockMessage, []BedrockSystemMessage, error) { + var bedrockMessages []BedrockMessage + var systemMessages []BedrockSystemMessage + + for _, msg := range messages { + // Handle Responses items + msgType := schemas.ResponsesMessageTypeMessage + if msg.Type != nil { + msgType = *msg.Type + } + switch msgType { + case schemas.ResponsesMessageTypeMessage: + // Check if Role is present, skip message if not + if msg.Role == nil { + continue + } + + // Extract role from the Responses message structure + role := *msg.Role + + if role == schemas.ResponsesInputMessageRoleSystem { + // Convert to system message + // Ensure Content and ContentStr are present + if msg.Content != nil { + if msg.Content.ContentStr != nil { + systemMessages = append(systemMessages, BedrockSystemMessage{ + Text: msg.Content.ContentStr, + }) + } else if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + systemMessages = append(systemMessages, BedrockSystemMessage{ + Text: block.Text, + }) + } + } + } + } + // Skip system messages with no content + } else { + // Convert regular message + // Ensure Content is present + if msg.Content == nil { + // Skip messages without content or create with empty content + continue + } + + bedrockMsg := BedrockMessage{ + Role: BedrockMessageRole(role), + } + + // Convert content + contentBlocks, err := convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(*msg.Content) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert content blocks: %w", err) + } + bedrockMsg.Content = contentBlocks + + bedrockMessages = append(bedrockMessages, bedrockMsg) + } + + case schemas.ResponsesMessageTypeFunctionCall: + // Handle function calls from Responses + if msg.ResponsesToolMessage != nil { + // Create tool use content block + var toolUseID string + if msg.ResponsesToolMessage.CallID != nil { + toolUseID = *msg.ResponsesToolMessage.CallID + } + + // Get function name from ToolMessage + var functionName string + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Name != nil { + functionName = *msg.ResponsesToolMessage.Name + } + + // Parse JSON arguments into interface{} + var input interface{} = map[string]interface{}{} + if msg.ResponsesToolMessage.Arguments != nil { + var parsedInput interface{} + if err := json.Unmarshal([]byte(*msg.ResponsesToolMessage.Arguments), &parsedInput); err != nil { + return nil, nil, fmt.Errorf("failed to parse tool arguments JSON: %w", err) + } + input = parsedInput + } + + toolUseBlock := BedrockContentBlock{ + ToolUse: &BedrockToolUse{ + ToolUseID: toolUseID, + Name: functionName, + Input: input, + }, + } + + // Create assistant message with tool use + assistantMsg := BedrockMessage{ + Role: BedrockMessageRoleAssistant, + Content: []BedrockContentBlock{toolUseBlock}, + } + bedrockMessages = append(bedrockMessages, assistantMsg) + + } + + case schemas.ResponsesMessageTypeFunctionCallOutput: + // Handle function call outputs from Responses + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Output != nil && msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + var toolUseID string + if msg.ResponsesToolMessage.CallID != nil { + toolUseID = *msg.ResponsesToolMessage.CallID + } + toolResultBlock := BedrockContentBlock{ + ToolResult: &BedrockToolResult{ + ToolUseID: toolUseID, + }, + } + // Set content based on available data + if msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + raw := *msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr + var parsed interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err == nil { + toolResultBlock.ToolResult.Content = []BedrockContentBlock{ + {JSON: parsed}, + } + } else { + toolResultBlock.ToolResult.Content = []BedrockContentBlock{ + {Text: &raw}, + } + } + } else if msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks != nil { + toolResultContent, err := convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(schemas.ResponsesMessageContent{ + ContentBlocks: msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert tool result content blocks: %w", err) + } + toolResultBlock.ToolResult.Content = toolResultContent + } + + // Create user message with tool result + userMsg := BedrockMessage{ + Role: BedrockMessageRoleUser, + Content: []BedrockContentBlock{toolResultBlock}, + } + bedrockMessages = append(bedrockMessages, userMsg) + } + } + } + + return bedrockMessages, systemMessages, nil +} + +// convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks converts Bifrost content to Bedrock content blocks +func convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(content schemas.ResponsesMessageContent) ([]BedrockContentBlock, error) { + var blocks []BedrockContentBlock + + if content.ContentStr != nil { + blocks = append(blocks, BedrockContentBlock{ + Text: content.ContentStr, + }) + } else if content.ContentBlocks != nil { + for _, block := range content.ContentBlocks { + + bedrockBlock := BedrockContentBlock{} + + switch block.Type { + case schemas.ResponsesInputMessageContentBlockTypeText, schemas.ResponsesOutputMessageContentTypeText: + bedrockBlock.Text = block.Text + case schemas.ResponsesInputMessageContentBlockTypeImage: + if block.ResponsesInputMessageContentBlockImage != nil && block.ResponsesInputMessageContentBlockImage.ImageURL != nil { + imageSource, err := convertImageToBedrockSource(*block.ResponsesInputMessageContentBlockImage.ImageURL) + if err != nil { + return nil, fmt.Errorf("failed to convert image in responses content block: %w", err) + } + bedrockBlock.Image = imageSource + } + default: + // Don't add anything + } + + blocks = append(blocks, bedrockBlock) + } + } + + return blocks, nil +} + +// convertBedrockMessageToResponsesMessages converts Bedrock message to ChatMessage output format +func convertBedrockMessageToResponsesMessages(bedrockMsg BedrockMessage) []schemas.ResponsesMessage { + var outputMessages []schemas.ResponsesMessage + + for _, block := range bedrockMsg.Content { + if block.Text != nil { + // Text content + outputMessages = append(outputMessages, schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: block.Text, + }, + }, + }, + }) + } else if block.ToolUse != nil { + // Tool use content + // Create copies of the values to avoid range loop variable capture + toolUseID := block.ToolUse.ToolUseID + toolUseName := block.ToolUse.Name + + toolMsg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolUseID, + Name: &toolUseName, + Arguments: schemas.Ptr(schemas.JsonifyInput(block.ToolUse.Input)), + }, + } + outputMessages = append(outputMessages, toolMsg) + } else if block.ToolResult != nil { + // Tool result content - typically not in assistant output but handled for completeness + // Prefer JSON payloads without unmarshalling; fallback to text + var resultContent string + if len(block.ToolResult.Content) > 0 { + // JSON first (no unmarshal; just one marshal to string when present) + for _, c := range block.ToolResult.Content { + if c.JSON != nil { + resultContent = schemas.JsonifyInput(c.JSON) + break + } + } + // Fallback to first available text block + if resultContent == "" { + for _, c := range block.ToolResult.Content { + if c.Text != nil { + resultContent = *c.Text + break + } + } + } + } + + // Create a copy of the value to avoid range loop variable capture + toolResultID := block.ToolResult.ToolUseID + + resultMsg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: &resultContent, + }, + }, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolResultID, + Output: &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: &resultContent, + }, + }, + } + outputMessages = append(outputMessages, resultMsg) + } + } + + return outputMessages +} + +// ToBifrostResponsesStream converts a Bedrock stream event to a Bifrost Responses Stream response +// Returns a slice of responses to support cases where a single event produces multiple responses +func (chunk *BedrockStreamEvent) ToBifrostResponsesStream(sequenceNumber int, state *BedrockResponsesStreamState) ([]*schemas.BifrostResponsesStreamResponse, *schemas.BifrostError, bool) { + switch { + case chunk.Role != nil: + // Message start - emit response.created and response.in_progress (OpenAI-style lifecycle) + var responses []*schemas.BifrostResponsesStreamResponse + + // Generate message ID if not already set + if state.MessageID == nil { + messageID := fmt.Sprintf("msg_%d", state.CreatedAt) + state.MessageID = &messageID + } + + // Emit response.created + if !state.HasEmittedCreated { + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + } + if state.Model != nil { + // Note: Model field doesn't exist in BifrostResponsesResponse schema + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeCreated, + SequenceNumber: sequenceNumber, + Response: response, + }) + state.HasEmittedCreated = true + } + + // Emit response.in_progress + if !state.HasEmittedInProgress { + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, // Use same timestamp + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeInProgress, + SequenceNumber: sequenceNumber + len(responses), + Response: response, + }) + state.HasEmittedInProgress = true + } + + // Emit output_item.added for text message + outputIndex := 0 + state.ContentIndexToOutputIndex[0] = outputIndex // Text is at content index 0 + + // Generate stable ID for text item + var itemID string + if state.MessageID == nil { + itemID = fmt.Sprintf("item_%d", outputIndex) + } else { + itemID = fmt.Sprintf("msg_%s_item_%d", *state.MessageID, outputIndex) + } + state.ItemIDs[outputIndex] = itemID + + messageType := schemas.ResponsesMessageTypeMessage + role := schemas.ResponsesInputMessageRoleAssistant + + item := &schemas.ResponsesMessage{ + ID: &itemID, + Type: &messageType, + Role: &role, + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{}, // Empty blocks slice for mutation support + }, + } + + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), + Item: item, + }) + + if len(responses) > 0 { + return responses, nil, false + } + + case chunk.Start != nil: + // Handle content block start (text content or tool use) + contentBlockIndex := 0 + if chunk.ContentBlockIndex != nil { + contentBlockIndex = *chunk.ContentBlockIndex + } + + // Check if this is a tool use start + if chunk.Start.ToolUse != nil { + // Close text item if it's still open + var responses []*schemas.BifrostResponsesStreamResponse + if !state.TextItemClosed { + outputIndex := 0 + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), + Item: doneItem, + }) + state.TextItemClosed = true + } + + // This is a function call starting - use output_index 1 + outputIndex := 1 + state.ContentIndexToOutputIndex[contentBlockIndex] = outputIndex + state.CurrentOutputIndex = 2 // Next available index + + // Store tool use ID as item ID and call ID + toolUseID := chunk.Start.ToolUse.ToolUseID + toolName := chunk.Start.ToolUse.Name + state.ItemIDs[outputIndex] = toolUseID + state.ToolCallIDs[outputIndex] = toolUseID + state.ToolCallNames[outputIndex] = toolName + + statusInProgress := "in_progress" + item := &schemas.ResponsesMessage{ + ID: &toolUseID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: &statusInProgress, + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &toolUseID, + Name: &toolName, + Arguments: schemas.Ptr(""), // Arguments will be filled by deltas + }, + } + + // Initialize argument buffer for this tool call + state.ToolArgumentBuffers[outputIndex] = "" + + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(contentBlockIndex), + Item: item, + }) + + return responses, nil, false + } + // Text content start is handled by Role event, so we can ignore Start for text + + case chunk.ContentBlockIndex != nil && chunk.Delta != nil: + // Handle contentBlockDelta event + contentBlockIndex := *chunk.ContentBlockIndex + outputIndex, exists := state.ContentIndexToOutputIndex[contentBlockIndex] + if !exists { + // Default to 0 for text if not mapped + outputIndex = 0 + state.ContentIndexToOutputIndex[contentBlockIndex] = outputIndex + } + + switch { + case chunk.Delta.Text != nil: + // Handle text delta + text := *chunk.Delta.Text + if text != "" { + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputTextDelta, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: &contentBlockIndex, + Delta: &text, + } + if itemID != "" { + response.ItemID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{response}, nil, false + } + + case chunk.Delta.ToolUse != nil: + // Handle tool use delta - function call arguments + toolUseDelta := chunk.Delta.ToolUse + + if toolUseDelta.Input != "" { + // Accumulate argument deltas + state.ToolArgumentBuffers[outputIndex] += toolUseDelta.Input + + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: &contentBlockIndex, + Delta: &toolUseDelta.Input, + } + if itemID != "" { + response.ItemID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{response}, nil, false + } + } + + case chunk.StopReason != nil: + // Stop reason - don't use it to close items, just return nil + // Items should be closed explicitly when content blocks end + return nil, nil, false + } + + return nil, nil, false +} + +// FinalizeBedrockStream finalizes the stream by closing any open items and emitting completed event +func FinalizeBedrockStream(state *BedrockResponsesStreamState, sequenceNumber int, usage *schemas.ResponsesResponseUsage) []*schemas.BifrostResponsesStreamResponse { + var responses []*schemas.BifrostResponsesStreamResponse + + // Close text item if still open + if !state.TextItemClosed { + outputIndex := 0 + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), + Item: doneItem, + }) + state.TextItemClosed = true + } + + // Close any open tool call items and emit function_call_arguments.done + for outputIndex, args := range state.ToolArgumentBuffers { + if args != "" { + itemID := state.ItemIDs[outputIndex] + callID := state.ToolCallIDs[outputIndex] + toolName := state.ToolCallNames[outputIndex] + + // Create item with tool message info for the done event + var doneItem *schemas.ResponsesMessage + if callID != "" || toolName != "" { + doneItem = &schemas.ResponsesMessage{ + ResponsesToolMessage: &schemas.ResponsesToolMessage{}, + } + if callID != "" { + doneItem.ResponsesToolMessage.CallID = &callID + } + if toolName != "" { + doneItem.ResponsesToolMessage.Name = &toolName + } + } + + // Emit function_call_arguments.done with full arguments + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDone, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + Arguments: &args, + } + if itemID != "" { + response.ItemID = &itemID + } + if doneItem != nil { + response.Item = doneItem + } + responses = append(responses, response) + + // Emit output_item.done for function call + statusCompleted := "completed" + outputItemDone := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + outputItemDone.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + Item: outputItemDone, + }) + } + } + + // Emit response.completed + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + Usage: usage, + } + + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeCompleted, + SequenceNumber: sequenceNumber + len(responses), + Response: response, + }) + + return responses +} diff --git a/core/providers/bedrock/signer.go b/core/providers/bedrock/signer.go new file mode 100644 index 000000000..9f12e3bba --- /dev/null +++ b/core/providers/bedrock/signer.go @@ -0,0 +1,434 @@ +package bedrock + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/smithy-go/encoding/httpbinding" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +const ( + signingAlgorithm = "AWS4-HMAC-SHA256" + amzDateKey = "X-Amz-Date" + amzSecurityToken = "X-Amz-Security-Token" + timeFormat = "20060102T150405Z" + shortTimeFormat = "20060102" +) + +// Headers to ignore during signing +var ignoredHeaders = map[string]struct{}{ + "authorization": {}, + "user-agent": {}, + "x-amzn-trace-id": {}, + "expect": {}, + "transfer-encoding": {}, +} + +// signingKeyCache caches derived signing keys to avoid recomputation +type signingKeyCache struct { + cache map[string]cachedKey + mu sync.RWMutex +} + +type cachedKey struct { + key []byte + date string // YYYYMMDD format + accessKey string +} + +var keyCache = &signingKeyCache{ + cache: make(map[string]cachedKey), +} + +// hmacSHA256 computes HMAC-SHA256 +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +// deriveSigningKey derives the AWS signing key +func deriveSigningKey(secret, dateStamp, region, service string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secret), []byte(dateStamp)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(service)) + kSigning := hmacSHA256(kService, []byte("aws4_request")) + return kSigning +} + +// getSigningKey retrieves or computes the signing key with caching +func getSigningKey(accessKey, secretKey, dateStamp, region, service string) []byte { + cacheKey := fmt.Sprintf("%s/%s/%s/%s", accessKey, dateStamp, region, service) + + keyCache.mu.RLock() + if cached, ok := keyCache.cache[cacheKey]; ok && cached.accessKey == accessKey && cached.date == dateStamp { + keyCache.mu.RUnlock() + return cached.key + } + keyCache.mu.RUnlock() + + keyCache.mu.Lock() + defer keyCache.mu.Unlock() + + // Double-check after acquiring write lock + if cached, ok := keyCache.cache[cacheKey]; ok && cached.accessKey == accessKey && cached.date == dateStamp { + return cached.key + } + + key := deriveSigningKey(secretKey, dateStamp, region, service) + keyCache.cache[cacheKey] = cachedKey{ + key: key, + date: dateStamp, + accessKey: accessKey, + } + + return key +} + +// stripExcessSpaces removes excess spaces from a string +func stripExcessSpaces(str string) string { + str = strings.TrimSpace(str) + if !strings.Contains(str, " ") { + return str + } + + var result strings.Builder + result.Grow(len(str)) + prevWasSpace := false + + for _, ch := range str { + if ch == ' ' { + if !prevWasSpace { + result.WriteRune(ch) + } + prevWasSpace = true + } else { + result.WriteRune(ch) + prevWasSpace = false + } + } + + return result.String() +} + +// percentEncodeRFC3986 encodes a string per RFC 3986 +// Keep unreserved characters (A-Z, a-z, 0-9, -, _, ., ~) as-is +// Percent-encode everything else as %HH using uppercase hex +func percentEncodeRFC3986(s string) string { + var result strings.Builder + result.Grow(len(s)) + + for i := 0; i < len(s); i++ { + b := s[i] + // RFC 3986 unreserved characters + if (b >= 'A' && b <= 'Z') || + (b >= 'a' && b <= 'z') || + (b >= '0' && b <= '9') || + b == '-' || b == '_' || b == '.' || b == '~' { + result.WriteByte(b) + } else { + // Percent-encode with uppercase hex + result.WriteByte('%') + result.WriteByte(uppercaseHex(b >> 4)) + result.WriteByte(uppercaseHex(b & 0x0F)) + } + } + + return result.String() +} + +// uppercaseHex returns the uppercase hex character for a nibble (0-15) +func uppercaseHex(b byte) byte { + if b < 10 { + return '0' + b + } + return 'A' + (b - 10) +} + +// percentDecode decodes percent-encoded sequences in a string without treating + as space +// This differs from url.QueryUnescape which uses form encoding (+ becomes space) +func percentDecode(s string) string { + // Quick check if there are any percent signs + if !strings.Contains(s, "%") { + return s + } + + var result strings.Builder + result.Grow(len(s)) + + for i := 0; i < len(s); { + if s[i] == '%' && i+2 < len(s) { + // Try to decode the hex sequence + if h1 := unhex(s[i+1]); h1 >= 0 { + if h2 := unhex(s[i+2]); h2 >= 0 { + result.WriteByte(byte(h1<<4 | h2)) + i += 3 + continue + } + } + } + result.WriteByte(s[i]) + i++ + } + + return result.String() +} + +// unhex converts a hex character to its value, or -1 if not a hex char +func unhex(c byte) int { + switch { + case '0' <= c && c <= '9': + return int(c - '0') + case 'a' <= c && c <= 'f': + return int(c - 'a' + 10) + case 'A' <= c && c <= 'F': + return int(c - 'A' + 10) + } + return -1 +} + +// queryPair represents a query parameter name-value pair +type queryPair struct { + encodedName string + encodedValue string +} + +// buildCanonicalQueryString builds a canonical query string per AWS SigV4 spec +// using proper RFC 3986 percent-encoding +func buildCanonicalQueryString(queryString string) string { + if queryString == "" { + return "" + } + + // Split the raw query string on '&' into pairs + rawPairs := strings.Split(queryString, "&") + pairs := make([]queryPair, 0, len(rawPairs)) + + for _, rawPair := range rawPairs { + if rawPair == "" { + continue + } + + // Split on the first '=' to get name and value + var name, value string + if idx := strings.IndexByte(rawPair, '='); idx >= 0 { + name = rawPair[:idx] + value = rawPair[idx+1:] + } else { + // No '=' means name only, empty value + name = rawPair + value = "" + } + + // Decode percent-encoded sequences first to normalize (handles already-encoded values) + // then encode per RFC 3986 to ensure consistent encoding + // Note: We use percentDecode instead of url.QueryUnescape because the latter + // treats + as space (form encoding), but we need + to encode as %2B + decodedName := percentDecode(name) + decodedValue := percentDecode(value) + + // Percent-encode name and value per RFC 3986 + encodedName := percentEncodeRFC3986(decodedName) + encodedValue := percentEncodeRFC3986(decodedValue) + + pairs = append(pairs, queryPair{ + encodedName: encodedName, + encodedValue: encodedValue, + }) + } + + // Sort pairs lexicographically by encoded name, then by encoded value + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].encodedName != pairs[j].encodedName { + return pairs[i].encodedName < pairs[j].encodedName + } + return pairs[i].encodedValue < pairs[j].encodedValue + }) + + // Join encoded pairs with '&' + var result strings.Builder + for i, pair := range pairs { + if i > 0 { + result.WriteByte('&') + } + result.WriteString(pair.encodedName) + result.WriteByte('=') + result.WriteString(pair.encodedValue) + } + + return result.String() +} + +// signAWSRequestFastHTTP signs a fasthttp request using AWS Signature Version 4 +// This is a native implementation that avoids allocating http.Request +func signAWSRequestFastHTTP( + ctx context.Context, + req *fasthttp.Request, + body []byte, + accessKey, secretKey string, + sessionToken *string, + region, service string, + providerName schemas.ModelProvider, +) *schemas.BifrostError { + // Get AWS credentials if not provided + if accessKey == "" && secretKey == "" { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return providerUtils.NewBifrostOperationError("failed to load aws config", err, providerName) + } + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return providerUtils.NewBifrostOperationError("failed to retrieve aws credentials", err, providerName) + } + accessKey = creds.AccessKeyID + secretKey = creds.SecretAccessKey + if creds.SessionToken != "" { + st := creds.SessionToken + sessionToken = &st + } + } + + // Get current time + now := time.Now().UTC() + amzDate := now.Format(timeFormat) + dateStamp := now.Format(shortTimeFormat) + + // Parse URI + uri := req.URI() + host := string(uri.Host()) + path := string(uri.Path()) + if path == "" { + path = "/" + } + queryString := string(uri.QueryString()) + + // Escape path for canonical URI (Bedrock doesn't disable escaping) + canonicalURI := httpbinding.EscapePath(path, false) + + // Calculate payload hash + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + // Set required headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set(amzDateKey, amzDate) + if sessionToken != nil && *sessionToken != "" { + req.Header.Set(amzSecurityToken, *sessionToken) + } + + // Build canonical headers + var headerNames []string + headerMap := make(map[string][]string) + + // Always include host + headerNames = append(headerNames, "host") + headerMap["host"] = []string{host} + + // Include content-length if body is present + if cl := req.Header.ContentLength(); cl >= 0 { + headerNames = append(headerNames, "content-length") + headerMap["content-length"] = []string{strconv.Itoa(cl)} + } + + // Collect other headers + for key, value := range req.Header.All() { + keyStr := strings.ToLower(string(key)) + + // Skip ignored headers + if _, ignore := ignoredHeaders[keyStr]; ignore { + continue + } + + // Skip if already handled + if keyStr == "host" || keyStr == "content-length" { + continue + } + + if _, exists := headerMap[keyStr]; !exists { + headerNames = append(headerNames, keyStr) + } + headerMap[keyStr] = append(headerMap[keyStr], string(value)) + } + + // Sort header names + sort.Strings(headerNames) + + // Build canonical headers string + var canonicalHeaders strings.Builder + for _, name := range headerNames { + canonicalHeaders.WriteString(name) + canonicalHeaders.WriteRune(':') + + values := headerMap[name] + for i, v := range values { + cleanedValue := stripExcessSpaces(v) + canonicalHeaders.WriteString(cleanedValue) + if i < len(values)-1 { + canonicalHeaders.WriteRune(',') + } + } + canonicalHeaders.WriteRune('\n') + } + + signedHeaders := strings.Join(headerNames, ";") + + // Build canonical query string using RFC 3986 encoding + canonicalQueryString := buildCanonicalQueryString(queryString) + + // Build canonical request + canonicalRequest := strings.Join([]string{ + string(req.Header.Method()), + canonicalURI, + canonicalQueryString, + canonicalHeaders.String(), + signedHeaders, + payloadHash, + }, "\n") + + // Build credential scope + credentialScope := strings.Join([]string{ + dateStamp, + region, + service, + "aws4_request", + }, "/") + + // Build string to sign + canonicalRequestHash := sha256.Sum256([]byte(canonicalRequest)) + stringToSign := strings.Join([]string{ + signingAlgorithm, + amzDate, + credentialScope, + hex.EncodeToString(canonicalRequestHash[:]), + }, "\n") + + // Calculate signature + signingKey := getSigningKey(accessKey, secretKey, dateStamp, region, service) + signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) + + // Build authorization header + authHeader := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + signingAlgorithm, + accessKey, + credentialScope, + signedHeaders, + signature, + ) + + req.Header.Set("Authorization", authHeader) + + return nil +} diff --git a/core/providers/bedrock/text.go b/core/providers/bedrock/text.go new file mode 100644 index 000000000..b2fbc1b9c --- /dev/null +++ b/core/providers/bedrock/text.go @@ -0,0 +1,109 @@ +package bedrock + +import ( + "strings" + + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBedrockTextCompletionRequest converts a Bifrost text completion request to Bedrock format +func ToBedrockTextCompletionRequest(bifrostReq *schemas.BifrostTextCompletionRequest) *BedrockTextCompletionRequest { + if bifrostReq == nil || (bifrostReq.Input.PromptStr == nil && len(bifrostReq.Input.PromptArray) == 0) { + return nil + } + + // Extract the raw prompt from bifrostReq + prompt := "" + if bifrostReq.Input != nil { + if bifrostReq.Input.PromptStr != nil { + prompt = *bifrostReq.Input.PromptStr + } else if len(bifrostReq.Input.PromptArray) > 0 && bifrostReq.Input.PromptArray != nil { + prompt = strings.Join(bifrostReq.Input.PromptArray, "\n\n") + } + } + + bedrockReq := &BedrockTextCompletionRequest{ + Prompt: prompt, + } + + // Apply parameters + if bifrostReq.Params != nil { + bedrockReq.Temperature = bifrostReq.Params.Temperature + bedrockReq.TopP = bifrostReq.Params.TopP + + if bifrostReq.Params.ExtraParams != nil { + if topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]); ok { + bedrockReq.TopK = topK + } + } + } + + // Apply model-specific formatting and field naming + if strings.Contains(bifrostReq.Model, "anthropic.") || strings.Contains(bifrostReq.Model, "claude") { + // For Claude models, wrap the prompt in Anthropic format and use Anthropic field names + anthropicReq := anthropic.ToAnthropicTextCompletionRequest(bifrostReq) + bedrockReq.Prompt = anthropicReq.Prompt + bedrockReq.MaxTokensToSample = &anthropicReq.MaxTokensToSample + bedrockReq.StopSequences = anthropicReq.StopSequences + } else { + // For other models, use standard field names with raw prompt + if bifrostReq.Params != nil { + bedrockReq.MaxTokens = bifrostReq.Params.MaxTokens + bedrockReq.Stop = bifrostReq.Params.Stop + } + } + + return bedrockReq +} + +// ToBifrostTextCompletionResponse converts a Bedrock Anthropic text response to Bifrost format +func (response *BedrockAnthropicTextResponse) ToBifrostTextCompletionResponse() *schemas.BifrostTextCompletionResponse { + if response == nil { + return nil + } + + return &schemas.BifrostTextCompletionResponse{ + Object: "text_completion", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{ + Text: &response.Completion, + }, + FinishReason: &response.StopReason, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.TextCompletionRequest, + Provider: schemas.Bedrock, + }, + } +} + +// ToBifrostTextCompletionResponse converts a Bedrock Mistral text response to Bifrost format +func (response *BedrockMistralTextResponse) ToBifrostTextCompletionResponse() *schemas.BifrostTextCompletionResponse { + if response == nil { + return nil + } + + var choices []schemas.BifrostResponseChoice + for i, output := range response.Outputs { + choices = append(choices, schemas.BifrostResponseChoice{ + Index: i, + TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{ + Text: &output.Text, + }, + FinishReason: &output.StopReason, + }) + } + + return &schemas.BifrostTextCompletionResponse{ + Object: "text_completion", + Choices: choices, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.TextCompletionRequest, + Provider: schemas.Bedrock, + }, + } +} diff --git a/core/providers/bedrock/types.go b/core/providers/bedrock/types.go new file mode 100644 index 000000000..0b4243394 --- /dev/null +++ b/core/providers/bedrock/types.go @@ -0,0 +1,458 @@ +package bedrock + +// DefaultBedrockRegion is the default region for Bedrock +const DefaultBedrockRegion = "us-east-1" + +// ==================== REQUEST TYPES ==================== + +// BedrockTextCompletionRequest represents a Bedrock text completion request +// Combines both Anthropic-style and standard completion parameters +type BedrockTextCompletionRequest struct { + // Required field + Prompt string `json:"prompt"` // The text prompt to complete + + // Token control parameters (both naming conventions supported) + MaxTokens *int `json:"max_tokens,omitempty"` // Maximum number of tokens to generate (standard format) + MaxTokensToSample *int `json:"max_tokens_to_sample,omitempty"` // Maximum number of tokens to generate (Anthropic format) + + // Sampling parameters + Temperature *float64 `json:"temperature,omitempty"` // Controls randomness in generation (0.0-1.0) + TopP *float64 `json:"top_p,omitempty"` // Nucleus sampling parameter (0.0-1.0) + TopK *int `json:"top_k,omitempty"` // Top-k sampling parameter + + // Stop sequences (both naming conventions supported) + Stop []string `json:"stop,omitempty"` // Stop sequences (standard format) + StopSequences []string `json:"stop_sequences,omitempty"` // Stop sequences (Anthropic format) +} + +// BedrockConverseRequest represents a Bedrock Converse API request +type BedrockConverseRequest struct { + ModelID string `json:"-"` // Model ID (sent in URL path, not body) + Messages []BedrockMessage `json:"messages,omitempty"` // Array of messages for the conversation + System []BedrockSystemMessage `json:"system,omitempty"` // System messages/prompts + InferenceConfig *BedrockInferenceConfig `json:"inferenceConfig,omitempty"` // Inference parameters + ToolConfig *BedrockToolConfig `json:"toolConfig,omitempty"` // Tool configuration + GuardrailConfig *BedrockGuardrailConfig `json:"guardrailConfig,omitempty"` // Guardrail configuration + AdditionalModelRequestFields map[string]interface{} `json:"additionalModelRequestFields,omitempty"` // Model-specific parameters (untyped) + AdditionalModelResponseFieldPaths []string `json:"additionalModelResponseFieldPaths,omitempty"` // Additional response field paths + PerformanceConfig *BedrockPerformanceConfig `json:"performanceConfig,omitempty"` // Performance configuration + PromptVariables map[string]BedrockPromptVariable `json:"promptVariables,omitempty"` // Prompt variables for prompt management + RequestMetadata map[string]string `json:"requestMetadata,omitempty"` // Request metadata +} + +type BedrockMessageRole string + +const ( + BedrockMessageRoleUser BedrockMessageRole = "user" + BedrockMessageRoleAssistant BedrockMessageRole = "assistant" +) + +// BedrockMessage represents a message in the conversation +type BedrockMessage struct { + Role BedrockMessageRole `json:"role"` // Required: "user" or "assistant" + Content []BedrockContentBlock `json:"content"` // Required: Array of content blocks +} + +// BedrockSystemMessage represents a system message +type BedrockSystemMessage struct { + Text *string `json:"text,omitempty"` // Text system message + GuardContent *BedrockGuardContent `json:"guardContent,omitempty"` // Guard content for guardrails +} + +// BedrockContentBlock represents a content block that can be text, image, document, toolUse, or toolResult +type BedrockContentBlock struct { + // Text content + Text *string `json:"text,omitempty"` + + // Image content + Image *BedrockImageSource `json:"image,omitempty"` + + // Document content + Document *BedrockDocumentSource `json:"document,omitempty"` + + // Tool use content + ToolUse *BedrockToolUse `json:"toolUse,omitempty"` + + // Tool result content + ToolResult *BedrockToolResult `json:"toolResult,omitempty"` + + // Guard content (for guardrails) + GuardContent *BedrockGuardContent `json:"guardContent,omitempty"` + + // For Tool Call Result content + JSON interface{} `json:"json,omitempty"` +} + +// BedrockImageSource represents image content +type BedrockImageSource struct { + Format string `json:"format"` // Required: Image format (png, jpeg, gif, webp) + Source BedrockImageSourceData `json:"source"` // Required: Image source data +} + +// BedrockImageSourceData represents the source of image data +type BedrockImageSourceData struct { + Bytes *string `json:"bytes,omitempty"` // Base64-encoded image bytes +} + +// BedrockDocumentSource represents document content +type BedrockDocumentSource struct { + Format string `json:"format"` // Required: Document format (pdf, csv, doc, docx, xls, xlsx, html, txt, md) + Name string `json:"name"` // Required: Document name + Source BedrockDocumentSourceData `json:"source"` // Required: Document source data +} + +// BedrockDocumentSourceData represents the source of document data +type BedrockDocumentSourceData struct { + Bytes *string `json:"bytes,omitempty"` // Base64-encoded document bytes +} + +// BedrockToolUse represents a tool use request +type BedrockToolUse struct { + ToolUseID string `json:"toolUseId"` // Required: Unique identifier for this tool use + Name string `json:"name"` // Required: Name of the tool to use + Input interface{} `json:"input"` // Required: Input parameters for the tool (JSON object) +} + +// BedrockToolResult represents the result of a tool use +type BedrockToolResult struct { + ToolUseID string `json:"toolUseId"` // Required: ID of the tool use this result corresponds to + Content []BedrockContentBlock `json:"content"` // Required: Content of the tool result + Status *string `json:"status,omitempty"` // Optional: Status of tool execution ("success" or "error") +} + +// BedrockGuardContent represents guard content for guardrails +type BedrockGuardContent struct { + Text *BedrockGuardContentText `json:"text,omitempty"` +} + +// BedrockGuardContentText represents text content for guardrails +type BedrockGuardContentText struct { + Text string `json:"text"` // Required: Text content + Qualifiers []BedrockContentQualifier `json:"qualifiers,omitempty"` // Optional: Content qualifiers +} + +// BedrockContentQualifier represents qualifiers for guard content +type BedrockContentQualifier string + +const ( + ContentQualifierGrounding BedrockContentQualifier = "grounding_source" + ContentQualifierSearchResult BedrockContentQualifier = "search_result" + ContentQualifierQuery BedrockContentQualifier = "query" +) + +// BedrockInferenceConfig represents inference configuration parameters +type BedrockInferenceConfig struct { + MaxTokens *int `json:"maxTokens,omitempty"` // Maximum number of tokens to generate + StopSequences []string `json:"stopSequences,omitempty"` // Sequences that will stop generation + Temperature *float64 `json:"temperature,omitempty"` // Sampling temperature (0.0 to 1.0) + TopP *float64 `json:"topP,omitempty"` // Top-p sampling parameter (0.0 to 1.0) +} + +// BedrockToolConfig represents tool configuration +type BedrockToolConfig struct { + Tools []BedrockTool `json:"tools,omitempty"` // Available tools + ToolChoice *BedrockToolChoice `json:"toolChoice,omitempty"` // Tool choice strategy +} + +// BedrockTool represents a tool definition +type BedrockTool struct { + ToolSpec *BedrockToolSpec `json:"toolSpec,omitempty"` // Tool specification +} + +// BedrockToolSpec represents the specification of a tool +type BedrockToolSpec struct { + Name string `json:"name"` // Required: Tool name + Description *string `json:"description,omitempty"` // Optional: Tool description + InputSchema BedrockToolInputSchema `json:"inputSchema"` // Required: JSON schema for tool input +} + +// BedrockToolInputSchema represents the input schema for a tool (union type) +type BedrockToolInputSchema struct { + JSON interface{} `json:"json,omitempty"` // The JSON schema for the tool +} + +// BedrockToolChoice represents tool choice configuration +type BedrockToolChoice struct { + // Union type - only one should be set + Auto *BedrockToolChoiceAuto `json:"auto,omitempty"` + Any *BedrockToolChoiceAny `json:"any,omitempty"` + Tool *BedrockToolChoiceTool `json:"tool,omitempty"` +} + +// BedrockToolChoiceAuto represents automatic tool choice +type BedrockToolChoiceAuto struct{} + +// BedrockToolChoiceAny represents any tool choice +type BedrockToolChoiceAny struct{} + +// BedrockToolChoiceTool represents specific tool choice +type BedrockToolChoiceTool struct { + Name string `json:"name"` // Required: Name of the specific tool to use +} + +// BedrockGuardrailConfig represents guardrail configuration +type BedrockGuardrailConfig struct { + GuardrailIdentifier string `json:"guardrailIdentifier"` // Required: Guardrail identifier + GuardrailVersion string `json:"guardrailVersion"` // Required: Guardrail version + Trace *string `json:"trace,omitempty"` // Optional: Trace level ("enabled" or "disabled") +} + +// BedrockPerformanceConfig represents performance configuration +type BedrockPerformanceConfig struct { + Latency *string `json:"latency,omitempty"` // Latency optimization ("standard" or "optimized") +} + +// BedrockPromptVariable represents a prompt variable +type BedrockPromptVariable struct { + Text *string `json:"text,omitempty"` // Text value for the variable +} + +// ==================== RESPONSE TYPES ==================== + +// BedrockAnthropicTextResponse represents the response structure from Bedrock's Anthropic text completion API. +// It includes the completion text and stop reason information. +type BedrockAnthropicTextResponse struct { + Completion string `json:"completion"` // Generated completion text + StopReason string `json:"stop_reason"` // Reason for completion termination + Stop string `json:"stop"` // Stop sequence that caused completion to stop +} + +// BedrockMistralTextResponse represents the response structure from Bedrock's Mistral text completion API. +// It includes multiple output choices with their text and stop reasons. +type BedrockMistralTextResponse struct { + Outputs []struct { + Text string `json:"text"` // Generated text + StopReason string `json:"stop_reason"` // Reason for completion termination + } `json:"outputs"` // Array of output choices +} + +// BedrockConverseResponse represents a Bedrock Converse API response +type BedrockConverseResponse struct { + Output *BedrockConverseOutput `json:"output"` // Required: Response output + StopReason string `json:"stopReason"` // Required: Reason for stopping + Usage *BedrockTokenUsage `json:"usage"` // Required: Token usage information + Metrics *BedrockConverseMetrics `json:"metrics"` // Required: Response metrics + AdditionalModelResponseFields map[string]interface{} `json:"additionalModelResponseFields,omitempty"` // Optional: Additional model-specific response fields + PerformanceConfig *BedrockPerformanceConfig `json:"performanceConfig,omitempty"` // Optional: Performance configuration used + Trace *BedrockConverseTrace `json:"trace,omitempty"` // Optional: Guardrail trace information +} + +// BedrockConverseOutput represents the output of a Converse request (union type) +type BedrockConverseOutput struct { + Message *BedrockMessage `json:"message,omitempty"` // Generated message (most common case) +} + +// BedrockTokenUsage represents token usage information +type BedrockTokenUsage struct { + InputTokens int `json:"inputTokens"` // Number of input tokens + OutputTokens int `json:"outputTokens"` // Number of output tokens + TotalTokens int `json:"totalTokens"` // Total number of tokens (input + output) +} + +// BedrockConverseMetrics represents response metrics +type BedrockConverseMetrics struct { + LatencyMs int64 `json:"latencyMs"` // Response latency in milliseconds +} + +// BedrockConverseTrace represents guardrail trace information +type BedrockConverseTrace struct { + Guardrail *BedrockGuardrailTrace `json:"guardrail,omitempty"` // Guardrail trace details +} + +// BedrockGuardrailTrace represents detailed guardrail trace information +type BedrockGuardrailTrace struct { + Action *string `json:"action,omitempty"` // Action taken by guardrail + InputAssessments []BedrockGuardrailAssessment `json:"inputAssessments,omitempty"` // Input assessments + OutputAssessments []BedrockGuardrailAssessment `json:"outputAssessments,omitempty"` // Output assessments + Trace *BedrockGuardrailTraceDetail `json:"trace,omitempty"` // Detailed trace information +} + +// BedrockGuardrailAssessment represents a guardrail assessment +type BedrockGuardrailAssessment struct { + TopicPolicy *BedrockGuardrailTopicPolicy `json:"topicPolicy,omitempty"` // Topic policy assessment + ContentPolicy *BedrockGuardrailContentPolicy `json:"contentPolicy,omitempty"` // Content policy assessment + WordPolicy *BedrockGuardrailWordPolicy `json:"wordPolicy,omitempty"` // Word policy assessment + SensitiveInfoPolicy *BedrockGuardrailSensitiveInfoPolicy `json:"sensitiveInfoPolicy,omitempty"` // Sensitive information policy assessment +} + +// BedrockGuardrailTopicPolicy represents topic policy assessment +type BedrockGuardrailTopicPolicy struct { + Topics []BedrockGuardrailTopic `json:"topics,omitempty"` // Topics identified +} + +// BedrockGuardrailTopic represents a topic identified by guardrail +type BedrockGuardrailTopic struct { + Name *string `json:"name,omitempty"` // Topic name + Type *string `json:"type,omitempty"` // Topic type + Action *string `json:"action,omitempty"` // Action taken +} + +// BedrockGuardrailContentPolicy represents content policy assessment +type BedrockGuardrailContentPolicy struct { + Filters []BedrockGuardrailContentFilter `json:"filters,omitempty"` // Content filters applied +} + +// BedrockGuardrailContentFilter represents a content filter +type BedrockGuardrailContentFilter struct { + Type *string `json:"type,omitempty"` // Filter type + Confidence *string `json:"confidence,omitempty"` // Confidence level + Action *string `json:"action,omitempty"` // Action taken +} + +// BedrockGuardrailWordPolicy represents word policy assessment +type BedrockGuardrailWordPolicy struct { + CustomWords []BedrockGuardrailCustomWord `json:"customWords,omitempty"` // Custom words detected + ManagedWordLists []BedrockGuardrailManagedWordList `json:"managedWordLists,omitempty"` // Managed word lists matched +} + +// BedrockGuardrailCustomWord represents a custom word detected +type BedrockGuardrailCustomWord struct { + Match *string `json:"match,omitempty"` // Matched word + Action *string `json:"action,omitempty"` // Action taken +} + +// BedrockGuardrailManagedWordList represents a managed word list match +type BedrockGuardrailManagedWordList struct { + Match *string `json:"match,omitempty"` // Matched word + Type *string `json:"type,omitempty"` // Word list type + Action *string `json:"action,omitempty"` // Action taken +} + +// BedrockGuardrailSensitiveInfoPolicy represents sensitive information policy assessment +type BedrockGuardrailSensitiveInfoPolicy struct { + PIIEntities []BedrockGuardrailPIIEntity `json:"piiEntities,omitempty"` // PII entities detected + Regexes []BedrockGuardrailRegex `json:"regexes,omitempty"` // Regex patterns matched +} + +// BedrockGuardrailPIIEntity represents a PII entity detected +type BedrockGuardrailPIIEntity struct { + Type *string `json:"type,omitempty"` // PII entity type + Match *string `json:"match,omitempty"` // Matched text + Action *string `json:"action,omitempty"` // Action taken +} + +// BedrockGuardrailRegex represents a regex pattern match +type BedrockGuardrailRegex struct { + Name *string `json:"name,omitempty"` // Regex name + Match *string `json:"match,omitempty"` // Matched text + Action *string `json:"action,omitempty"` // Action taken +} + +// BedrockGuardrailTraceDetail represents detailed guardrail trace +type BedrockGuardrailTraceDetail struct { + Trace *string `json:"trace,omitempty"` // Detailed trace information +} + +// ==================== ERROR TYPES ==================== + +// BedrockError represents a Bedrock API error response +type BedrockError struct { + Type string `json:"__type"` // Error type + Message string `json:"message"` // Error message + Code *string `json:"code,omitempty"` // Optional error code +} + +// ==================== STREAMING RESPONSE TYPES ==================== + +// BedrockConverseStreamResponse represents the overall streaming response structure +type BedrockConverseStreamResponse struct { + Events []BedrockStreamEvent `json:"-"` // Events are parsed from the stream, not JSON +} + +// BedrockStreamEvent represents a union type for all possible streaming events +type BedrockStreamEvent struct { + // Flat structure matching actual Bedrock API response + Role *string `json:"role,omitempty"` // For messageStart events + ContentBlockIndex *int `json:"contentBlockIndex,omitempty"` // For content block events + Delta *BedrockContentBlockDelta `json:"delta,omitempty"` // For contentBlockDelta events + StopReason *string `json:"stopReason,omitempty"` // For messageStop events + + // Start field for tool use events + Start *BedrockContentBlockStart `json:"start,omitempty"` // For contentBlockStart events + + // Metadata and usage (can appear at top level) + Usage *BedrockTokenUsage `json:"usage,omitempty"` // Usage information + Metrics *BedrockConverseMetrics `json:"metrics,omitempty"` // Performance metrics + Trace *BedrockConverseTrace `json:"trace,omitempty"` // Trace information + + // Additional fields + AdditionalModelResponseFields interface{} `json:"additionalModelResponseFields,omitempty"` +} + +// BedrockMessageStartEvent indicates the start of a message +type BedrockMessageStartEvent struct { + Role string `json:"role"` // "assistant" or "user" +} + +// BedrockContentBlockStart contains details about the starting content block +type BedrockContentBlockStart struct { + ToolUse *BedrockToolUseStart `json:"toolUse,omitempty"` +} + +// BedrockToolUseStart contains details about a tool use block start +type BedrockToolUseStart struct { + ToolUseID string `json:"toolUseId"` // Unique identifier for the tool use + Name string `json:"name"` // Name of the tool being used +} + +// BedrockContentBlockDelta represents the incremental content +type BedrockContentBlockDelta struct { + Text *string `json:"text,omitempty"` // Text content delta + ToolUse *BedrockToolUseDelta `json:"toolUse,omitempty"` // Tool use delta +} + +// BedrockToolUseDelta represents incremental tool use content +type BedrockToolUseDelta struct { + Input string `json:"input"` // Incremental input for the tool (JSON string) +} + +// BedrockMessageStopEvent indicates the end of a message +type BedrockMessageStopEvent struct { + StopReason string `json:"stopReason"` + AdditionalModelResponseFields interface{} `json:"additionalModelResponseFields,omitempty"` +} + +// BedrockMetadataEvent provides metadata about the response +type BedrockMetadataEvent struct { + Usage *BedrockTokenUsage `json:"usage,omitempty"` // Token usage information + Metrics *BedrockConverseMetrics `json:"metrics,omitempty"` // Performance metrics + Trace *BedrockConverseTrace `json:"trace,omitempty"` // Trace information +} + +// ==================== EMBEDDING TYPES ==================== + +// BedrockTitanEmbeddingRequest represents a Bedrock Titan embedding request +type BedrockTitanEmbeddingRequest struct { + InputText string `json:"inputText"` // Required: Text to embed + // Note: Titan models have fixed dimensions and don't support the dimensions parameter + // ExtraParams can be used for any additional model-specific parameters +} + +// BedrockTitanEmbeddingResponse represents a Bedrock Titan embedding response +type BedrockTitanEmbeddingResponse struct { + Embedding []float32 `json:"embedding"` // The embedding vector + InputTextTokenCount int `json:"inputTextTokenCount"` // Number of tokens in input +} + +// ==================== MODELS TYPES ==================== +type BedrockModelLifecycle struct { + Status string `json:"status"` +} + +type BedrockModel struct { + CustomizationsSupported []string `json:"customizationsSupported,omitempty"` + InferenceTypesSupported []string `json:"inferenceTypesSupported,omitempty"` + InputModalities []string `json:"inputModalities,omitempty"` + ModelArn string `json:"modelArn"` + ModelID string `json:"modelId"` + ModelLifecycle BedrockModelLifecycle `json:"modelLifecycle,omitempty"` + ModelName string `json:"modelName"` + OutputModalities []string `json:"outputModalities,omitempty"` + ProviderName string `json:"providerName"` + ResponseStreamingSupported bool `json:"responseStreamingSupported"` +} + +// BedrockListModelsResponse represents the response from AWS Bedrock's ListFoundationModels API +type BedrockListModelsResponse struct { + ModelSummaries []BedrockModel `json:"modelSummaries"` +} diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go new file mode 100644 index 000000000..6bf344f96 --- /dev/null +++ b/core/providers/bedrock/utils.go @@ -0,0 +1,570 @@ +package bedrock + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/bytedance/sonic" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// convertParameters handles parameter conversion +func convertChatParameters(bifrostReq *schemas.BifrostChatRequest, bedrockReq *BedrockConverseRequest) { + if bifrostReq.Params == nil { + return + } + // Convert inference config + if inferenceConfig := convertInferenceConfig(bifrostReq.Params); inferenceConfig != nil { + bedrockReq.InferenceConfig = inferenceConfig + } + // Convert tool config + if toolConfig := convertToolConfig(bifrostReq.Params); toolConfig != nil { + bedrockReq.ToolConfig = toolConfig + } + // Add extra parameters + if len(bifrostReq.Params.ExtraParams) > 0 { + // Handle guardrail configuration + if guardrailConfig, exists := bifrostReq.Params.ExtraParams["guardrailConfig"]; exists { + if gc, ok := guardrailConfig.(map[string]interface{}); ok { + config := &BedrockGuardrailConfig{} + + if identifier, ok := gc["guardrailIdentifier"].(string); ok { + config.GuardrailIdentifier = identifier + } + if version, ok := gc["guardrailVersion"].(string); ok { + config.GuardrailVersion = version + } + if trace, ok := gc["trace"].(string); ok { + config.Trace = &trace + } + + bedrockReq.GuardrailConfig = config + } + } + // Handle additional model request field paths + if bifrostReq.Params != nil && bifrostReq.Params.ExtraParams != nil { + if requestFields, exists := bifrostReq.Params.ExtraParams["additionalModelRequestFieldPaths"]; exists { + bedrockReq.AdditionalModelRequestFields = requestFields.(map[string]interface{}) + } + + // Handle additional model response field paths + if responseFields, exists := bifrostReq.Params.ExtraParams["additionalModelResponseFieldPaths"]; exists { + if fields, ok := responseFields.([]string); ok { + bedrockReq.AdditionalModelResponseFieldPaths = fields + } + } + // Handle performance configuration + if perfConfig, exists := bifrostReq.Params.ExtraParams["performanceConfig"]; exists { + if pc, ok := perfConfig.(map[string]interface{}); ok { + config := &BedrockPerformanceConfig{} + + if latency, ok := pc["latency"].(string); ok { + config.Latency = &latency + } + bedrockReq.PerformanceConfig = config + } + } + // Handle prompt variables + if promptVars, exists := bifrostReq.Params.ExtraParams["promptVariables"]; exists { + if vars, ok := promptVars.(map[string]interface{}); ok { + variables := make(map[string]BedrockPromptVariable) + + for key, value := range vars { + if valueMap, ok := value.(map[string]interface{}); ok { + variable := BedrockPromptVariable{} + if text, ok := valueMap["text"].(string); ok { + variable.Text = &text + } + variables[key] = variable + } + } + + if len(variables) > 0 { + bedrockReq.PromptVariables = variables + } + } + } + // Handle request metadata + if reqMetadata, exists := bifrostReq.Params.ExtraParams["requestMetadata"]; exists { + if metadata, ok := reqMetadata.(map[string]string); ok { + bedrockReq.RequestMetadata = metadata + } + } + } + } +} + +// ensureChatToolConfigForConversation ensures toolConfig is present when tool content exists +func ensureChatToolConfigForConversation(bifrostReq *schemas.BifrostChatRequest, bedrockReq *BedrockConverseRequest) { + if bedrockReq.ToolConfig != nil { + return // Already has tool config + } + + hasToolContent, tools := extractToolsFromConversationHistory(bifrostReq.Input) + if hasToolContent && len(tools) > 0 { + bedrockReq.ToolConfig = &BedrockToolConfig{Tools: tools} + } +} + +// convertMessages converts Bifrost messages to Bedrock format +// Returns regular messages and system messages separately +func convertMessages(bifrostMessages []schemas.ChatMessage) ([]BedrockMessage, []BedrockSystemMessage, error) { + var messages []BedrockMessage + var systemMessages []BedrockSystemMessage + + for _, msg := range bifrostMessages { + switch msg.Role { + case schemas.ChatMessageRoleSystem: + // Convert system message + systemMsg, err := convertSystemMessage(msg) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert system message: %w", err) + } + systemMessages = append(systemMessages, systemMsg) + + case schemas.ChatMessageRoleUser, schemas.ChatMessageRoleAssistant: + // Convert regular message + bedrockMsg, err := convertMessage(msg) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert message: %w", err) + } + messages = append(messages, bedrockMsg) + + case schemas.ChatMessageRoleTool: + // Convert tool message - this should be part of the conversation + bedrockMsg, err := convertToolMessage(msg) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert tool message: %w", err) + } + messages = append(messages, bedrockMsg) + + default: + return nil, nil, fmt.Errorf("unsupported message role: %s", msg.Role) + } + } + + return messages, systemMessages, nil +} + +// convertSystemMessage converts a Bifrost system message to Bedrock format +func convertSystemMessage(msg schemas.ChatMessage) (BedrockSystemMessage, error) { + systemMsg := BedrockSystemMessage{} + + // Convert content + if msg.Content.ContentStr != nil { + systemMsg.Text = msg.Content.ContentStr + } else if msg.Content.ContentBlocks != nil { + // For system messages, we only support text content + // Combine all text blocks into a single string + var textParts []string + for _, block := range msg.Content.ContentBlocks { + if block.Type == schemas.ChatContentBlockTypeText && block.Text != nil { + textParts = append(textParts, *block.Text) + } + } + if len(textParts) > 0 { + combined := strings.Join(textParts, "\n") + systemMsg.Text = &combined + } + } + + return systemMsg, nil +} + +// convertMessage converts a Bifrost message to Bedrock format +func convertMessage(msg schemas.ChatMessage) (BedrockMessage, error) { + bedrockMsg := BedrockMessage{ + Role: BedrockMessageRole(msg.Role), + } + + // Convert content + var contentBlocks []BedrockContentBlock + if msg.Content != nil { + var err error + contentBlocks, err = convertContent(*msg.Content) + if err != nil { + return BedrockMessage{}, fmt.Errorf("failed to convert content: %w", err) + } + } + + // Add tool calls if present (for assistant messages) + if msg.ChatAssistantMessage != nil && msg.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { + toolUseBlock := convertToolCallToContentBlock(toolCall) + contentBlocks = append(contentBlocks, toolUseBlock) + } + } + + bedrockMsg.Content = contentBlocks + return bedrockMsg, nil +} + +// convertToolMessage converts a Bifrost tool message to Bedrock format +func convertToolMessage(msg schemas.ChatMessage) (BedrockMessage, error) { + bedrockMsg := BedrockMessage{ + Role: "user", // Tool messages are typically treated as user messages in Bedrock + } + + // Tool messages should have a tool_call_id + if msg.ChatToolMessage == nil || msg.ChatToolMessage.ToolCallID == nil { + return BedrockMessage{}, fmt.Errorf("tool message missing tool_call_id") + } + + // Convert content to tool result + var toolResultContent []BedrockContentBlock + if msg.Content.ContentStr != nil { + // Bedrock expects JSON to be a parsed object, not a string + // Try to unmarshal the string content as JSON + var parsedOutput interface{} + if err := json.Unmarshal([]byte(*msg.Content.ContentStr), &parsedOutput); err != nil { + // If it's not valid JSON, wrap it as a text block instead + toolResultContent = append(toolResultContent, BedrockContentBlock{ + Text: msg.Content.ContentStr, + }) + } else { + // Use the parsed JSON object + toolResultContent = append(toolResultContent, BedrockContentBlock{ + JSON: parsedOutput, + }) + } + } else if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + switch block.Type { + case schemas.ChatContentBlockTypeText: + if block.Text != nil { + toolResultContent = append(toolResultContent, BedrockContentBlock{ + Text: block.Text, + }) + } + case schemas.ChatContentBlockTypeImage: + if block.ImageURLStruct != nil { + imageSource, err := convertImageToBedrockSource(block.ImageURLStruct.URL) + if err != nil { + return BedrockMessage{}, fmt.Errorf("failed to convert image in tool result: %w", err) + } + toolResultContent = append(toolResultContent, BedrockContentBlock{ + Image: imageSource, + }) + } + } + } + } + + // Create tool result content block + toolResultBlock := BedrockContentBlock{ + ToolResult: &BedrockToolResult{ + ToolUseID: *msg.ChatToolMessage.ToolCallID, + Content: toolResultContent, + Status: schemas.Ptr("success"), // Default to success + }, + } + + bedrockMsg.Content = []BedrockContentBlock{toolResultBlock} + return bedrockMsg, nil +} + +// convertContent converts Bifrost message content to Bedrock content blocks +func convertContent(content schemas.ChatMessageContent) ([]BedrockContentBlock, error) { + var contentBlocks []BedrockContentBlock + + if content.ContentStr != nil { + // Simple text content + contentBlocks = append(contentBlocks, BedrockContentBlock{ + Text: content.ContentStr, + }) + } else if content.ContentBlocks != nil { + // Multi-modal content + for _, block := range content.ContentBlocks { + bedrockBlock, err := convertContentBlock(block) + if err != nil { + return nil, fmt.Errorf("failed to convert content block: %w", err) + } + contentBlocks = append(contentBlocks, bedrockBlock) + } + } + + return contentBlocks, nil +} + +// convertContentBlock converts a Bifrost content block to Bedrock format +func convertContentBlock(block schemas.ChatContentBlock) (BedrockContentBlock, error) { + switch block.Type { + case schemas.ChatContentBlockTypeText: + return BedrockContentBlock{ + Text: block.Text, + }, nil + + case schemas.ChatContentBlockTypeImage: + if block.ImageURLStruct == nil { + return BedrockContentBlock{}, fmt.Errorf("image_url block missing image_url field") + } + + imageSource, err := convertImageToBedrockSource(block.ImageURLStruct.URL) + if err != nil { + return BedrockContentBlock{}, fmt.Errorf("failed to convert image: %w", err) + } + return BedrockContentBlock{ + Image: imageSource, + }, nil + + case schemas.ChatContentBlockTypeInputAudio: + // Bedrock doesn't support audio input in Converse API + return BedrockContentBlock{}, fmt.Errorf("audio input not supported in Bedrock Converse API") + + default: + return BedrockContentBlock{}, fmt.Errorf("unsupported content block type: %s", block.Type) + } +} + +// convertImageToBedrockSource converts a Bifrost image URL to Bedrock image source +// Uses centralized utility functions like Anthropic converter +// Returns an error for URL-based images (non-base64) since Bedrock requires base64 data +func convertImageToBedrockSource(imageURL string) (*BedrockImageSource, error) { + // Use centralized utility functions from schemas package + sanitizedURL, err := schemas.SanitizeImageURL(imageURL) + if err != nil { + return nil, fmt.Errorf("failed to sanitize image URL: %w", err) + } + urlTypeInfo := schemas.ExtractURLTypeInfo(sanitizedURL) + + // Check if this is a URL-based image (not base64/data URI) + if urlTypeInfo.Type != schemas.ImageContentTypeBase64 || urlTypeInfo.DataURLWithoutPrefix == nil { + return nil, fmt.Errorf("only base64-encoded images (data URI format) are supported; remote image URLs are not allowed") + } + + // Determine format from media type or default to jpeg + format := "jpeg" + if urlTypeInfo.MediaType != nil { + switch *urlTypeInfo.MediaType { + case "image/png": + format = "png" + case "image/gif": + format = "gif" + case "image/webp": + format = "webp" + case "image/jpeg", "image/jpg": + format = "jpeg" + } + } + + imageSource := &BedrockImageSource{ + Format: format, + Source: BedrockImageSourceData{ + Bytes: urlTypeInfo.DataURLWithoutPrefix, + }, + } + + return imageSource, nil +} + +// convertInferenceConfig converts Bifrost parameters to Bedrock inference config +func convertInferenceConfig(params *schemas.ChatParameters) *BedrockInferenceConfig { + var config BedrockInferenceConfig + if params.MaxCompletionTokens != nil { + config.MaxTokens = params.MaxCompletionTokens + } + + if params.Temperature != nil { + config.Temperature = params.Temperature + } + + if params.TopP != nil { + config.TopP = params.TopP + } + + if params.Stop != nil { + config.StopSequences = params.Stop + } + + return &config +} + +// convertToolConfig converts Bifrost tools to Bedrock tool config +func convertToolConfig(params *schemas.ChatParameters) *BedrockToolConfig { + if len(params.Tools) == 0 { + return nil + } + + var bedrockTools []BedrockTool + for _, tool := range params.Tools { + if tool.Function != nil { + // Create the complete schema object that Bedrock expects + var schemaObject interface{} + if tool.Function.Parameters != nil { + // Use the complete parameters object which includes type, properties, required, etc. + schemaObject = map[string]interface{}{ + "type": tool.Function.Parameters.Type, + "properties": tool.Function.Parameters.Properties, + } + // Add required field if present + if len(tool.Function.Parameters.Required) > 0 { + schemaObject.(map[string]interface{})["required"] = tool.Function.Parameters.Required + } + } else { + // Fallback to empty object schema if no parameters + schemaObject = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } + } + + // Use the tool description if available, otherwise use a generic description + description := "Function tool" + if tool.Function.Description != nil { + description = *tool.Function.Description + } + + bedrockTool := BedrockTool{ + ToolSpec: &BedrockToolSpec{ + Name: tool.Function.Name, + Description: schemas.Ptr(description), + InputSchema: BedrockToolInputSchema{ + JSON: schemaObject, + }, + }, + } + bedrockTools = append(bedrockTools, bedrockTool) + } + } + + toolConfig := &BedrockToolConfig{ + Tools: bedrockTools, + } + + // Convert tool choice + if params.ToolChoice != nil { + toolChoice := convertToolChoice(*params.ToolChoice) + if toolChoice != nil { + toolConfig.ToolChoice = toolChoice + } + } + + return toolConfig +} + +// convertToolChoice converts Bifrost tool choice to Bedrock format +func convertToolChoice(toolChoice schemas.ChatToolChoice) *BedrockToolChoice { + // String variant + if toolChoice.ChatToolChoiceStr != nil { + switch schemas.ChatToolChoiceType(*toolChoice.ChatToolChoiceStr) { + case schemas.ChatToolChoiceTypeAny, schemas.ChatToolChoiceTypeRequired: + return &BedrockToolChoice{Any: &BedrockToolChoiceAny{}} + case schemas.ChatToolChoiceTypeNone: + // Bedrock doesn't have explicit "none" - omit ToolChoice + return nil + case schemas.ChatToolChoiceTypeFunction: + // Not representable without a name; expect struct form instead. + return nil + } + } + // Struct variant + if toolChoice.ChatToolChoiceStruct != nil { + switch toolChoice.ChatToolChoiceStruct.Type { + case schemas.ChatToolChoiceTypeFunction: + name := toolChoice.ChatToolChoiceStruct.Function.Name + if name != "" { + return &BedrockToolChoice{ + Tool: &BedrockToolChoiceTool{Name: name}, + } + } + return nil + case schemas.ChatToolChoiceTypeAny, schemas.ChatToolChoiceTypeRequired: + return &BedrockToolChoice{Any: &BedrockToolChoiceAny{}} + case schemas.ChatToolChoiceTypeNone: + return nil + } + } + return nil +} + +// extractToolsFromConversationHistory analyzes conversation history for tool content +func extractToolsFromConversationHistory(messages []schemas.ChatMessage) (bool, []BedrockTool) { + hasToolContent := false + toolsMap := make(map[string]BedrockTool) + + for _, msg := range messages { + hasToolContent = checkMessageForToolContent(msg, toolsMap) || hasToolContent + } + + tools := make([]BedrockTool, 0, len(toolsMap)) + for _, tool := range toolsMap { + tools = append(tools, tool) + } + + return hasToolContent, tools +} + +// checkMessageForToolContent checks a single message for tool content and updates the tools map +func checkMessageForToolContent(msg schemas.ChatMessage, toolsMap map[string]BedrockTool) bool { + hasContent := false + + // Check assistant tool calls + if msg.ChatAssistantMessage != nil && msg.ChatAssistantMessage.ToolCalls != nil { + hasContent = true + for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { + if toolCall.Function.Name != nil { + if _, exists := toolsMap[*toolCall.Function.Name]; !exists { + // Create a complete schema object for extracted tools + schemaObject := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } + + toolsMap[*toolCall.Function.Name] = BedrockTool{ + ToolSpec: &BedrockToolSpec{ + Name: *toolCall.Function.Name, + Description: schemas.Ptr("Tool extracted from conversation history"), + InputSchema: BedrockToolInputSchema{ + JSON: schemaObject, + }, + }, + } + } + } + } + } + + // Check tool messages + if msg.ChatToolMessage != nil && msg.ChatToolMessage.ToolCallID != nil { + hasContent = true + } + + // Check content blocks + if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Type == "tool_use" || block.Type == "tool_result" { + hasContent = true + } + } + } + + return hasContent +} + +// convertToolCallToContentBlock converts a Bifrost tool call to a Bedrock content block +func convertToolCallToContentBlock(toolCall schemas.ChatAssistantMessageToolCall) BedrockContentBlock { + toolUseID := "" + if toolCall.ID != nil { + toolUseID = *toolCall.ID + } + + toolName := "" + if toolCall.Function.Name != nil { + toolName = *toolCall.Function.Name + } + + // Parse JSON arguments to object + var input interface{} + if err := sonic.Unmarshal([]byte(toolCall.Function.Arguments), &input); err != nil { + input = map[string]interface{}{} // Fallback to empty object + } + + return BedrockContentBlock{ + ToolUse: &BedrockToolUse{ + ToolUseID: toolUseID, + Name: toolName, + Input: input, + }, + } +} diff --git a/core/providers/cerebras.go b/core/providers/cerebras.go new file mode 100644 index 000000000..f6b7efcb7 --- /dev/null +++ b/core/providers/cerebras.go @@ -0,0 +1,207 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the Cerebras provider implementation. +package providers + +import ( + "context" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// CerebrasProvider implements the Provider interface for Cerebras's API. +type CerebrasProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewCerebrasProvider creates a new Cerebras provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewCerebrasProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*CerebrasProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.cerebras.ai" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &CerebrasProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Cerebras. +func (provider *CerebrasProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Cerebras +} + +// ListModels performs a list models request to Cerebras's API. +func (provider *CerebrasProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return openai.HandleOpenAIListModelsRequest( + ctx, + provider.client, + request, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/models"), + keys, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletion performs a text completion request to Cerebras's API. +// It formats the request, sends it to Cerebras, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *CerebrasProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return openai.HandleOpenAITextCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletionStream performs a streaming text completion request to Cerebras's API. +// It formats the request, sends it to Cerebras, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *CerebrasProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAITextCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// ChatCompletion performs a chat completion request to the Cerebras API. +func (provider *CerebrasProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the Cerebras API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Cerebras's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *CerebrasProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/chat/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Cerebras, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +func (provider *CerebrasProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Cerebras API. +func (provider *CerebrasProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding is not supported by the Cerebras provider. +func (provider *CerebrasProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.EmbeddingRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the Cerebras provider. +func (provider *CerebrasProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Cerebras provider. +func (provider *CerebrasProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Cerebras provider. +func (provider *CerebrasProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Cerebras provider. +func (provider *CerebrasProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/cohere.go b/core/providers/cohere.go deleted file mode 100644 index 0240af086..000000000 --- a/core/providers/cohere.go +++ /dev/null @@ -1,360 +0,0 @@ -// Package providers implements various LLM providers and their utility functions. -// This file contains the Cohere provider implementation. -package providers - -import ( - "fmt" - "slices" - "sync" - "time" - - "github.com/goccy/go-json" - - schemas "github.com/maximhq/bifrost/core/schemas" - "github.com/valyala/fasthttp" -) - -// cohereResponsePool provides a pool for Cohere response objects. -var cohereResponsePool = sync.Pool{ - New: func() interface{} { - return &CohereChatResponse{} - }, -} - -// acquireCohereResponse gets a Cohere response from the pool and resets it. -func acquireCohereResponse() *CohereChatResponse { - resp := cohereResponsePool.Get().(*CohereChatResponse) - *resp = CohereChatResponse{} // Reset the struct - return resp -} - -// releaseCohereResponse returns a Cohere response to the pool. -func releaseCohereResponse(resp *CohereChatResponse) { - if resp != nil { - cohereResponsePool.Put(resp) - } -} - -// CohereParameterDefinition represents a parameter definition for a Cohere tool. -// It defines the type, description, and whether the parameter is required. -type CohereParameterDefinition struct { - Type string `json:"type"` // Type of the parameter - Description *string `json:"description,omitempty"` // Optional description of the parameter - Required bool `json:"required"` // Whether the parameter is required -} - -// CohereTool represents a tool definition for the Cohere API. -// It includes the tool's name, description, and parameter definitions. -type CohereTool struct { - Name string `json:"name"` // Name of the tool - Description string `json:"description"` // Description of the tool - ParameterDefinitions map[string]CohereParameterDefinition `json:"parameter_definitions"` // Definitions of the tool's parameters -} - -// CohereToolCall represents a tool call made by the Cohere API. -// It includes the name of the tool and its parameters. -type CohereToolCall struct { - Name string `json:"name"` // Name of the tool being called - Parameters interface{} `json:"parameters"` // Parameters passed to the tool -} - -// CohereChatResponse represents the response from Cohere's chat API. -// It includes the response ID, generated text, chat history, and usage statistics. -type CohereChatResponse struct { - ResponseID string `json:"response_id"` // Unique identifier for the response - Text string `json:"text"` // Generated text response - GenerationID string `json:"generation_id"` // ID of the generation - ChatHistory []struct { - Role schemas.ModelChatMessageRole `json:"role"` // Role of the message sender - Message string `json:"message"` // Content of the message - ToolCalls []CohereToolCall `json:"tool_calls"` // Tool calls made in the message - } `json:"chat_history"` // History of the chat conversation - FinishReason string `json:"finish_reason"` // Reason for completion termination - Meta struct { - APIVersion struct { - Version string `json:"version"` // Version of the API used - } `json:"api_version"` // API version information - BilledUnits struct { - InputTokens float64 `json:"input_tokens"` // Number of input tokens billed - OutputTokens float64 `json:"output_tokens"` // Number of output tokens billed - } `json:"billed_units"` // Token usage billing information - Tokens struct { - InputTokens float64 `json:"input_tokens"` // Number of input tokens used - OutputTokens float64 `json:"output_tokens"` // Number of output tokens generated - } `json:"tokens"` // Token usage statistics - } `json:"meta"` // Metadata about the response - ToolCalls []CohereToolCall `json:"tool_calls"` // Tool calls made in the response -} - -// CohereError represents an error response from the Cohere API. -type CohereError struct { - Message string `json:"message"` // Error message -} - -// CohereProvider implements the Provider interface for Cohere. -type CohereProvider struct { - logger schemas.Logger // Logger for provider operations - client *fasthttp.Client // HTTP client for API requests -} - -// NewCohereProvider creates a new Cohere provider instance. -// It initializes the HTTP client with the provided configuration and sets up response pools. -// The client is configured with timeouts and connection limits. -func NewCohereProvider(config *schemas.ProviderConfig, logger schemas.Logger) *CohereProvider { - setConfigDefaults(config) - - client := &fasthttp.Client{ - ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - MaxConnsPerHost: config.ConcurrencyAndBufferSize.BufferSize, - } - - // Pre-warm response pools - for range config.ConcurrencyAndBufferSize.Concurrency { - cohereResponsePool.Put(&CohereChatResponse{}) - bifrostResponsePool.Put(&schemas.BifrostResponse{}) - } - - return &CohereProvider{ - logger: logger, - client: client, - } -} - -// GetProviderKey returns the provider identifier for Cohere. -func (provider *CohereProvider) GetProviderKey() schemas.ModelProvider { - return schemas.Cohere -} - -// TextCompletion is not supported by the Cohere provider. -// Returns an error indicating that text completion is not supported. -func (provider *CohereProvider) TextCompletion(model, key, text string, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "text completion is not supported by cohere provider", - }, - } -} - -// ChatCompletion performs a chat completion request to the Cohere API. -// It formats the request, sends it to Cohere, and processes the response. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *CohereProvider) ChatCompletion(model, key string, messages []schemas.Message, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - // Get the last message and chat history - lastMessage := messages[len(messages)-1] - chatHistory := messages[:len(messages)-1] - - // Transform chat history - var cohereHistory []map[string]interface{} - for _, msg := range chatHistory { - cohereHistory = append(cohereHistory, map[string]interface{}{ - "role": msg.Role, - "message": msg.Content, - }) - } - - preparedParams := prepareParams(params) - - // Prepare request body - requestBody := mergeConfig(map[string]interface{}{ - "message": lastMessage.Content, - "chat_history": cohereHistory, - "model": model, - }, preparedParams) - - // Add tools if present - if params != nil && params.Tools != nil && len(*params.Tools) > 0 { - var tools []CohereTool - for _, tool := range *params.Tools { - parameterDefinitions := make(map[string]CohereParameterDefinition) - params := tool.Function.Parameters - for name, prop := range tool.Function.Parameters.Properties { - propMap, ok := prop.(map[string]interface{}) - if ok { - paramDef := CohereParameterDefinition{ - Required: slices.Contains(params.Required, name), - } - - if typeStr, ok := propMap["type"].(string); ok { - paramDef.Type = typeStr - } - - if desc, ok := propMap["description"].(string); ok { - paramDef.Description = &desc - } - - parameterDefinitions[name] = paramDef - } - } - - tools = append(tools, CohereTool{ - Name: tool.Function.Name, - Description: tool.Function.Description, - ParameterDefinitions: parameterDefinitions, - }) - } - requestBody["tools"] = tools - } - - // Marshal request body - jsonBody, err := json.Marshal(requestBody) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderJSONMarshaling, - Error: err, - }, - } - } - - // Create request - req := fasthttp.AcquireRequest() - resp := fasthttp.AcquireResponse() - defer fasthttp.ReleaseRequest(req) - defer fasthttp.ReleaseResponse(resp) - - req.SetRequestURI("https://api.cohere.ai/v1/chat") - req.Header.SetMethod("POST") - req.Header.SetContentType("application/json") - req.Header.Set("Authorization", "Bearer "+key) - req.SetBody(jsonBody) - - // Make request - if err := provider.client.Do(req, resp); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderRequest, - Error: err, - }, - } - } - - // Handle error response - if resp.StatusCode() != fasthttp.StatusOK { - var errorResp CohereError - - bifrostErr := handleProviderAPIError(resp, &errorResp) - bifrostErr.Error.Message = errorResp.Message - - return nil, bifrostErr - } - - // Read response body - responseBody := resp.Body() - - // Create response object from pool - response := acquireCohereResponse() - defer releaseCohereResponse(response) - - // Create Bifrost response from pool - bifrostResponse := acquireBifrostResponse() - defer releaseBifrostResponse(bifrostResponse) - - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - // Transform tool calls if present - var toolCalls []schemas.ToolCall - if response.ToolCalls != nil { - for _, tool := range response.ToolCalls { - function := schemas.FunctionCall{ - Name: &tool.Name, - } - - args, err := json.Marshal(tool.Parameters) - if err != nil { - function.Arguments = fmt.Sprintf("%v", tool.Parameters) - } else { - function.Arguments = string(args) - } - - toolCalls = append(toolCalls, schemas.ToolCall{ - Function: function, - }) - } - } - - // Get role and content from the last message in chat history - var role schemas.ModelChatMessageRole - var content string - if len(response.ChatHistory) > 0 { - lastMsg := response.ChatHistory[len(response.ChatHistory)-1] - role = lastMsg.Role - content = lastMsg.Message - } else { - role = schemas.RoleChatbot - content = response.Text - } - - bifrostResponse.ID = response.ResponseID - bifrostResponse.Choices = []schemas.BifrostResponseChoice{ - { - Index: 0, - Message: schemas.BifrostResponseChoiceMessage{ - Role: role, - Content: &content, - ToolCalls: &toolCalls, - }, - FinishReason: &response.FinishReason, - }, - } - bifrostResponse.Usage = schemas.LLMUsage{ - PromptTokens: int(response.Meta.Tokens.InputTokens), - CompletionTokens: int(response.Meta.Tokens.OutputTokens), - TotalTokens: int(response.Meta.Tokens.InputTokens + response.Meta.Tokens.OutputTokens), - } - bifrostResponse.Model = model - bifrostResponse.ExtraFields = schemas.BifrostResponseExtraFields{ - Provider: schemas.Cohere, - BilledUsage: &schemas.BilledLLMUsage{ - PromptTokens: float64Ptr(response.Meta.BilledUnits.InputTokens), - CompletionTokens: float64Ptr(response.Meta.BilledUnits.OutputTokens), - }, - ChatHistory: convertChatHistory(response.ChatHistory), - RawResponse: rawResponse, - } - - return bifrostResponse, nil -} - -// convertChatHistory converts Cohere's chat history format to Bifrost's format for standardization. -// It transforms the chat history messages and their tool calls. -func convertChatHistory(history []struct { - Role schemas.ModelChatMessageRole `json:"role"` - Message string `json:"message"` - ToolCalls []CohereToolCall `json:"tool_calls"` -}) *[]schemas.BifrostResponseChoiceMessage { - converted := make([]schemas.BifrostResponseChoiceMessage, len(history)) - for i, msg := range history { - var toolCalls []schemas.ToolCall - if msg.ToolCalls != nil { - for _, tool := range msg.ToolCalls { - function := schemas.FunctionCall{ - Name: &tool.Name, - } - - args, err := json.Marshal(tool.Parameters) - if err != nil { - function.Arguments = fmt.Sprintf("%v", tool.Parameters) - } else { - function.Arguments = string(args) - } - - toolCalls = append(toolCalls, schemas.ToolCall{ - Function: function, - }) - } - } - converted[i] = schemas.BifrostResponseChoiceMessage{ - Role: msg.Role, - Content: &msg.Message, - ToolCalls: &toolCalls, - } - } - return &converted -} diff --git a/core/providers/cohere/chat.go b/core/providers/cohere/chat.go new file mode 100644 index 000000000..75888ff93 --- /dev/null +++ b/core/providers/cohere/chat.go @@ -0,0 +1,480 @@ +package cohere + +import ( + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ToCohereChatCompletionRequest converts a Bifrost request to Cohere v2 format +func ToCohereChatCompletionRequest(bifrostReq *schemas.BifrostChatRequest) *CohereChatRequest { + if bifrostReq == nil || bifrostReq.Input == nil { + return nil + } + + messages := bifrostReq.Input + cohereReq := &CohereChatRequest{ + Model: bifrostReq.Model, + } + + // Convert messages to Cohere v2 format + var cohereMessages []CohereMessage + for _, msg := range messages { + cohereMsg := CohereMessage{ + Role: string(msg.Role), + } + + // Convert content + if msg.Content != nil && msg.Content.ContentStr != nil { + cohereMsg.Content = NewStringContent(*msg.Content.ContentStr) + } else if msg.Content != nil && msg.Content.ContentBlocks != nil { + var contentBlocks []CohereContentBlock + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + contentBlocks = append(contentBlocks, CohereContentBlock{ + Type: CohereContentBlockTypeText, + Text: block.Text, + }) + } else if block.ImageURLStruct != nil { + contentBlocks = append(contentBlocks, CohereContentBlock{ + Type: CohereContentBlockTypeImage, + ImageURL: &CohereImageURL{ + URL: block.ImageURLStruct.URL, + }, + }) + } + } + if len(contentBlocks) > 0 { + cohereMsg.Content = NewBlocksContent(contentBlocks) + } + } + + // Convert tool calls for assistant messages + if msg.ChatAssistantMessage != nil && msg.ChatAssistantMessage.ToolCalls != nil { + var toolCalls []CohereToolCall + for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { + // Safely extract function name and arguments + var functionName *string + var functionArguments string + + if toolCall.Function.Name != nil { + functionName = toolCall.Function.Name + } else { + // Use empty string if Name is nil + functionName = schemas.Ptr("") + } + + // Arguments is a string, not a pointer, so it's safe to access directly + functionArguments = toolCall.Function.Arguments + + cohereToolCall := CohereToolCall{ + ID: toolCall.ID, + Type: "function", + Function: &CohereFunction{ + Name: functionName, + Arguments: functionArguments, + }, + } + toolCalls = append(toolCalls, cohereToolCall) + } + cohereMsg.ToolCalls = toolCalls + } + + // Convert tool messages + if msg.ChatToolMessage != nil && msg.ChatToolMessage.ToolCallID != nil { + cohereMsg.ToolCallID = msg.ChatToolMessage.ToolCallID + } + + cohereMessages = append(cohereMessages, cohereMsg) + } + + cohereReq.Messages = cohereMessages + + // Convert parameters + if bifrostReq.Params != nil { + cohereReq.MaxTokens = bifrostReq.Params.MaxCompletionTokens + cohereReq.Temperature = bifrostReq.Params.Temperature + cohereReq.P = bifrostReq.Params.TopP + cohereReq.StopSequences = bifrostReq.Params.Stop + cohereReq.FrequencyPenalty = bifrostReq.Params.FrequencyPenalty + cohereReq.PresencePenalty = bifrostReq.Params.PresencePenalty + + // Convert extra params + if bifrostReq.Params.ExtraParams != nil { + // Handle thinking parameter + if thinkingParam, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "thinking"); ok { + if thinkingMap, ok := thinkingParam.(map[string]interface{}); ok { + thinking := &CohereThinking{} + if typeStr, ok := schemas.SafeExtractString(thinkingMap["type"]); ok { + thinking.Type = CohereThinkingType(typeStr) + } + if tokenBudget, ok := schemas.SafeExtractIntPointer(thinkingMap["token_budget"]); ok { + thinking.TokenBudget = tokenBudget + } + cohereReq.Thinking = thinking + } + } + + // Handle other Cohere-specific extra params + if safetyMode, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["safety_mode"]); ok { + cohereReq.SafetyMode = safetyMode + } + + if logProbs, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["log_probs"]); ok { + cohereReq.LogProbs = logProbs + } + + if strictToolChoice, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["strict_tool_choice"]); ok { + cohereReq.StrictToolChoice = strictToolChoice + } + } + + // Convert tools to Cohere-specific format (without "strict" field) + if bifrostReq.Params.Tools != nil { + cohereTools := make([]CohereChatRequestTool, len(bifrostReq.Params.Tools)) + for i, tool := range bifrostReq.Params.Tools { + cohereTools[i] = CohereChatRequestTool{ + Type: string(tool.Type), + } + if tool.Function != nil { + cohereTools[i].Function = CohereChatRequestFunction{ + Name: tool.Function.Name, + Description: tool.Function.Description, + Parameters: tool.Function.Parameters, // Convert to map + // Note: No "strict" field - Cohere doesn't support it + } + } + } + cohereReq.Tools = cohereTools + } + + // Convert tool choice + if bifrostReq.Params.ToolChoice != nil { + toolChoice := bifrostReq.Params.ToolChoice + + if toolChoice.ChatToolChoiceStr != nil { + switch schemas.ChatToolChoiceType(*toolChoice.ChatToolChoiceStr) { + case schemas.ChatToolChoiceTypeNone: + toolChoice := ToolChoiceNone + cohereReq.ToolChoice = &toolChoice + default: + toolChoice := ToolChoiceRequired + cohereReq.ToolChoice = &toolChoice + } + } else if toolChoice.ChatToolChoiceStruct != nil { + switch toolChoice.ChatToolChoiceStruct.Type { + case schemas.ChatToolChoiceTypeFunction: + toolChoice := ToolChoiceRequired + cohereReq.ToolChoice = &toolChoice + default: + toolChoice := ToolChoiceAuto + cohereReq.ToolChoice = &toolChoice + } + } + } + } + + return cohereReq +} + +// ToBifrostChatResponse converts a Cohere v2 response to Bifrost format +func (response *CohereChatResponse) ToBifrostChatResponse(model string) *schemas.BifrostChatResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostChatResponse{ + ID: response.ID, + Model: model, + Object: "chat.completion", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + }, + }, + }, + }, + Created: int(time.Now().Unix()), + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: schemas.Cohere, + }, + } + + var content *string + var contentBlocks []schemas.ChatContentBlock + var toolCalls []schemas.ChatAssistantMessageToolCall + + // Convert message content + if response.Message != nil { + if response.Message.Content != nil { + if response.Message.Content.IsString() || + (response.Message.Content.IsBlocks() && + len(response.Message.Content.GetBlocks()) == 1 && + response.Message.Content.GetBlocks()[0].Type == CohereContentBlockTypeText) { + if response.Message.Content.IsString() { + content = response.Message.Content.GetString() + } else { + content = response.Message.Content.GetBlocks()[0].Text + } + } else if response.Message.Content.IsBlocks() { + for _, block := range response.Message.Content.GetBlocks() { + if block.Type == CohereContentBlockTypeText && block.Text != nil { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: block.Text, + }) + } else if block.Type == CohereContentBlockTypeImage && block.ImageURL != nil { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: block.ImageURL.URL, + }, + }) + } + } + } + } + + // Create the message content + messageContent := &schemas.ChatMessageContent{ + ContentStr: content, + ContentBlocks: contentBlocks, + } + + // Convert tool calls + if response.Message.ToolCalls != nil { + for _, toolCall := range response.Message.ToolCalls { + // Check if Function is nil to avoid nil pointer dereference + if toolCall.Function == nil { + // Skip this tool call if Function is nil + continue + } + + // Safely extract function name and arguments + var functionName *string + var functionArguments string + + if toolCall.Function.Name != nil { + functionName = toolCall.Function.Name + } else { + // Use empty string if Name is nil + functionName = schemas.Ptr("") + } + + // Arguments is a string, not a pointer, so it's safe to access directly + functionArguments = toolCall.Function.Arguments + + bifrostToolCall := schemas.ChatAssistantMessageToolCall{ + Index: uint16(len(toolCalls)), + ID: toolCall.ID, + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: functionName, + Arguments: functionArguments, + }, + } + toolCalls = append(toolCalls, bifrostToolCall) + } + } + + // Create assistant message if we have tool calls + var assistantMessage *schemas.ChatAssistantMessage + if len(toolCalls) > 0 { + assistantMessage = &schemas.ChatAssistantMessage{ + ToolCalls: toolCalls, + } + } + + bifrostResponse.Choices[0].ChatNonStreamResponseChoice.Message = &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: messageContent, + ChatAssistantMessage: assistantMessage, + } + } + + // Convert finish reason + if response.FinishReason != nil { + finishReason := ConvertCohereFinishReasonToBifrost(*response.FinishReason) + bifrostResponse.Choices[0].FinishReason = schemas.Ptr(finishReason) + } + + // Convert usage information + if response.Usage != nil { + usage := &schemas.BifrostLLMUsage{} + + if response.Usage.Tokens != nil { + if response.Usage.Tokens.InputTokens != nil { + usage.PromptTokens = *response.Usage.Tokens.InputTokens + } + if response.Usage.Tokens.OutputTokens != nil { + usage.CompletionTokens = *response.Usage.Tokens.OutputTokens + } + if response.Usage.CachedTokens != nil { + usage.PromptTokensDetails = &schemas.ChatPromptTokensDetails{ + CachedTokens: *response.Usage.CachedTokens, + } + } + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + + bifrostResponse.Usage = usage + } + + return bifrostResponse +} + +func (chunk *CohereStreamEvent) ToBifrostChatCompletionStream() (*schemas.BifrostChatResponse, *schemas.BifrostError, bool) { + switch chunk.Type { + case StreamEventMessageStart: + if chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.Role != nil { + // Create streaming response for this delta + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Role: chunk.Delta.Message.Role, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case StreamEventContentDelta: + if chunk.Delta != nil && + chunk.Delta.Message != nil && + chunk.Delta.Message.Content != nil && + chunk.Delta.Message.Content.CohereStreamContentObject != nil && + chunk.Delta.Message.Content.CohereStreamContentObject.Text != nil { + // Try to cast content to CohereStreamContent + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Content: chunk.Delta.Message.Content.CohereStreamContentObject.Text, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case StreamEventToolPlanDelta: + if chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.ToolPlan != nil { + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Thought: chunk.Delta.Message.ToolPlan, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case StreamEventContentStart: + // Content start event - just continue, actual content comes in content-delta + return nil, nil, false + + case StreamEventToolCallStart, StreamEventToolCallDelta: + if chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.ToolCalls != nil && chunk.Delta.Message.ToolCalls.CohereToolCallObject != nil { + // Handle single tool call object (tool-call-start/delta events) + cohereToolCall := chunk.Delta.Message.ToolCalls.CohereToolCallObject + toolCall := schemas.ChatAssistantMessageToolCall{} + + if cohereToolCall.ID != nil { + toolCall.ID = cohereToolCall.ID + } + + if cohereToolCall.Function != nil { + if cohereToolCall.Function.Name != nil { + toolCall.Function.Name = cohereToolCall.Function.Name + } + toolCall.Function.Arguments = cohereToolCall.Function.Arguments + } + + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + ToolCalls: []schemas.ChatAssistantMessageToolCall{toolCall}, + }, + }, + }, + }, + } + + return streamResponse, nil, false + } + + case StreamEventToolCallEnd: + return nil, nil, false + + case StreamEventContentEnd: + return nil, nil, false + + case StreamEventMessageEnd: + if chunk.Delta != nil { + var finishReason string + usage := &schemas.BifrostLLMUsage{} + // Set finish reason + if chunk.Delta.FinishReason != nil { + finishReason = ConvertCohereFinishReasonToBifrost(*chunk.Delta.FinishReason) + } + + // Set usage information + if chunk.Delta.Usage != nil { + if chunk.Delta.Usage.Tokens != nil { + if chunk.Delta.Usage.Tokens.InputTokens != nil { + usage.PromptTokens = *chunk.Delta.Usage.Tokens.InputTokens + } + if chunk.Delta.Usage.Tokens.OutputTokens != nil { + usage.CompletionTokens = *chunk.Delta.Usage.Tokens.OutputTokens + } + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + } + + streamResponse := &schemas.BifrostChatResponse{ + Object: "chat.completion.chunk", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + FinishReason: &finishReason, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{}, + }, + }, + }, + Usage: usage, + } + + return streamResponse, nil, true + } + return nil, nil, false + } + + return nil, nil, false +} diff --git a/core/providers/cohere/cohere.go b/core/providers/cohere/cohere.go new file mode 100644 index 000000000..e9b9a572a --- /dev/null +++ b/core/providers/cohere/cohere.go @@ -0,0 +1,888 @@ +package cohere + +import ( + "bufio" + "context" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "net/http" + "net/url" + + "github.com/bytedance/sonic" + + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + + "github.com/valyala/fasthttp" +) + +// cohereResponsePool provides a pool for Cohere v2 response objects. +var cohereResponsePool = sync.Pool{ + New: func() interface{} { + return &CohereChatResponse{} + }, +} + +// cohereEmbeddingResponsePool provides a pool for Cohere embedding response objects. +var cohereEmbeddingResponsePool = sync.Pool{ + New: func() interface{} { + return &CohereEmbeddingResponse{} + }, +} + +// acquireCohereEmbeddingResponse gets a Cohere embedding response from the pool and resets it. +func acquireCohereEmbeddingResponse() *CohereEmbeddingResponse { + resp := cohereEmbeddingResponsePool.Get().(*CohereEmbeddingResponse) + *resp = CohereEmbeddingResponse{} // Reset the struct + return resp +} + +// releaseCohereEmbeddingResponse returns a Cohere embedding response to the pool. +func releaseCohereEmbeddingResponse(resp *CohereEmbeddingResponse) { + if resp != nil { + cohereEmbeddingResponsePool.Put(resp) + } +} + +// acquireCohereResponse gets a Cohere v2 response from the pool and resets it. +func acquireCohereResponse() *CohereChatResponse { + resp := cohereResponsePool.Get().(*CohereChatResponse) + *resp = CohereChatResponse{} // Reset the struct + return resp +} + +// releaseCohereResponse returns a Cohere v2 response to the pool. +func releaseCohereResponse(resp *CohereChatResponse) { + if resp != nil { + cohereResponsePool.Put(resp) + } +} + +// CohereProvider implements the Provider interface for Cohere. +type CohereProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse + customProviderConfig *schemas.CustomProviderConfig // Custom provider config +} + +// NewCohereProvider creates a new Cohere provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts and connection limits. +func NewCohereProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*CohereProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Setting proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Pre-warm response pools + for i := 0; i < config.ConcurrencyAndBufferSize.Concurrency; i++ { + cohereResponsePool.Put(&CohereChatResponse{}) + cohereEmbeddingResponsePool.Put(&CohereEmbeddingResponse{}) + } + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.cohere.ai" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &CohereProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + customProviderConfig: config.CustomProviderConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Cohere. +func (provider *CohereProvider) GetProviderKey() schemas.ModelProvider { + return providerUtils.GetProviderName(schemas.Cohere, provider.customProviderConfig) +} + +// buildRequestURL constructs the full request URL using the provider's configuration. +func (provider *CohereProvider) buildRequestURL(ctx context.Context, defaultPath string, requestType schemas.RequestType) string { + return provider.networkConfig.BaseURL + providerUtils.GetRequestPath(ctx, defaultPath, provider.customProviderConfig, requestType) +} + +// completeRequest sends a request to Cohere's API and handles the response. +// It constructs the API URL, sets up authentication, and processes the response. +// Returns the response body or an error if the request fails. +func (provider *CohereProvider) completeRequest(ctx context.Context, jsonData []byte, url string, key string) ([]byte, time.Duration, *schemas.BifrostError) { + // Create the request with the JSON body + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + + req.SetBody(jsonData) + + // Send the request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, latency, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + provider.logger.Debug(fmt.Sprintf("error from %s provider: %s", provider.GetProviderKey(), string(resp.Body()))) + + var errorResp CohereError + + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + bifrostErr.Type = &errorResp.Type + if bifrostErr.Error == nil { + bifrostErr.Error = &schemas.ErrorField{} + } + bifrostErr.Error.Message = errorResp.Message + if errorResp.Code != nil { + bifrostErr.Error.Code = errorResp.Code + } + + return nil, latency, bifrostErr + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + // Read the response body and copy it before releasing the response + // to avoid use-after-free since resp.Body() references fasthttp's internal buffer + bodyCopy := append([]byte(nil), body...) + + return bodyCopy, latency, nil +} + +// listModelsByKey performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +func (provider *CohereProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Build query parameters + params := url.Values{} + params.Set("page_size", strconv.Itoa(schemas.DefaultPageSize)) + if request.ExtraParams != nil { + if endpoint, ok := request.ExtraParams["endpoint"].(string); ok && endpoint != "" { + params.Set("endpoint", endpoint) + } + if defaultOnly, ok := request.ExtraParams["default_only"].(bool); ok && defaultOnly { + params.Set("default_only", "true") + } + } + + // Build URL + req.SetRequestURI(provider.buildRequestURL(ctx, fmt.Sprintf("/v1/models?%s", params.Encode()), schemas.ListModelsRequest)) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key.Value)) + } + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + var errorResp CohereError + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + bifrostErr.Error.Message = errorResp.Message + return nil, bifrostErr + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + // Parse Cohere list models response + var cohereResponse CohereListModelsResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, &cohereResponse, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Convert Cohere v2 response to Bifrost response + response := cohereResponse.ToBifrostListModelsResponse(providerName) + + response.ExtraFields.Latency = latency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ListModels performs a list models request to Cohere's API. +// Requests are made concurrently for improved performance. +func (provider *CohereProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Cohere, provider.customProviderConfig, schemas.ListModelsRequest); err != nil { + return nil, err + } + if provider.customProviderConfig != nil && provider.customProviderConfig.IsKeyLess { + return provider.listModelsByKey(ctx, schemas.Key{}, request) + } + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion is not supported by the Cohere provider. +// Returns an error indicating that text completion is not supported. +func (provider *CohereProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream performs a streaming text completion request to Cohere's API. +// It formats the request, sends it to Cohere, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *CohereProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to the Cohere API using v2 converter. +// It formats the request, sends it to Cohere, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *CohereProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + // Check if chat completion is allowed + if err := providerUtils.CheckOperationAllowed(schemas.Cohere, provider.customProviderConfig, schemas.ChatCompletionRequest); err != nil { + return nil, err + } + + // Convert to Cohere v2 request + jsonBody, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToCohereChatCompletionRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + responseBody, latency, err := provider.completeRequest(ctx, jsonBody, provider.buildRequestURL(ctx, "/v2/chat", schemas.ChatCompletionRequest), key.Value) + if err != nil { + return nil, err + } + + // Create response object from pool + response := acquireCohereResponse() + defer releaseCohereResponse(response) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + bifrostResponse := response.ToBifrostChatResponse(request.Model) + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.ChatCompletionRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// ChatCompletionStream performs a streaming chat completion request to the Cohere API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *CohereProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if chat completion stream is allowed + if err := providerUtils.CheckOperationAllowed(schemas.Cohere, provider.customProviderConfig, schemas.ChatCompletionStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToCohereChatCompletionRequest(request) + if reqBody != nil { + reqBody.Stream = schemas.Ptr(true) + } + return reqBody, nil + }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.buildRequestURL(ctx, "/v2/chat", schemas.ChatCompletionStreamRequest)) + req.Header.SetContentType("application/json") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Set headers + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + req.SetBody(jsonBody) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, providerUtils.NewProviderAPIError(fmt.Sprintf("HTTP error from %s: %d", providerName, resp.StatusCode()), fmt.Errorf("%s", string(resp.Body())), resp.StatusCode(), providerName, nil, nil) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + chunkIndex := 0 + startTime := time.Now() + lastChunkTime := startTime + + var responseID string + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Parse SSE data + if strings.HasPrefix(line, "data: ") { + eventData := strings.TrimPrefix(line, "data: ") + + // Handle [DONE] marker + if strings.TrimSpace(eventData) == "[DONE]" { + provider.logger.Debug("Received [DONE] marker, ending stream") + return + } + + // Parse the unified streaming event + var event CohereStreamEvent + if err := sonic.Unmarshal([]byte(eventData), &event); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse stream event: %v", err)) + continue + } + + chunkIndex++ + + // Extract response ID from message-start events + if event.Type == StreamEventMessageStart && event.ID != nil { + responseID = *event.ID + } + + response, bifrostErr, isLastChunk := event.ToBifrostChatCompletionStream() + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ChatCompletionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + break + } + if response != nil { + response.ID = responseID + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + + lastChunkTime = time.Now() + chunkIndex++ + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = eventData + } + + if isLastChunk { + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + break + } + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + } + } + } + + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ChatCompletionStreamRequest, providerName, request.Model, provider.logger) + } + }() + + return responseChan, nil +} + +// Responses performs a responses request to the Cohere API using v2 converter. +func (provider *CohereProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + // Check if chat completion is allowed + if err := providerUtils.CheckOperationAllowed(schemas.Cohere, provider.customProviderConfig, schemas.ResponsesRequest); err != nil { + return nil, err + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToCohereResponsesRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Convert to Cohere v2 request + responseBody, latency, err := provider.completeRequest(ctx, jsonBody, provider.buildRequestURL(ctx, "/v2/chat", schemas.ResponsesRequest), key.Value) + if err != nil { + return nil, err + } + + // Create response object from pool + response := acquireCohereResponse() + defer releaseCohereResponse(response) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + bifrostResponse := response.ToBifrostResponsesResponse() + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.ResponsesRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// ResponsesStream performs a streaming responses request to the Cohere API. +func (provider *CohereProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if responses stream is allowed + if err := providerUtils.CheckOperationAllowed(schemas.Cohere, provider.customProviderConfig, schemas.ResponsesStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + // Convert to Cohere v2 request and add streaming + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToCohereResponsesRequest(request) + if reqBody != nil { + reqBody.Stream = schemas.Ptr(true) + } + return reqBody, nil + }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.buildRequestURL(ctx, "/v2/chat", schemas.ResponsesStreamRequest)) + req.Header.SetContentType("application/json") + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Set headers + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + req.SetBody(jsonBody) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, providerUtils.NewProviderAPIError(fmt.Sprintf("HTTP error from %s: %d", providerName, resp.StatusCode()), fmt.Errorf("%s", string(resp.Body())), resp.StatusCode(), providerName, nil, nil) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + chunkIndex := 0 + + startTime := time.Now() + lastChunkTime := startTime + + // Create stream state for stateful conversions (outside loop to persist across events) + streamState := acquireCohereResponsesStreamState() + streamState.Model = &request.Model + defer releaseCohereResponsesStreamState(streamState) + + // Track SSE event parsing state + var eventData string + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Parse SSE event - track event data + if after, ok := strings.CutPrefix(line, "data: "); ok { + eventData = after + } else { + continue + } + + // Skip if we don't have event data + if eventData == "" { + continue + } + + // Handle [DONE] marker + if strings.TrimSpace(eventData) == "[DONE]" { + provider.logger.Debug("Received [DONE] marker, ending stream") + return + } + + // Parse the unified streaming event + var event CohereStreamEvent + if err := sonic.Unmarshal([]byte(eventData), &event); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse stream event: %v", err)) + continue + } + + // Note: response.created and response.in_progress are now emitted by ToBifrostResponsesStream + // from the message_start event, so we don't need to call them manually here + + responses, bifrostErr, isLastChunk := event.ToBifrostResponsesStream(chunkIndex, streamState) + if bifrostErr != nil { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + break + } + // Handle each response in the slice + for i, response := range responses { + if response != nil { + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + lastChunkTime = time.Now() + chunkIndex++ + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = eventData + } + + if isLastChunk && i == len(responses)-1 { + if response.Response == nil { + response.Response = &schemas.BifrostResponsesResponse{} + } + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + return + } + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + } + } + + // Reset for next event + eventData = "" + } + + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading %s stream: %v", providerName, err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ResponsesStreamRequest, providerName, request.Model, provider.logger) + } + }() + + return responseChan, nil +} + +// Embedding generates embeddings for the given input text(s) using the Cohere API. +// Supports Cohere's embedding models and returns a BifrostResponse containing the embedding(s). +func (provider *CohereProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + // Check if embedding is allowed + if err := providerUtils.CheckOperationAllowed(schemas.Cohere, provider.customProviderConfig, schemas.EmbeddingRequest); err != nil { + return nil, err + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToCohereEmbeddingRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create Bifrost request for conversion + responseBody, latency, err := provider.completeRequest(ctx, jsonBody, provider.buildRequestURL(ctx, "/v2/embed", schemas.EmbeddingRequest), key.Value) + if err != nil { + return nil, err + } + + // Create response object from pool + response := acquireCohereEmbeddingResponse() + defer releaseCohereEmbeddingResponse(response) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + bifrostResponse := response.ToBifrostEmbeddingResponse() + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.EmbeddingRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// Speech is not supported by the Cohere provider. +func (provider *CohereProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Cohere provider. +func (provider *CohereProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Cohere provider. +func (provider *CohereProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Cohere provider. +func (provider *CohereProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/cohere/embedding.go b/core/providers/cohere/embedding.go new file mode 100644 index 000000000..3af681942 --- /dev/null +++ b/core/providers/cohere/embedding.go @@ -0,0 +1,124 @@ +package cohere + +import "github.com/maximhq/bifrost/core/schemas" + +// ToCohereEmbeddingRequest converts a Bifrost embedding request to Cohere format +func ToCohereEmbeddingRequest(bifrostReq *schemas.BifrostEmbeddingRequest) *CohereEmbeddingRequest { + if bifrostReq == nil || bifrostReq.Input == nil || (bifrostReq.Input.Text == nil && bifrostReq.Input.Texts == nil) { + return nil + } + + embeddingInput := bifrostReq.Input + cohereReq := &CohereEmbeddingRequest{ + Model: bifrostReq.Model, + } + + texts := []string{} + if embeddingInput.Text != nil { + texts = append(texts, *embeddingInput.Text) + } else { + texts = embeddingInput.Texts + } + + // Convert texts from Bifrost format + if len(texts) > 0 { + cohereReq.Texts = texts + } + + // Set default input type if not specified in extra params + cohereReq.InputType = "search_document" // Default value + + if bifrostReq.Params != nil { + cohereReq.OutputDimension = bifrostReq.Params.Dimensions + + if bifrostReq.Params.ExtraParams != nil { + if maxTokens, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["max_tokens"]); ok { + cohereReq.MaxTokens = maxTokens + } + } + } + + // Handle extra params + if bifrostReq.Params != nil && bifrostReq.Params.ExtraParams != nil { + // Input type + if inputType, ok := schemas.SafeExtractString(bifrostReq.Params.ExtraParams["input_type"]); ok { + cohereReq.InputType = inputType + } + + // Embedding types + if embeddingTypes, ok := schemas.SafeExtractStringSlice(bifrostReq.Params.ExtraParams["embedding_types"]); ok { + if len(embeddingTypes) > 0 { + cohereReq.EmbeddingTypes = embeddingTypes + } + } + + // Truncate + if truncate, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["truncate"]); ok { + cohereReq.Truncate = truncate + } + } + + return cohereReq +} + +// ToBifrostEmbeddingResponse converts a Cohere embedding response to Bifrost format +func (response *CohereEmbeddingResponse) ToBifrostEmbeddingResponse() *schemas.BifrostEmbeddingResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostEmbeddingResponse{ + Object: "list", + } + + // Convert embeddings data + if response.Embeddings != nil { + var bifrostEmbeddings []schemas.EmbeddingData + + // Handle different embedding types - prioritize float embeddings + if response.Embeddings.Float != nil { + for i, embedding := range response.Embeddings.Float { + bifrostEmbedding := schemas.EmbeddingData{ + Object: "embedding", + Index: i, + Embedding: schemas.EmbeddingStruct{ + EmbeddingArray: embedding, + }, + } + bifrostEmbeddings = append(bifrostEmbeddings, bifrostEmbedding) + } + } else if response.Embeddings.Base64 != nil { + // Handle base64 embeddings as strings + for i, embedding := range response.Embeddings.Base64 { + bifrostEmbedding := schemas.EmbeddingData{ + Object: "embedding", + Index: i, + Embedding: schemas.EmbeddingStruct{ + EmbeddingStr: &embedding, + }, + } + bifrostEmbeddings = append(bifrostEmbeddings, bifrostEmbedding) + } + } + // Note: Int8, Uint8, Binary, Ubinary types would need special handling + // depending on how Bifrost wants to represent them + + bifrostResponse.Data = bifrostEmbeddings + } + + // Convert usage information + if response.Meta != nil { + if response.Meta.Tokens != nil { + bifrostResponse.Usage = &schemas.BifrostLLMUsage{} + if response.Meta.Tokens.InputTokens != nil { + bifrostResponse.Usage.PromptTokens = int(*response.Meta.Tokens.InputTokens) + } + if response.Meta.Tokens.OutputTokens != nil { + bifrostResponse.Usage.CompletionTokens = int(*response.Meta.Tokens.OutputTokens) + } + bifrostResponse.Usage.TotalTokens = bifrostResponse.Usage.PromptTokens + bifrostResponse.Usage.CompletionTokens + } + } + + return bifrostResponse +} diff --git a/core/providers/cohere/models.go b/core/providers/cohere/models.go new file mode 100644 index 000000000..18319209c --- /dev/null +++ b/core/providers/cohere/models.go @@ -0,0 +1,24 @@ +package cohere + +import "github.com/maximhq/bifrost/core/schemas" + +func (response *CohereListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider) *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Models)), + } + + for _, model := range response.Models { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(providerKey) + "/" + model.Name, + Name: schemas.Ptr(model.Name), + ContextLength: schemas.Ptr(int(model.ContextLength)), + SupportedMethods: model.Endpoints, + }) + } + + return bifrostResponse +} diff --git a/core/providers/cohere/responses.go b/core/providers/cohere/responses.go new file mode 100644 index 000000000..8a14cca5b --- /dev/null +++ b/core/providers/cohere/responses.go @@ -0,0 +1,1059 @@ +package cohere + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// CohereResponsesStreamState tracks state during streaming conversion for responses API +type CohereResponsesStreamState struct { + ContentIndexToOutputIndex map[int]int // Maps Cohere content_index to OpenAI output_index + ToolArgumentBuffers map[int]string // Maps output_index to accumulated tool argument JSON + ItemIDs map[int]string // Maps output_index to item ID for stable IDs + CurrentOutputIndex int // Current output index counter + MessageID *string // Message ID from message_start + Model *string // Model name from message_start + CreatedAt int // Timestamp for created_at consistency + HasEmittedCreated bool // Whether we've emitted response.created + HasEmittedInProgress bool // Whether we've emitted response.in_progress + ToolPlanOutputIndex *int // Output index for tool plan text item (if created) +} + +// cohereResponsesStreamStatePool provides a pool for Cohere responses stream state objects. +var cohereResponsesStreamStatePool = sync.Pool{ + New: func() interface{} { + return &CohereResponsesStreamState{ + ContentIndexToOutputIndex: make(map[int]int), + ToolArgumentBuffers: make(map[int]string), + ItemIDs: make(map[int]string), + CurrentOutputIndex: 0, + CreatedAt: int(time.Now().Unix()), + HasEmittedCreated: false, + HasEmittedInProgress: false, + ToolPlanOutputIndex: nil, + } + }, +} + +// acquireCohereResponsesStreamState gets a Cohere responses stream state from the pool. +func acquireCohereResponsesStreamState() *CohereResponsesStreamState { + state := cohereResponsesStreamStatePool.Get().(*CohereResponsesStreamState) + // Clear maps (they're already initialized from New or previous flush) + // Only initialize if nil (shouldn't happen, but defensive) + if state.ContentIndexToOutputIndex == nil { + state.ContentIndexToOutputIndex = make(map[int]int) + } else { + clear(state.ContentIndexToOutputIndex) + } + if state.ToolArgumentBuffers == nil { + state.ToolArgumentBuffers = make(map[int]string) + } else { + clear(state.ToolArgumentBuffers) + } + if state.ItemIDs == nil { + state.ItemIDs = make(map[int]string) + } else { + clear(state.ItemIDs) + } + // Reset other fields + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + state.ToolPlanOutputIndex = nil + return state +} + +// releaseCohereResponsesStreamState returns a Cohere responses stream state to the pool. +func releaseCohereResponsesStreamState(state *CohereResponsesStreamState) { + if state != nil { + state.flush() // Clean before returning to pool + cohereResponsesStreamStatePool.Put(state) + } +} + +// flush resets the state of the stream state to its initial values +func (state *CohereResponsesStreamState) flush() { + // Clear maps (reuse if already initialized, otherwise initialize) + if state.ContentIndexToOutputIndex == nil { + state.ContentIndexToOutputIndex = make(map[int]int) + } else { + clear(state.ContentIndexToOutputIndex) + } + if state.ToolArgumentBuffers == nil { + state.ToolArgumentBuffers = make(map[int]string) + } else { + clear(state.ToolArgumentBuffers) + } + if state.ItemIDs == nil { + state.ItemIDs = make(map[int]string) + } else { + clear(state.ItemIDs) + } + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + state.ToolPlanOutputIndex = nil +} + +// getOrCreateOutputIndex returns the output index for a given content index, creating a new one if needed +func (state *CohereResponsesStreamState) getOrCreateOutputIndex(contentIndex *int) int { + if contentIndex == nil { + // If no content index, create a new output index + outputIndex := state.CurrentOutputIndex + state.CurrentOutputIndex++ + return outputIndex + } + + if outputIndex, exists := state.ContentIndexToOutputIndex[*contentIndex]; exists { + return outputIndex + } + + // Create new output index for this content index + outputIndex := state.CurrentOutputIndex + state.CurrentOutputIndex++ + state.ContentIndexToOutputIndex[*contentIndex] = outputIndex + return outputIndex +} + +// ToCohereResponsesRequest converts a BifrostRequest (Responses structure) to CohereChatRequest +func ToCohereResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *CohereChatRequest { + if bifrostReq == nil { + return nil + } + + cohereReq := &CohereChatRequest{ + Model: bifrostReq.Model, + } + + // Map basic parameters + if bifrostReq.Params != nil { + if bifrostReq.Params.MaxOutputTokens != nil { + cohereReq.MaxTokens = bifrostReq.Params.MaxOutputTokens + } + if bifrostReq.Params.Temperature != nil { + cohereReq.Temperature = bifrostReq.Params.Temperature + } + if bifrostReq.Params.TopP != nil { + cohereReq.P = bifrostReq.Params.TopP + } + if bifrostReq.Params.ExtraParams != nil { + if topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]); ok { + cohereReq.K = topK + } + if stop, ok := schemas.SafeExtractStringSlice(bifrostReq.Params.ExtraParams["stop"]); ok { + cohereReq.StopSequences = stop + } + if frequencyPenalty, ok := schemas.SafeExtractFloat64Pointer(bifrostReq.Params.ExtraParams["frequency_penalty"]); ok { + cohereReq.FrequencyPenalty = frequencyPenalty + } + if presencePenalty, ok := schemas.SafeExtractFloat64Pointer(bifrostReq.Params.ExtraParams["presence_penalty"]); ok { + cohereReq.PresencePenalty = presencePenalty + } + if thinkingParam, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "thinking"); ok { + if thinkingMap, ok := thinkingParam.(map[string]interface{}); ok { + thinking := &CohereThinking{} + if typeStr, ok := schemas.SafeExtractString(thinkingMap["type"]); ok { + thinking.Type = CohereThinkingType(typeStr) + } + if tokenBudget, ok := schemas.SafeExtractIntPointer(thinkingMap["token_budget"]); ok { + thinking.TokenBudget = tokenBudget + } + cohereReq.Thinking = thinking + } + } + } + } + + // Convert tools + if bifrostReq.Params != nil && bifrostReq.Params.Tools != nil { + var cohereTools []CohereChatRequestTool + for _, tool := range bifrostReq.Params.Tools { + if tool.ResponsesToolFunction != nil && tool.Name != nil { + cohereTool := CohereChatRequestTool{ + Type: "function", + Function: CohereChatRequestFunction{ + Name: *tool.Name, + Description: tool.Description, + Parameters: tool.ResponsesToolFunction.Parameters, + }, + } + cohereTools = append(cohereTools, cohereTool) + } + } + + if len(cohereTools) > 0 { + cohereReq.Tools = cohereTools + } + } + + // Convert tool choice + if bifrostReq.Params != nil && bifrostReq.Params.ToolChoice != nil { + cohereReq.ToolChoice = convertBifrostToolChoiceToCohereToolChoice(*bifrostReq.Params.ToolChoice) + } + + // Process ResponsesInput (which contains the Responses items) + if bifrostReq.Input != nil { + cohereReq.Messages = convertResponsesMessagesToCohereMessages(bifrostReq.Input) + } + + return cohereReq +} + +// ToBifrostResponsesResponse converts CohereChatResponse to BifrostResponse (Responses structure) +func (response *CohereChatResponse) ToBifrostResponsesResponse() *schemas.BifrostResponsesResponse { + if response == nil { + return nil + } + + bifrostResp := &schemas.BifrostResponsesResponse{ + ID: schemas.Ptr(response.ID), + CreatedAt: int(time.Now().Unix()), // Set current timestamp + } + + // Convert usage information + if response.Usage != nil { + usage := &schemas.ResponsesResponseUsage{} + + if response.Usage.Tokens != nil { + if response.Usage.Tokens.InputTokens != nil { + usage.InputTokens = *response.Usage.Tokens.InputTokens + } + if response.Usage.Tokens.OutputTokens != nil { + usage.OutputTokens = *response.Usage.Tokens.OutputTokens + } + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } + + if response.Usage.CachedTokens != nil { + usage.InputTokensDetails = &schemas.ResponsesResponseInputTokens{ + CachedTokens: *response.Usage.CachedTokens, + } + } + + bifrostResp.Usage = usage + } + + // Convert output message to Responses format + if response.Message != nil { + outputMessages := convertCohereMessageToResponsesOutput(*response.Message) + bifrostResp.Output = outputMessages + } + + return bifrostResp +} + +// Helper functions + +// convertBifrostToolChoiceToCohere converts schemas.ToolChoice to CohereToolChoice +func convertBifrostToolChoiceToCohereToolChoice(toolChoice schemas.ResponsesToolChoice) *CohereToolChoice { + toolChoiceString := toolChoice.ResponsesToolChoiceStr + + if toolChoiceString != nil { + switch *toolChoiceString { + case "none": + choice := ToolChoiceNone + return &choice + case "required", "auto", "function": + choice := ToolChoiceRequired + return &choice + default: + choice := ToolChoiceRequired + return &choice + } + } + + return nil +} + +// convertResponsesMessagesToCohereMessages converts Responses items to Cohere messages +func convertResponsesMessagesToCohereMessages(messages []schemas.ResponsesMessage) []CohereMessage { + var cohereMessages []CohereMessage + var systemContent []string + + for _, msg := range messages { + // Handle nil Type with default + msgType := schemas.ResponsesMessageTypeMessage + if msg.Type != nil { + msgType = *msg.Type + } + + switch msgType { + case schemas.ResponsesMessageTypeMessage: + // Handle nil Role with default + role := "user" + if msg.Role != nil { + role = string(*msg.Role) + } + + if role == "system" { + // Collect system messages separately for Cohere + if msg.Content != nil { + if msg.Content.ContentStr != nil { + systemContent = append(systemContent, *msg.Content.ContentStr) + } else if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + systemContent = append(systemContent, *block.Text) + } + } + } + } + } else { + cohereMsg := CohereMessage{ + Role: role, + } + + // Convert content - only if Content is not nil + if msg.Content != nil { + if msg.Content.ContentStr != nil { + cohereMsg.Content = NewStringContent(*msg.Content.ContentStr) + } else if msg.Content.ContentBlocks != nil { + contentBlocks := convertResponsesMessageContentBlocksToCohere(msg.Content.ContentBlocks) + cohereMsg.Content = NewBlocksContent(contentBlocks) + } + } + + cohereMessages = append(cohereMessages, cohereMsg) + } + + case "function_call": + // Handle function calls from Responses + assistantMsg := CohereMessage{ + Role: "assistant", + } + + // Extract function call details + var cohereToolCalls []CohereToolCall + toolCall := CohereToolCall{ + Type: "function", + Function: &CohereFunction{}, + } + + if msg.ID != nil { + toolCall.ID = msg.ID + } + + // Get function details from AssistantMessage + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Arguments != nil { + toolCall.Function.Arguments = *msg.ResponsesToolMessage.Arguments + } + + // Get name from ToolMessage if available + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Name != nil { + toolCall.Function.Name = msg.ResponsesToolMessage.Name + } + + cohereToolCalls = append(cohereToolCalls, toolCall) + + if len(cohereToolCalls) > 0 { + assistantMsg.ToolCalls = cohereToolCalls + } + + cohereMessages = append(cohereMessages, assistantMsg) + + case "function_call_output": + // Handle function call outputs + if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.CallID != nil { + toolMsg := CohereMessage{ + Role: "tool", + } + + // Extract content from ResponsesFunctionToolCallOutput if Content is not set + // This is needed for OpenAI Responses API which uses an "output" field + content := msg.Content + if content == nil && msg.ResponsesToolMessage.Output != nil { + content = &schemas.ResponsesMessageContent{} + if msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + content.ContentStr = msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr + } else if msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks != nil { + content.ContentBlocks = msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks + } + } + + // Convert content - only if Content is not nil + if content != nil { + if content.ContentStr != nil { + toolMsg.Content = NewStringContent(*content.ContentStr) + } else if content.ContentBlocks != nil { + contentBlocks := convertResponsesMessageContentBlocksToCohere(content.ContentBlocks) + toolMsg.Content = NewBlocksContent(contentBlocks) + } + } + + toolMsg.ToolCallID = msg.ResponsesToolMessage.CallID + + cohereMessages = append(cohereMessages, toolMsg) + } + } + } + + // Prepend system messages if any + if len(systemContent) > 0 { + systemMsg := CohereMessage{ + Role: "system", + Content: NewStringContent(strings.Join(systemContent, "\n")), + } + cohereMessages = append([]CohereMessage{systemMsg}, cohereMessages...) + } + + return cohereMessages +} + +// convertBifrostContentBlocksToCohere converts Bifrost content blocks to Cohere format +func convertResponsesMessageContentBlocksToCohere(blocks []schemas.ResponsesMessageContentBlock) []CohereContentBlock { + var cohereBlocks []CohereContentBlock + + for _, block := range blocks { + switch block.Type { + case schemas.ResponsesInputMessageContentBlockTypeText: + if block.Text != nil { + cohereBlocks = append(cohereBlocks, CohereContentBlock{ + Type: CohereContentBlockTypeText, + Text: block.Text, + }) + } + case schemas.ResponsesInputMessageContentBlockTypeImage: + if block.ResponsesInputMessageContentBlockImage != nil && block.ResponsesInputMessageContentBlockImage.ImageURL != nil && *block.ResponsesInputMessageContentBlockImage.ImageURL != "" { + cohereBlocks = append(cohereBlocks, CohereContentBlock{ + Type: CohereContentBlockTypeImage, + ImageURL: &CohereImageURL{ + URL: *block.ResponsesInputMessageContentBlockImage.ImageURL, + }, + }) + } + case schemas.ResponsesOutputMessageContentTypeReasoning: + if block.Text != nil { + cohereBlocks = append(cohereBlocks, CohereContentBlock{ + Type: CohereContentBlockTypeThinking, + Thinking: block.Text, + }) + } + } + } + + return cohereBlocks +} + +// convertCohereMessageToResponsesOutput converts Cohere message to Responses output format +func convertCohereMessageToResponsesOutput(cohereMsg CohereMessage) []schemas.ResponsesMessage { + var outputMessages []schemas.ResponsesMessage + + // Handle text content first + if cohereMsg.Content != nil { + var content schemas.ResponsesMessageContent + + var contentBlocks []schemas.ResponsesMessageContentBlock + + if cohereMsg.Content.StringContent != nil { + contentBlocks = append(contentBlocks, schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: cohereMsg.Content.StringContent, + }) + } else if cohereMsg.Content.BlocksContent != nil { + // Convert content blocks + for _, block := range cohereMsg.Content.BlocksContent { + contentBlocks = append(contentBlocks, convertCohereContentBlockToBifrost(block)) + } + } + content.ContentBlocks = contentBlocks + + // Create message output + if content.ContentBlocks != nil { + outputMsg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &content, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + } + + outputMessages = append(outputMessages, outputMsg) + } + } + + // Handle tool calls + if cohereMsg.ToolCalls != nil { + for _, toolCall := range cohereMsg.ToolCalls { + // Check if Function is nil to avoid nil pointer dereference + if toolCall.Function == nil { + // Skip this tool call if Function is nil + continue + } + + // Safely extract function name and arguments + var functionName *string + var functionArguments *string + + if toolCall.Function.Name != nil { + functionName = toolCall.Function.Name + } else { + // Use empty string if Name is nil + functionName = schemas.Ptr("") + } + + // Arguments is a string, not a pointer, so it's safe to access directly + functionArguments = schemas.Ptr(toolCall.Function.Arguments) + + toolCallMsg := schemas.ResponsesMessage{ + ID: toolCall.ID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: schemas.Ptr("completed"), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + Name: functionName, + CallID: toolCall.ID, + Arguments: functionArguments, + }, + } + + outputMessages = append(outputMessages, toolCallMsg) + } + } + + return outputMessages +} + +// convertCohereContentBlockToBifrost converts CohereContentBlock to schemas.ContentBlock for Responses +func convertCohereContentBlockToBifrost(cohereBlock CohereContentBlock) schemas.ResponsesMessageContentBlock { + switch cohereBlock.Type { + case CohereContentBlockTypeText: + return schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeText, + Text: cohereBlock.Text, + } + case CohereContentBlockTypeImage: + // For images, create a text block describing the image + if cohereBlock.ImageURL == nil { + // Skip invalid image blocks without ImageURL + return schemas.ResponsesMessageContentBlock{} + } + return schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeImage, + ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{ + ImageURL: &cohereBlock.ImageURL.URL, + }, + } + case CohereContentBlockTypeThinking: + return schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesOutputMessageContentTypeReasoning, + Text: cohereBlock.Thinking, + } + default: + // Fallback to text block + return schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeText, + Text: schemas.Ptr(string(cohereBlock.Type)), + } + } +} + +func (chunk *CohereStreamEvent) ToBifrostResponsesStream(sequenceNumber int, state *CohereResponsesStreamState) ([]*schemas.BifrostResponsesStreamResponse, *schemas.BifrostError, bool) { + switch chunk.Type { + case StreamEventMessageStart: + // Message start - emit response.created and response.in_progress (OpenAI-style lifecycle) + if chunk.ID != nil { + state.MessageID = chunk.ID + // Use the state's CreatedAt for consistency + if state.CreatedAt == 0 { + state.CreatedAt = int(time.Now().Unix()) + } + + var responses []*schemas.BifrostResponsesStreamResponse + + // Emit response.created + if !state.HasEmittedCreated { + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeCreated, + SequenceNumber: sequenceNumber, + Response: response, + }) + state.HasEmittedCreated = true + } + + // Emit response.in_progress + if !state.HasEmittedInProgress { + response := &schemas.BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, // Use same timestamp + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeInProgress, + SequenceNumber: sequenceNumber + len(responses), + Response: response, + }) + state.HasEmittedInProgress = true + } + + if len(responses) > 0 { + return responses, nil, false + } + } + case StreamEventContentStart: + // Content block start - emit output_item.added (OpenAI-style) + // First, close tool plan message item if it's still open + var responses []*schemas.BifrostResponsesStreamResponse + if state.ToolPlanOutputIndex != nil { + outputIndex := *state.ToolPlanOutputIndex + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), + Item: doneItem, + }) + state.ToolPlanOutputIndex = nil // Mark as closed + } + + if chunk.Delta != nil && chunk.Index != nil && chunk.Delta.Message != nil && chunk.Delta.Message.Content != nil && chunk.Delta.Message.Content.CohereStreamContentObject != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + + switch chunk.Delta.Message.Content.CohereStreamContentObject.Type { + case CohereContentBlockTypeText: + // Text block - emit output_item.added with type "message" + messageType := schemas.ResponsesMessageTypeMessage + role := schemas.ResponsesInputMessageRoleAssistant + + // Generate stable ID for text item + var itemID string + if state.MessageID == nil { + itemID = fmt.Sprintf("item_%d", outputIndex) + } else { + itemID = fmt.Sprintf("msg_%s_item_%d", *state.MessageID, outputIndex) + } + if state.MessageID == nil { + itemID = fmt.Sprintf("item_%d", outputIndex) + } + state.ItemIDs[outputIndex] = itemID + + item := &schemas.ResponsesMessage{ + ID: &itemID, + Type: &messageType, + Role: &role, + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{}, // Empty blocks slice for mutation support + }, + } + + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }) + return responses, nil, false + case CohereContentBlockTypeThinking: + // Thinking/reasoning content - emit as reasoning item + messageType := schemas.ResponsesMessageTypeReasoning + role := schemas.ResponsesInputMessageRoleAssistant + + // Generate stable ID for reasoning item + itemID := fmt.Sprintf("msg_%s_reasoning_%d", *state.MessageID, outputIndex) + if state.MessageID == nil { + itemID = fmt.Sprintf("reasoning_%d", outputIndex) + } + state.ItemIDs[outputIndex] = itemID + + item := &schemas.ResponsesMessage{ + ID: &itemID, + Type: &messageType, + Role: &role, + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{}, + }, + } + + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: item, + }) + return responses, nil, false + } + } + if len(responses) > 0 { + return responses, nil, false + } + case StreamEventContentDelta: + if chunk.Index != nil && chunk.Delta != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + + // Handle text content delta + if chunk.Delta.Message != nil && chunk.Delta.Message.Content != nil && chunk.Delta.Message.Content.CohereStreamContentObject != nil && chunk.Delta.Message.Content.CohereStreamContentObject.Text != nil && *chunk.Delta.Message.Content.CohereStreamContentObject.Text != "" { + // Emit output_text.delta (not reasoning_summary_text.delta for regular text) + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputTextDelta, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Delta: chunk.Delta.Message.Content.CohereStreamContentObject.Text, + } + if itemID != "" { + response.ItemID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{response}, nil, false + } + } + return nil, nil, false + case StreamEventContentEnd: + // Content block is complete - emit output_item.done (OpenAI-style) + if chunk.Index != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: doneItem, + }}, nil, false + } + case StreamEventToolPlanDelta: + if chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.ToolPlan != nil && *chunk.Delta.Message.ToolPlan != "" { + // Tool plan delta - treat as normal text (Option A) + // Use output_index 0 for text message if it exists, otherwise create new + outputIndex := 0 + var responses []*schemas.BifrostResponsesStreamResponse + + if state.ToolPlanOutputIndex != nil { + outputIndex = *state.ToolPlanOutputIndex + } else { + // Create message item first if it doesn't exist + outputIndex = 0 + state.ToolPlanOutputIndex = &outputIndex + state.ContentIndexToOutputIndex[0] = outputIndex + + // Generate stable ID for text item + // Generate stable ID for text item + var itemID string + if state.MessageID == nil { + itemID = fmt.Sprintf("item_%d", outputIndex) + } else { + itemID = fmt.Sprintf("msg_%s_item_%d", *state.MessageID, outputIndex) + } + state.ItemIDs[outputIndex] = itemID + + messageType := schemas.ResponsesMessageTypeMessage + role := schemas.ResponsesInputMessageRoleAssistant + + item := &schemas.ResponsesMessage{ + ID: &itemID, + Type: &messageType, + Role: &role, + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{}, + }, + } + + // Emit output_item.added for text message + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), + Item: item, + }) + } + + // Emit output_text.delta (not reasoning_summary_text.delta) + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputTextDelta, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), // Tool plan is typically at index 0 + Delta: chunk.Delta.Message.ToolPlan, + } + if itemID != "" { + response.ItemID = &itemID + } + responses = append(responses, response) + return responses, nil, false + } + return nil, nil, false + case StreamEventToolCallStart: + // First, close tool plan message item if it's still open + var responses []*schemas.BifrostResponsesStreamResponse + if state.ToolPlanOutputIndex != nil { + outputIndex := *state.ToolPlanOutputIndex + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: schemas.Ptr(0), + Item: doneItem, + }) + state.ToolPlanOutputIndex = nil // Mark as closed + } + + if chunk.Index != nil && chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.ToolCalls != nil && chunk.Delta.Message.ToolCalls.CohereToolCallObject != nil { + // Tool call start - emit output_item.added with type "function_call" and status "in_progress" + toolCall := chunk.Delta.Message.ToolCalls.CohereToolCallObject + if toolCall.Function != nil && toolCall.Function.Name != nil { + // Always use a new output index for tool calls to avoid collision with text items + // Use output_index 1 (or next available) to avoid collision with text at index 0 + outputIndex := state.CurrentOutputIndex + if outputIndex == 0 { + outputIndex = 1 // Skip 0 if it's used for text + } + state.CurrentOutputIndex = outputIndex + 1 + // Optionally map the content index if provided + if chunk.Index != nil { + state.ContentIndexToOutputIndex[*chunk.Index] = outputIndex + } + + statusInProgress := "in_progress" + itemID := "" + if toolCall.ID != nil { + itemID = *toolCall.ID + state.ItemIDs[outputIndex] = itemID + } + + item := &schemas.ResponsesMessage{ + ID: toolCall.ID, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + Status: &statusInProgress, + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: toolCall.ID, + Name: toolCall.Function.Name, + Arguments: schemas.Ptr(""), // Arguments will be filled by deltas + }, + } + + // Initialize argument buffer for this tool call + state.ToolArgumentBuffers[outputIndex] = "" + + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + Item: item, + }) + return responses, nil, false + } + } + if len(responses) > 0 { + return responses, nil, false + } + return nil, nil, false + case StreamEventToolCallDelta: + if chunk.Index != nil && chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.ToolCalls != nil && chunk.Delta.Message.ToolCalls.CohereToolCallObject != nil { + // Tool call delta - handle function arguments streaming + toolCall := chunk.Delta.Message.ToolCalls.CohereToolCallObject + if toolCall.Function != nil { + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + + // Accumulate tool arguments in buffer + if _, exists := state.ToolArgumentBuffers[outputIndex]; !exists { + state.ToolArgumentBuffers[outputIndex] = "" + } + state.ToolArgumentBuffers[outputIndex] += toolCall.Function.Arguments + + // Emit function_call_arguments.delta + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta, + SequenceNumber: sequenceNumber, + ContentIndex: chunk.Index, + OutputIndex: schemas.Ptr(outputIndex), + Delta: schemas.Ptr(toolCall.Function.Arguments), + } + if itemID != "" { + response.ItemID = &itemID + } + return []*schemas.BifrostResponsesStreamResponse{response}, nil, false + } + } + return nil, nil, false + case StreamEventToolCallEnd: + if chunk.Index != nil { + // Tool call end - emit function_call_arguments.done then output_item.done + outputIndex := state.getOrCreateOutputIndex(chunk.Index) + var responses []*schemas.BifrostResponsesStreamResponse + + // Emit function_call_arguments.done with full accumulated JSON + if accumulatedArgs, hasArgs := state.ToolArgumentBuffers[outputIndex]; hasArgs && accumulatedArgs != "" { + itemID := state.ItemIDs[outputIndex] + response := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDone, + SequenceNumber: sequenceNumber, + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Arguments: &accumulatedArgs, + } + if itemID != "" { + response.ItemID = &itemID + } + responses = append(responses, response) + // Clear the buffer + delete(state.ToolArgumentBuffers, outputIndex) + } + + // Emit output_item.done for the function call + statusCompleted := "completed" + itemID := state.ItemIDs[outputIndex] + doneItem := &schemas.ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: sequenceNumber + len(responses), + OutputIndex: schemas.Ptr(outputIndex), + ContentIndex: chunk.Index, + Item: doneItem, + }) + + return responses, nil, false + } + return nil, nil, false + case StreamEventCitationStart: + if chunk.Index != nil && chunk.Delta != nil && chunk.Delta.Message != nil && chunk.Delta.Message.Citations != nil { + // Citation start - create annotation for the citation + citation := chunk.Delta.Message.Citations.CohereStreamCitationObject + + // Map Cohere citation to ResponsesOutputMessageContentTextAnnotation + annotation := &schemas.ResponsesOutputMessageContentTextAnnotation{ + Type: "file_citation", // Default to file_citation + StartIndex: schemas.Ptr(citation.Start), + EndIndex: schemas.Ptr(citation.End), + } + + // Set annotation type and metadata + if len(citation.Sources) > 0 { + source := citation.Sources[0] + + if source.ID != nil { + annotation.FileID = source.ID + } + + if source.Document != nil { + if title, ok := (*source.Document)["title"].(string); ok { + annotation.Title = &title + } + if id, ok := (*source.Document)["id"].(string); ok && annotation.FileID == nil { + annotation.FileID = &id + } + if snippet, ok := (*source.Document)["snippet"].(string); ok { + annotation.Text = &snippet + } + if url, ok := (*source.Document)["url"].(string); ok { + annotation.URL = &url + } + } + } + + // Use output_index based on content index for citations (they're part of the text item) + outputIndex := 0 + if citation.ContentIndex >= 0 { + contentIndexPtr := &citation.ContentIndex + outputIndex = state.getOrCreateOutputIndex(contentIndexPtr) + } + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputTextAnnotationAdded, + SequenceNumber: sequenceNumber, + ContentIndex: schemas.Ptr(citation.ContentIndex), + Annotation: annotation, + OutputIndex: schemas.Ptr(outputIndex), + AnnotationIndex: chunk.Index, + }}, nil, false + } + return nil, nil, false + case StreamEventCitationEnd: + if chunk.Index != nil { + // Citation end - indicate annotation is complete + outputIndex := 0 + if chunk.Index != nil { + outputIndex = state.getOrCreateOutputIndex(chunk.Index) + } + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeOutputTextAnnotationDone, + SequenceNumber: sequenceNumber, + ContentIndex: chunk.Index, + OutputIndex: schemas.Ptr(outputIndex), + AnnotationIndex: chunk.Index, + }}, nil, false + } + return nil, nil, false + case StreamEventMessageEnd: + // Message end - emit response.completed (OpenAI-style) + response := &schemas.BifrostResponsesResponse{ + CreatedAt: state.CreatedAt, + } + if state.MessageID != nil { + response.ID = state.MessageID + } + + if chunk.Delta != nil { + if chunk.Delta.Usage != nil { + usage := &schemas.ResponsesResponseUsage{} + + if chunk.Delta.Usage.Tokens != nil { + if chunk.Delta.Usage.Tokens.InputTokens != nil { + usage.InputTokens = *chunk.Delta.Usage.Tokens.InputTokens + } + if chunk.Delta.Usage.Tokens.OutputTokens != nil { + usage.OutputTokens = *chunk.Delta.Usage.Tokens.OutputTokens + } + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } + + if chunk.Delta.Usage.CachedTokens != nil { + usage.InputTokensDetails = &schemas.ResponsesResponseInputTokens{ + CachedTokens: *chunk.Delta.Usage.CachedTokens, + } + } + response.Usage = usage + } + } + + return []*schemas.BifrostResponsesStreamResponse{{ + Type: schemas.ResponsesStreamResponseTypeCompleted, + SequenceNumber: sequenceNumber, + Response: response, + }}, nil, true + case StreamEventDebug: + return nil, nil, false + } + return nil, nil, false +} diff --git a/core/providers/cohere/types.go b/core/providers/cohere/types.go new file mode 100644 index 000000000..ceb3b31ed --- /dev/null +++ b/core/providers/cohere/types.go @@ -0,0 +1,544 @@ +package cohere + +import ( + "encoding/json" + "fmt" + + "github.com/bytedance/sonic" +) + +// ==================== REQUEST TYPES ==================== + +// CohereChatRequest represents a Cohere chat completion request +type CohereChatRequest struct { + Model string `json:"model"` // Required: Model to use for chat completion + Messages []CohereMessage `json:"messages"` // Required: Array of message objects + Tools []CohereChatRequestTool `json:"tools,omitempty"` // Optional: Tools available for the model + ToolChoice *CohereToolChoice `json:"tool_choice,omitempty"` // Optional: Tool choice configuration + Temperature *float64 `json:"temperature,omitempty"` // Optional: Sampling temperature + P *float64 `json:"p,omitempty"` // Optional: Top-p sampling + K *int `json:"k,omitempty"` // Optional: Top-k sampling + MaxTokens *int `json:"max_tokens,omitempty"` // Optional: Maximum tokens to generate + StopSequences []string `json:"stop_sequences,omitempty"` // Optional: Stop sequences + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // Optional: Frequency penalty + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // Optional: Presence penalty + Stream *bool `json:"stream,omitempty"` // Optional: Enable streaming + SafetyMode *string `json:"safety_mode,omitempty"` // Optional: Safety mode + LogProbs *bool `json:"log_probs,omitempty"` // Optional: Log probabilities + StrictToolChoice *bool `json:"strict_tool_choice,omitempty"` // Optional: Strict tool choice + Thinking *CohereThinking `json:"thinking,omitempty"` // Optional: Reasoning configuration +} + +type CohereChatRequestTool struct { + Type string `json:"type"` // always "function" + Function CohereChatRequestFunction `json:"function"` +} + +type CohereChatRequestFunction struct { + Name string `json:"name"` // Function name + Parameters interface{} `json:"parameters,omitempty"` // Function parameters (JSON string) + Description *string `json:"description,omitempty"` // Optional: Function description +} + +// CohereMessage represents a message in Cohere format +type CohereMessage struct { + Role string `json:"role"` // Required: Message role (system, user, assistant, tool) + Content *CohereMessageContent `json:"content,omitempty"` // Optional: Message content (string or array of content blocks) + ToolCalls []CohereToolCall `json:"tool_calls,omitempty"` // Optional: Tool calls (for assistant messages) + ToolCallID *string `json:"tool_call_id,omitempty"` // Optional: Tool call ID (for tool messages) + ToolPlan *string `json:"tool_plan,omitempty"` // Optional: Chain-of-thought style reflection (assistant only) +} + +// CohereMessageContent represents flexible content that can be string or content blocks +type CohereMessageContent struct { + // Use custom marshaling to handle string or []CohereContentBlock + StringContent *string `json:"-"` + BlocksContent []CohereContentBlock `json:"-"` +} + +// MarshalJSON implements custom JSON marshaling for CohereMessageContent +func (c *CohereMessageContent) MarshalJSON() ([]byte, error) { + if c.StringContent != nil { + return json.Marshal(*c.StringContent) + } + if c.BlocksContent != nil { + return json.Marshal(c.BlocksContent) + } + return []byte("null"), nil +} + +// UnmarshalJSON implements custom JSON unmarshaling for CohereMessageContent +func (c *CohereMessageContent) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first + var str string + if err := json.Unmarshal(data, &str); err == nil { + c.StringContent = &str + return nil + } + + // Try to unmarshal as content blocks array + var blocks []CohereContentBlock + if err := json.Unmarshal(data, &blocks); err == nil { + c.BlocksContent = blocks + return nil + } + + return fmt.Errorf("content must be either string or array of content blocks") +} + +// Helper methods for CohereMessageContent + +// NewStringContent creates a CohereMessageContent with string content +func NewStringContent(content string) *CohereMessageContent { + return &CohereMessageContent{ + StringContent: &content, + } +} + +// NewBlocksContent creates a CohereMessageContent with content blocks +func NewBlocksContent(blocks []CohereContentBlock) *CohereMessageContent { + return &CohereMessageContent{ + BlocksContent: blocks, + } +} + +// IsString returns true if content is a string +func (c *CohereMessageContent) IsString() bool { + return c.StringContent != nil +} + +// IsBlocks returns true if content is content blocks +func (c *CohereMessageContent) IsBlocks() bool { + return c.BlocksContent != nil +} + +// GetString returns the string content (nil if not string) +func (c *CohereMessageContent) GetString() *string { + return c.StringContent +} + +// GetBlocks returns the content blocks (nil if not blocks) +func (c *CohereMessageContent) GetBlocks() []CohereContentBlock { + return c.BlocksContent +} + +type CohereContentBlockType string + +const ( + CohereContentBlockTypeText CohereContentBlockType = "text" + CohereContentBlockTypeImage CohereContentBlockType = "image_url" + CohereContentBlockTypeThinking CohereContentBlockType = "thinking" + CohereContentBlockTypeDocument CohereContentBlockType = "document" +) + +// CohereContentBlock represents a content block in Cohere format +// This is a union type that can be text, image_url, thinking, or document +type CohereContentBlock struct { + Type CohereContentBlockType `json:"type"` // Required: Content block type + + // Text content block + Text *string `json:"text,omitempty"` + + // Image URL content block + ImageURL *CohereImageURL `json:"image_url,omitempty"` + + // Thinking content block (assistant only) + Thinking *string `json:"thinking,omitempty"` + + // Document content block (tool messages) + Document *CohereDocument `json:"document,omitempty"` +} + +// CohereImageURL represents an image URL content block +type CohereImageURL struct { + URL string `json:"url"` // Required: Image URL +} + +// CohereDocument represents a document content block +type CohereDocument struct { + Data map[string]interface{} `json:"data"` // Required: Document data as key-value pairs + ID *string `json:"id,omitempty"` // Optional: Document ID for citations +} + +// CohereThinking represents reasoning configuration +type CohereThinking struct { + Type CohereThinkingType `json:"type"` // Required: Reasoning type (enabled, disabled) + TokenBudget *int `json:"token_budget,omitempty"` // Optional: Maximum thinking tokens (>=1) +} + +// CohereThinkingType represents the type of reasoning +type CohereThinkingType string + +const ( + ThinkingTypeEnabled CohereThinkingType = "enabled" + ThinkingTypeDisabled CohereThinkingType = "disabled" +) + +// CohereToolChoice represents tool choice configuration +type CohereToolChoice string + +const ( + ToolChoiceRequired CohereToolChoice = "REQUIRED" + ToolChoiceNone CohereToolChoice = "NONE" + ToolChoiceAuto CohereToolChoice = "AUTO" +) + +// CohereToolCall represents a tool call in Cohere format +type CohereToolCall struct { + ID *string `json:"id,omitempty"` // Optional: Tool call ID + Type string `json:"type"` // Required: Tool call type (must be "function") + Function *CohereFunction `json:"function"` // Required: Function call details +} + +// CohereFunction represents a function call +type CohereFunction struct { + Name *string `json:"name,omitempty"` // Optional: Function name + Arguments string `json:"arguments,omitempty"` // Optional: Function arguments (JSON string) +} + +// CohereParameterDefinition represents a parameter definition for a Cohere tool. +// It defines the type, description, and whether the parameter is required. +type CohereParameterDefinition struct { + Type string `json:"type"` // Type of the parameter + Description *string `json:"description,omitempty"` // Optional description of the parameter + Required bool `json:"required"` // Whether the parameter is required +} + +// CohereTool represents a tool definition for the Cohere API. +// It includes the tool's name, description, and parameter definitions. +type CohereTool struct { + Name string `json:"name"` // Name of the tool + Description string `json:"description"` // Description of the tool + ParameterDefinitions map[string]CohereParameterDefinition `json:"parameter_definitions"` // Definitions of the tool's parameters +} + +// CohereEmbeddingRequest represents a Cohere embedding request +type CohereEmbeddingRequest struct { + Model string `json:"model"` // Required: ID of embedding model + InputType string `json:"input_type"` // Required: Type of input for v3+ models + Texts []string `json:"texts,omitempty"` // Optional: Array of strings to embed (max 96) + Images []string `json:"images,omitempty"` // Optional: Array of image data URIs (max 1) + Inputs []CohereEmbeddingInput `json:"inputs,omitempty"` // Optional: Array of mixed text/image inputs (max 96) + MaxTokens *int `json:"max_tokens,omitempty"` // Optional: Max tokens to embed per input + OutputDimension *int `json:"output_dimension,omitempty"` // Optional: Embedding dimensions (256, 512, 1024, 1536) + EmbeddingTypes []string `json:"embedding_types,omitempty"` // Optional: Types of embeddings to return + Truncate *string `json:"truncate,omitempty"` // Optional: How to handle long inputs +} + +// CohereEmbeddingInput represents a mixed text/image input +type CohereEmbeddingInput struct { + Content []CohereContentBlock `json:"content"` // Required: Array of content blocks (reuses chat content blocks) +} + +// CohereEmbeddingResponse represents a Cohere embedding response +type CohereEmbeddingResponse struct { + ID string `json:"id"` // Response ID + Embeddings *CohereEmbeddingData `json:"embeddings,omitempty"` // Embedding data object + ResponseType *string `json:"response_type,omitempty"` // Response type (embeddings_floats, embeddings_by_type) + Texts []string `json:"texts,omitempty"` // Original text entries + Images []CohereEmbeddingImageInfo `json:"images,omitempty"` // Original image entries + Meta *CohereEmbeddingMeta `json:"meta,omitempty"` // Response metadata +} + +// CohereEmbeddingData represents the embeddings object with different types +type CohereEmbeddingData struct { + Float [][]float32 `json:"float,omitempty"` // Float embeddings + Int8 [][]int8 `json:"int8,omitempty"` // Int8 embeddings + Uint8 [][]uint8 `json:"uint8,omitempty"` // Uint8 embeddings + Binary [][]int8 `json:"binary,omitempty"` // Binary embeddings + Ubinary [][]uint8 `json:"ubinary,omitempty"` // Unsigned binary embeddings + Base64 []string `json:"base64,omitempty"` // Base64 embeddings +} + +// CohereEmbeddingImageInfo represents image information in the response +type CohereEmbeddingImageInfo struct { + Width int64 `json:"width"` // Width in pixels + Height int64 `json:"height"` // Height in pixels + Format string `json:"format"` // Image format + BitDepth int64 `json:"bit_depth"` // Bit depth +} + +// CohereEmbeddingMeta represents metadata in embedding response +type CohereEmbeddingMeta struct { + APIVersion *CohereEmbeddingAPIVersion `json:"api_version,omitempty"` // API version info + BilledUnits *CohereBilledUnits `json:"billed_units,omitempty"` // Billing information + Tokens *CohereTokenUsage `json:"tokens,omitempty"` // Token usage + Warnings []string `json:"warnings,omitempty"` // Any warnings +} + +// CohereEmbeddingAPIVersion represents API version information +type CohereEmbeddingAPIVersion struct { + Version *string `json:"version,omitempty"` // API version + IsDeprecated *bool `json:"is_deprecated,omitempty"` // Deprecation status + IsExperimental *bool `json:"is_experimental,omitempty"` // Experimental status +} + +// ==================== RESPONSE TYPES ==================== + +// CohereChatResponse represents a Cohere chat completion response +type CohereChatResponse struct { + ID string `json:"id"` // Unique identifier for the generated reply + FinishReason *CohereFinishReason `json:"finish_reason,omitempty"` // Reason for completion + Message *CohereMessage `json:"message,omitempty"` // Generated message from assistant + Usage *CohereUsage `json:"usage,omitempty"` // Token usage information + LogProbs []CohereLogProb `json:"logprobs,omitempty"` // Log probabilities (if requested) +} + +// CohereFinishReason represents the reason a chat request has finished +type CohereFinishReason string + +const ( + FinishReasonComplete CohereFinishReason = "COMPLETE" // Model finished sending complete message + FinishReasonStopSequence CohereFinishReason = "STOP_SEQUENCE" // Stop sequence was reached + FinishReasonMaxTokens CohereFinishReason = "MAX_TOKENS" // Max tokens exceeded + FinishReasonToolCall CohereFinishReason = "TOOL_CALL" // Model generated tool call + FinishReasonError CohereFinishReason = "ERROR" // Generation failed due to internal error + FinishReasonTimeout CohereFinishReason = "TIMEOUT" // Timeout +) + +// CohereUsage represents token usage information +type CohereUsage struct { + BilledUnits *CohereBilledUnits `json:"billed_units,omitempty"` // Billed usage information + Tokens *CohereTokenUsage `json:"tokens,omitempty"` // Token usage details + CachedTokens *int `json:"cached_tokens,omitempty"` // Cached tokens +} + +// CohereBilledUnits represents billed usage information +type CohereBilledUnits struct { + InputTokens *int `json:"input_tokens,omitempty"` // Number of billed input tokens + OutputTokens *int `json:"output_tokens,omitempty"` // Number of billed output tokens + SearchUnits *int `json:"search_units,omitempty"` // Number of billed search units + Classifications *int `json:"classifications,omitempty"` // Number of billed classification units +} + +// CohereTokenUsage represents detailed token usage +type CohereTokenUsage struct { + InputTokens *int `json:"input_tokens"` // Number of input tokens used + OutputTokens *int `json:"output_tokens"` // Number of output tokens produced +} + +// CohereLogProb represents log probability information +type CohereLogProb struct { + TokenIDs []int `json:"token_ids"` // Token IDs of each token in text chunk + Text *string `json:"text,omitempty"` // Text chunk for log probabilities + LogProbs []float64 `json:"logprobs,omitempty"` // Log probability of each token +} + +type CohereCitationType string + +const ( + CitationTypeTextContent CohereCitationType = "TEXT_CONTENT" + CitationTypeThinkingContent CohereCitationType = "THINKING_CONTENT" + CitationTypePlan CohereCitationType = "PLAN" +) + +type CohereSourceType string + +const ( + SourceTypeTool CohereSourceType = "tool" + SourceTypeDocument CohereSourceType = "document" +) + +// CohereCitation represents a citation in the response +type CohereCitation struct { + Start int `json:"start"` // Start position of cited text + End int `json:"end"` // End position of cited text + Text string `json:"text"` // Cited text + Sources []CohereSource `json:"sources,omitempty"` // Citation sources + ContentIndex int `json:"content_index"` // Content index of the citation + Type CohereCitationType `json:"type"` // Type of citation +} + +// CohereSource represents a citation source +type CohereSource struct { + Type CohereSourceType `json:"type"` // Source type ("tool" or "document") + ID *string `json:"id,omitempty"` // Source ID (nullable) + ToolOutput *map[string]any `json:"tool_output,omitempty"` // Tool output (for tool sources) + Document *map[string]any `json:"document,omitempty"` // Document data (for document sources) +} + +// ==================== STREAMING TYPES ==================== + +// CohereStreamEventType represents the type of streaming event +type CohereStreamEventType string + +const ( + StreamEventMessageStart CohereStreamEventType = "message-start" + StreamEventContentStart CohereStreamEventType = "content-start" + StreamEventContentDelta CohereStreamEventType = "content-delta" + StreamEventContentEnd CohereStreamEventType = "content-end" + StreamEventToolPlanDelta CohereStreamEventType = "tool-plan-delta" + StreamEventToolCallStart CohereStreamEventType = "tool-call-start" + StreamEventToolCallDelta CohereStreamEventType = "tool-call-delta" + StreamEventToolCallEnd CohereStreamEventType = "tool-call-end" + StreamEventCitationStart CohereStreamEventType = "citation-start" + StreamEventCitationEnd CohereStreamEventType = "citation-end" + StreamEventMessageEnd CohereStreamEventType = "message-end" + StreamEventDebug CohereStreamEventType = "debug" +) + +// CohereStreamEvent represents a unified streaming event from Cohere API +type CohereStreamEvent struct { + Type CohereStreamEventType `json:"type"` + ID *string `json:"id,omitempty"` // For message-start + Index *int `json:"index,omitempty"` // For indexed events + Delta *CohereStreamDelta `json:"delta,omitempty"` +} + +// CohereStreamDelta represents the delta content in streaming events +type CohereStreamDelta struct { + Message *CohereStreamMessage `json:"message,omitempty"` + FinishReason *CohereFinishReason `json:"finish_reason,omitempty"` + Usage *CohereUsage `json:"usage,omitempty"` +} + +type CohereStreamToolCallStruct struct { + CohereToolCallObject *CohereToolCall + CohereToolCallArray *[]CohereToolCall +} + +// JSON marshaling for CohereStreamToolCall +func (c *CohereStreamToolCallStruct) MarshalJSON() ([]byte, error) { + if c.CohereToolCallObject != nil { + return sonic.Marshal(c.CohereToolCallObject) + } + if c.CohereToolCallArray != nil { + return sonic.Marshal(c.CohereToolCallArray) + } + return sonic.Marshal(nil) +} + +func (c *CohereStreamToolCallStruct) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + // Try to unmarshal as array first + var toolCallArray []CohereToolCall + if err := sonic.Unmarshal(data, &toolCallArray); err == nil { + c.CohereToolCallArray = &toolCallArray + return nil + } + + // Try to unmarshal as single object + var toolCallObject CohereToolCall + if err := sonic.Unmarshal(data, &toolCallObject); err == nil { + c.CohereToolCallObject = &toolCallObject + return nil + } + + return fmt.Errorf("tool_calls field is neither array nor object") +} + +type CohereStreamContentStruct struct { + CohereStreamContentObject *CohereStreamContent + CohereStreamContentArray *[]CohereStreamContent +} + +func (c *CohereStreamContentStruct) MarshalJSON() ([]byte, error) { + if c.CohereStreamContentObject != nil { + return sonic.Marshal(c.CohereStreamContentObject) + } + if c.CohereStreamContentArray != nil { + return sonic.Marshal(c.CohereStreamContentArray) + } + return sonic.Marshal(nil) +} + +func (c *CohereStreamContentStruct) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + // Try to unmarshal as array first + var contentArray []CohereStreamContent + if err := sonic.Unmarshal(data, &contentArray); err == nil { + c.CohereStreamContentArray = &contentArray + return nil + } + + // Try to unmarshal as single object + var contentObject CohereStreamContent + if err := sonic.Unmarshal(data, &contentObject); err == nil { + c.CohereStreamContentObject = &contentObject + return nil + } + + return fmt.Errorf("content field is neither array nor object") +} + +type CohereStreamCitationStruct struct { + CohereStreamCitationObject *CohereCitation + CohereStreamCitationArray *[]CohereCitation +} + +func (c *CohereStreamCitationStruct) MarshalJSON() ([]byte, error) { + if c.CohereStreamCitationObject != nil { + return sonic.Marshal(c.CohereStreamCitationObject) + } + if c.CohereStreamCitationArray != nil { + return sonic.Marshal(c.CohereStreamCitationArray) + } + return sonic.Marshal(nil) +} + +func (c *CohereStreamCitationStruct) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + // Try to unmarshal as array first + var citationArray []CohereCitation + if err := sonic.Unmarshal(data, &citationArray); err == nil { + c.CohereStreamCitationArray = &citationArray + return nil + } + + // Try to unmarshal as single object + var citationObject CohereCitation + if err := sonic.Unmarshal(data, &citationObject); err == nil { + c.CohereStreamCitationObject = &citationObject + return nil + } + + return fmt.Errorf("citations field is neither array nor object") +} + +// CohereStreamMessage represents the message part of streaming deltas +type CohereStreamMessage struct { + Role *string `json:"role,omitempty"` // For message-start + Content *CohereStreamContentStruct `json:"content,omitempty"` // For content events (object) + ToolPlan *string `json:"tool_plan,omitempty"` // For tool-plan-delta + ToolCalls *CohereStreamToolCallStruct `json:"tool_calls,omitempty"` // For tool-call events (flexible) + Citations *CohereStreamCitationStruct `json:"citations,omitempty"` // For citation events +} + +// CohereStreamContent represents content in streaming events +type CohereStreamContent struct { + Type CohereContentBlockType `json:"type,omitempty"` // For content-start + Text *string `json:"text,omitempty"` // For content deltas +} + +// ==================== ERROR TYPES ==================== + +// CohereError represents an error response from the Cohere API +type CohereError struct { + Type string `json:"type"` // Error type + Message string `json:"message"` // Error message + Code *string `json:"code,omitempty"` // Optional error code +} + +// ==================== MODEL TYPES ==================== + +type CohereModel struct { + Name string `json:"name"` + IsDeprecated bool `json:"is_deprecated"` + Endpoints []string `json:"endpoints"` + Finetuned bool `json:"finetuned"` + ContextLength int `json:"context_length"` + TokenizerURL string `json:"tokenizer_url"` + DefaultEndpoints []string `json:"default_endpoints"` + Features []string `json:"features"` +} + +type CohereListModelsResponse struct { + Models []CohereModel `json:"models"` + NextPageToken string `json:"next_page_token"` +} diff --git a/core/providers/cohere/utils.go b/core/providers/cohere/utils.go new file mode 100644 index 000000000..0c32c0092 --- /dev/null +++ b/core/providers/cohere/utils.go @@ -0,0 +1,19 @@ +package cohere + +var ( + // Maps provider-specific finish reasons to Bifrost format + cohereFinishReasonToBifrost = map[CohereFinishReason]string{ + FinishReasonComplete: "stop", + FinishReasonStopSequence: "stop", + FinishReasonMaxTokens: "length", + FinishReasonToolCall: "tool_calls", + } +) + +// ConvertCohereFinishReasonToBifrost converts provider finish reasons to Bifrost format +func ConvertCohereFinishReasonToBifrost(providerReason CohereFinishReason) string { + if bifrostReason, ok := cohereFinishReasonToBifrost[providerReason]; ok { + return bifrostReason + } + return string(providerReason) +} diff --git a/core/providers/gemini/chat.go b/core/providers/gemini/chat.go new file mode 100644 index 000000000..28c695b1f --- /dev/null +++ b/core/providers/gemini/chat.go @@ -0,0 +1,488 @@ +package gemini + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +func (request *GeminiGenerationRequest) ToBifrostChatRequest() *schemas.BifrostChatRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.Gemini) + + if provider == schemas.Vertex && !request.IsEmbedding { + // Add google/ prefix for Bifrost if not already present + if !strings.HasPrefix(model, "google/") { + model = "google/" + model + } + } + + // Handle chat completion requests + bifrostReq := &schemas.BifrostChatRequest{ + Provider: provider, + Model: model, + Input: []schemas.ChatMessage{}, + } + + messages := []schemas.ChatMessage{} + + allGenAiMessages := []Content{} + if request.SystemInstruction != nil { + allGenAiMessages = append(allGenAiMessages, *request.SystemInstruction) + } + allGenAiMessages = append(allGenAiMessages, request.Contents...) + + for _, content := range allGenAiMessages { + if len(content.Parts) == 0 { + continue + } + + // Handle multiple parts - collect all content and tool calls + var toolCalls []schemas.ChatAssistantMessageToolCall + var contentBlocks []schemas.ChatContentBlock + var thoughtStr string // Track thought content for assistant/model + + for _, part := range content.Parts { + switch { + case part.Text != "": + // Handle thought content specially for assistant messages + if part.Thought && + (content.Role == string(schemas.ChatMessageRoleAssistant) || content.Role == string(RoleModel)) { + thoughtStr = thoughtStr + part.Text + "\n" + } else { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: &part.Text, + }) + } + + case part.FunctionCall != nil: + // Only add function calls for assistant messages + if content.Role == string(schemas.ChatMessageRoleAssistant) || content.Role == string(RoleModel) { + jsonArgs, err := json.Marshal(part.FunctionCall.Args) + if err != nil { + jsonArgs = []byte(fmt.Sprintf("%v", part.FunctionCall.Args)) + } + name := part.FunctionCall.Name // create local copy + // Gemini primarily works with function names for correlation + // Use ID if provided, otherwise fallback to name for stable correlation + callID := name + if strings.TrimSpace(part.FunctionCall.ID) != "" { + callID = part.FunctionCall.ID + } + toolCall := schemas.ChatAssistantMessageToolCall{ + Index: uint16(len(toolCalls)), + ID: schemas.Ptr(callID), + Type: schemas.Ptr(string(schemas.ChatToolChoiceTypeFunction)), + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: &name, + Arguments: string(jsonArgs), + }, + } + toolCalls = append(toolCalls, toolCall) + } + + case part.FunctionResponse != nil: + // Create a separate tool response message + responseContent, err := json.Marshal(part.FunctionResponse.Response) + if err != nil { + responseContent = []byte(fmt.Sprintf("%v", part.FunctionResponse.Response)) + } + + // Correlate with the function call: prefer ID if available, otherwise use name + callID := part.FunctionResponse.Name + if strings.TrimSpace(part.FunctionResponse.ID) != "" { + callID = part.FunctionResponse.ID + } else { + // Fallback: correlate with the prior function call by name to reuse its ID + for _, tc := range toolCalls { + if tc.Function.Name != nil && *tc.Function.Name == part.FunctionResponse.Name && + tc.ID != nil && *tc.ID != "" { + callID = *tc.ID + break + } + } + } + + toolResponseMsg := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleTool, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr(string(responseContent)), + }, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: &callID, + }, + } + + messages = append(messages, toolResponseMsg) + + case part.InlineData != nil: + // Handle inline images/media - only append if it's actually an image + if isImageMimeType(part.InlineData.MIMEType) { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MIMEType, base64.StdEncoding.EncodeToString(part.InlineData.Data)), + }, + }) + } + + case part.FileData != nil: + // Handle file data - only append if it's actually an image + if isImageMimeType(part.FileData.MIMEType) { + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: part.FileData.FileURI, + }, + }) + } + + case part.ExecutableCode != nil: + // Handle executable code as text content + codeText := fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code) + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: &codeText, + }) + + case part.CodeExecutionResult != nil: + // Handle code execution results as text content + resultText := fmt.Sprintf("Code execution result (%s):\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output) + contentBlocks = append(contentBlocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: &resultText, + }) + } + } + + // Only create message if there's actual content, tool calls, or thought content + if len(contentBlocks) > 0 || len(toolCalls) > 0 || thoughtStr != "" { + // Create main message with content blocks + bifrostMsg := schemas.ChatMessage{ + Role: func(r string) schemas.ChatMessageRole { + if r == string(RoleModel) { // GenAI's internal alias + return schemas.ChatMessageRoleAssistant + } + return schemas.ChatMessageRole(r) + }(content.Role), + } + + // Set content only if there are content blocks + if len(contentBlocks) > 0 { + bifrostMsg.Content = &schemas.ChatMessageContent{ + ContentBlocks: contentBlocks, + } + } + + // Set assistant-specific fields for assistant/model messages + if content.Role == string(schemas.ChatMessageRoleAssistant) || content.Role == string(RoleModel) { + if len(toolCalls) > 0 || thoughtStr != "" { + bifrostMsg.ChatAssistantMessage = &schemas.ChatAssistantMessage{} + if len(toolCalls) > 0 { + bifrostMsg.ChatAssistantMessage.ToolCalls = toolCalls + } + } + } + + messages = append(messages, bifrostMsg) + } + } + + bifrostReq.Input = messages + + // Convert generation config to parameters + if params := request.convertGenerationConfigToChatParameters(); params != nil { + bifrostReq.Params = params + } + + // Convert safety settings + if len(request.SafetySettings) > 0 { + ensureExtraParams(bifrostReq) + bifrostReq.Params.ExtraParams["safety_settings"] = request.SafetySettings + } + + // Convert additional request fields + if request.CachedContent != "" { + ensureExtraParams(bifrostReq) + bifrostReq.Params.ExtraParams["cached_content"] = request.CachedContent + } + + // Convert labels + if len(request.Labels) > 0 { + ensureExtraParams(bifrostReq) + bifrostReq.Params.ExtraParams["labels"] = request.Labels + } + + // Convert tools and tool config + if len(request.Tools) > 0 { + ensureExtraParams(bifrostReq) + + tools := make([]schemas.ChatTool, 0, len(request.Tools)) + for _, tool := range request.Tools { + if len(tool.FunctionDeclarations) > 0 { + for _, fn := range tool.FunctionDeclarations { + bifrostTool := schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: fn.Name, + Description: schemas.Ptr(fn.Description), + }, + } + // Convert parameters schema if present + if fn.Parameters != nil { + params := request.convertSchemaToFunctionParameters(fn.Parameters) + bifrostTool.Function.Parameters = ¶ms + } + tools = append(tools, bifrostTool) + } + } + // Handle other tool types (Retrieval, GoogleSearch, etc.) as ExtraParams + if tool.Retrieval != nil { + bifrostReq.Params.ExtraParams["retrieval"] = tool.Retrieval + } + if tool.GoogleSearch != nil { + bifrostReq.Params.ExtraParams["google_search"] = tool.GoogleSearch + } + if tool.CodeExecution != nil { + bifrostReq.Params.ExtraParams["code_execution"] = tool.CodeExecution + } + } + + if len(tools) > 0 { + bifrostReq.Params.Tools = tools + } + } + + // Convert tool config + if request.ToolConfig.FunctionCallingConfig != nil || request.ToolConfig.RetrievalConfig != nil { + ensureExtraParams(bifrostReq) + bifrostReq.Params.ExtraParams["tool_config"] = request.ToolConfig + } + + return bifrostReq +} + +// ToGeminiChatCompletionRequest converts a BifrostChatRequest to Gemini's generation request format for chat completion +func ToGeminiChatCompletionRequest(bifrostReq *schemas.BifrostChatRequest, responseModalities []string) *GeminiGenerationRequest { + if bifrostReq == nil { + return nil + } + + // Create the base Gemini generation request + geminiReq := &GeminiGenerationRequest{ + Model: bifrostReq.Model, + } + + // Convert parameters to generation config + if bifrostReq.Params != nil { + geminiReq.GenerationConfig = convertParamsToGenerationConfig(bifrostReq.Params, responseModalities) + + // Handle tool-related parameters + if len(bifrostReq.Params.Tools) > 0 { + geminiReq.Tools = convertBifrostToolsToGemini(bifrostReq.Params.Tools) + + // Convert tool choice to tool config + if bifrostReq.Params.ToolChoice != nil { + geminiReq.ToolConfig = convertToolChoiceToToolConfig(bifrostReq.Params.ToolChoice) + } + } + + // Handle extra parameters + if bifrostReq.Params.ExtraParams != nil { + // Safety settings + if safetySettings, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "safety_settings"); ok { + if settings, ok := safetySettings.([]SafetySetting); ok { + geminiReq.SafetySettings = settings + } + } + + // Cached content + if cachedContent, ok := schemas.SafeExtractString(bifrostReq.Params.ExtraParams["cached_content"]); ok { + geminiReq.CachedContent = cachedContent + } + + // Labels + if labels, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "labels"); ok { + if labelMap, ok := labels.(map[string]string); ok { + geminiReq.Labels = labelMap + } + } + } + } + + // Convert chat completion messages to Gemini format + geminiReq.Contents = convertBifrostMessagesToGemini(bifrostReq.Input) + + return geminiReq +} + +// ToBifrostChatResponse converts a GenerateContentResponse to a BifrostChatResponse +func (response *GenerateContentResponse) ToBifrostChatResponse() *schemas.BifrostChatResponse { + bifrostResp := &schemas.BifrostChatResponse{ + ID: response.ResponseID, + Model: response.ModelVersion, + Object: "chat.completion", + } + + // Set creation timestamp if available + if !response.CreateTime.IsZero() { + bifrostResp.Created = int(response.CreateTime.Unix()) + } + + // Extract usage metadata + inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens := response.extractUsageMetadata() + + // Process candidates to extract text content + if len(response.Candidates) > 0 { + candidate := response.Candidates[0] + if candidate.Content != nil && len(candidate.Content.Parts) > 0 { + var textContent string + + // Extract text content from all parts + for _, part := range candidate.Content.Parts { + if part.Text != "" { + textContent += part.Text + } + } + + if textContent != "" { + // Create choice from the candidate + choice := schemas.BifrostResponseChoice{ + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: &textContent, + }, + }, + }, + } + + // Set finish reason if available + if candidate.FinishReason != "" { + finishReason := string(candidate.FinishReason) + choice.FinishReason = &finishReason + } + + bifrostResp.Choices = []schemas.BifrostResponseChoice{choice} + } + } + } + + // Set usage information + bifrostResp.Usage = &schemas.BifrostLLMUsage{ + PromptTokens: inputTokens, + CompletionTokens: outputTokens, + TotalTokens: totalTokens, + PromptTokensDetails: &schemas.ChatPromptTokensDetails{ + CachedTokens: cachedTokens, + }, + CompletionTokensDetails: &schemas.ChatCompletionTokensDetails{ + ReasoningTokens: reasoningTokens, + }, + } + + return bifrostResp +} + +// ToGeminiChatResponse converts a BifrostChatResponse to Gemini's GenerateContentResponse +func ToGeminiChatResponse(bifrostResp *schemas.BifrostChatResponse) *GenerateContentResponse { + if bifrostResp == nil { + return nil + } + + genaiResp := &GenerateContentResponse{ + ResponseID: bifrostResp.ID, + ModelVersion: bifrostResp.Model, + } + + // Set creation time if available + if bifrostResp.Created > 0 { + genaiResp.CreateTime = time.Unix(int64(bifrostResp.Created), 0) + } + + if len(bifrostResp.Choices) > 0 { + candidates := make([]*Candidate, len(bifrostResp.Choices)) + + for i, choice := range bifrostResp.Choices { + candidate := &Candidate{ + Index: int32(choice.Index), + } + + if choice.FinishReason != nil { + candidate.FinishReason = FinishReason(*choice.FinishReason) + } + + // Convert message content to Gemini parts + var parts []*Part + if choice.ChatNonStreamResponseChoice != nil && choice.ChatNonStreamResponseChoice.Message != nil { + if choice.ChatNonStreamResponseChoice.Message.Content != nil { + if choice.ChatNonStreamResponseChoice.Message.Content.ContentStr != nil && *choice.ChatNonStreamResponseChoice.Message.Content.ContentStr != "" { + parts = append(parts, &Part{Text: *choice.ChatNonStreamResponseChoice.Message.Content.ContentStr}) + } else if choice.ChatNonStreamResponseChoice.Message.Content.ContentBlocks != nil { + for _, block := range choice.ChatNonStreamResponseChoice.Message.Content.ContentBlocks { + if block.Text != nil { + parts = append(parts, &Part{Text: *block.Text}) + } + } + } + } + + // Handle tool calls + if choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage != nil && choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ToolCalls { + argsMap := make(map[string]interface{}) + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &argsMap); err != nil { + argsMap = map[string]interface{}{} + } + } + if toolCall.Function.Name != nil { + fc := &FunctionCall{ + Name: *toolCall.Function.Name, + Args: argsMap, + } + if toolCall.ID != nil { + fc.ID = *toolCall.ID + } + parts = append(parts, &Part{FunctionCall: fc}) + } + } + } + + if len(parts) > 0 { + candidate.Content = &Content{ + Parts: parts, + Role: string(choice.ChatNonStreamResponseChoice.Message.Role), + } + } + } + + candidates[i] = candidate + } + + genaiResp.Candidates = candidates + } + + // Set usage metadata from LLM usage + if bifrostResp.Usage != nil { + genaiResp.UsageMetadata = &GenerateContentResponseUsageMetadata{ + PromptTokenCount: int32(bifrostResp.Usage.PromptTokens), + CandidatesTokenCount: int32(bifrostResp.Usage.CompletionTokens), + TotalTokenCount: int32(bifrostResp.Usage.TotalTokens), + } + if bifrostResp.Usage.PromptTokensDetails != nil { + genaiResp.UsageMetadata.CachedContentTokenCount = int32(bifrostResp.Usage.PromptTokensDetails.CachedTokens) + } + if bifrostResp.Usage.CompletionTokensDetails != nil { + genaiResp.UsageMetadata.ThoughtsTokenCount = int32(bifrostResp.Usage.CompletionTokensDetails.ReasoningTokens) + } + } + + return genaiResp +} diff --git a/core/providers/gemini/embedding.go b/core/providers/gemini/embedding.go new file mode 100644 index 000000000..69e80a6fa --- /dev/null +++ b/core/providers/gemini/embedding.go @@ -0,0 +1,164 @@ +package gemini + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ToGeminiEmbeddingRequest converts a BifrostRequest with embedding input to Gemini's embedding request format +func ToGeminiEmbeddingRequest(bifrostReq *schemas.BifrostEmbeddingRequest) *GeminiEmbeddingRequest { + if bifrostReq == nil || bifrostReq.Input == nil || (bifrostReq.Input.Text == nil && bifrostReq.Input.Texts == nil) { + return nil + } + embeddingInput := bifrostReq.Input + // Get the text to embed + var text string + if embeddingInput.Text != nil { + text = *embeddingInput.Text + } else if len(embeddingInput.Texts) > 0 { + // Take the first text if multiple texts are provided + text = strings.Join(embeddingInput.Texts, " ") + } + if text == "" { + return nil + } + // Create the Gemini embedding request + request := &GeminiEmbeddingRequest{ + Model: bifrostReq.Model, + Content: &Content{ + Parts: []*Part{ + { + Text: text, + }, + }, + }, + } + // Add parameters if available + if bifrostReq.Params != nil { + if bifrostReq.Params.Dimensions != nil { + request.OutputDimensionality = bifrostReq.Params.Dimensions + } + + // Handle extra parameters + if bifrostReq.Params.ExtraParams != nil { + if taskType, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["taskType"]); ok { + request.TaskType = taskType + } + if title, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["title"]); ok { + request.Title = title + } + } + } + return request +} + +// ToGeminiEmbeddingResponse converts a BifrostResponse with embedding data to Gemini's embedding response format +func ToGeminiEmbeddingResponse(bifrostResp *schemas.BifrostEmbeddingResponse) *GeminiEmbeddingResponse { + if bifrostResp == nil || len(bifrostResp.Data) == 0 { + return nil + } + + geminiResp := &GeminiEmbeddingResponse{ + Embeddings: make([]GeminiEmbedding, len(bifrostResp.Data)), + } + + // Convert each embedding from Bifrost format to Gemini format + for i, embedding := range bifrostResp.Data { + var values []float32 + + // Extract embedding values from BifrostEmbeddingResponse + if embedding.Embedding.EmbeddingArray != nil { + values = embedding.Embedding.EmbeddingArray + } else if len(embedding.Embedding.Embedding2DArray) > 0 { + // If it's a 2D array, take the first array + values = embedding.Embedding.Embedding2DArray[0] + } + + geminiEmbedding := GeminiEmbedding{ + Values: values, + } + + // Add statistics if available (token count from usage metadata) + if bifrostResp.Usage != nil { + geminiEmbedding.Statistics = &ContentEmbeddingStatistics{ + TokenCount: int32(bifrostResp.Usage.PromptTokens), + } + } + + geminiResp.Embeddings[i] = geminiEmbedding + } + + // Set metadata if available (for Vertex API compatibility) + if bifrostResp.Usage != nil { + geminiResp.Metadata = &EmbedContentMetadata{ + BillableCharacterCount: int32(bifrostResp.Usage.PromptTokens), + } + } + + return geminiResp +} + +// ToBifrostEmbeddingRequest converts a GeminiGenerationRequest to BifrostEmbeddingRequest format +func (request *GeminiGenerationRequest) ToBifrostEmbeddingRequest() *schemas.BifrostEmbeddingRequest { + if request == nil { + return nil + } + + provider, model := schemas.ParseModelString(request.Model, schemas.Gemini) + + if provider == schemas.Vertex && request.IsEmbedding { + // Add google/ prefix for Bifrost if not already present + if !strings.HasPrefix(model, "google/") { + model = "google/" + model + } + } + + // Create the embedding request + bifrostReq := &schemas.BifrostEmbeddingRequest{ + Provider: provider, + Model: model, + } + + if len(request.Requests) > 0 { + embeddingRequest := request.Requests[0] + if embeddingRequest.Content != nil { + var texts []string + for _, part := range embeddingRequest.Content.Parts { + if part != nil && part.Text != "" { + texts = append(texts, part.Text) + } + } + if len(texts) > 0 { + bifrostReq.Input = &schemas.EmbeddingInput{} + if len(texts) == 1 { + bifrostReq.Input.Text = &texts[0] + } else { + bifrostReq.Input.Texts = texts + } + } + } + + // Convert parameters + if embeddingRequest.OutputDimensionality != nil || embeddingRequest.TaskType != nil || embeddingRequest.Title != nil { + bifrostReq.Params = &schemas.EmbeddingParameters{} + + if embeddingRequest.OutputDimensionality != nil { + bifrostReq.Params.Dimensions = embeddingRequest.OutputDimensionality + } + + // Handle extra parameters + if embeddingRequest.TaskType != nil || embeddingRequest.Title != nil { + bifrostReq.Params.ExtraParams = make(map[string]interface{}) + if embeddingRequest.TaskType != nil { + bifrostReq.Params.ExtraParams["taskType"] = embeddingRequest.TaskType + } + if embeddingRequest.Title != nil { + bifrostReq.Params.ExtraParams["title"] = embeddingRequest.Title + } + } + } + } + + return bifrostReq +} diff --git a/core/providers/gemini/errors.go b/core/providers/gemini/errors.go new file mode 100644 index 000000000..f39dfd878 --- /dev/null +++ b/core/providers/gemini/errors.go @@ -0,0 +1,29 @@ +package gemini + +import "github.com/maximhq/bifrost/core/schemas" + +// ToGeminiError derives a GeminiChatRequestError from a BifrostError +func ToGeminiError(bifrostErr *schemas.BifrostError) *GeminiChatRequestError { + if bifrostErr == nil { + return nil + } + code := 500 + status := "" + if bifrostErr.Error != nil && bifrostErr.Error.Type != nil { + status = *bifrostErr.Error.Type + } + message := "" + if bifrostErr.Error != nil && bifrostErr.Error.Message != "" { + message = bifrostErr.Error.Message + } + if bifrostErr.StatusCode != nil { + code = *bifrostErr.StatusCode + } + return &GeminiChatRequestError{ + Error: GeminiChatRequestErrorStruct{ + Code: code, + Message: message, + Status: status, + }, + } +} diff --git a/core/providers/gemini/gemini.go b/core/providers/gemini/gemini.go new file mode 100644 index 000000000..33aa802de --- /dev/null +++ b/core/providers/gemini/gemini.go @@ -0,0 +1,1067 @@ +package gemini + +import ( + "bufio" + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +type GeminiProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse + customProviderConfig *schemas.CustomProviderConfig // Custom provider config +} + +// NewGeminiProvider creates a new Gemini provider instance. +// It initializes the HTTP client with the provided configuration. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewGeminiProvider(config *schemas.ProviderConfig, logger schemas.Logger) *GeminiProvider { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://generativelanguage.googleapis.com/v1beta" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &GeminiProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + customProviderConfig: config.CustomProviderConfig, + sendBackRawResponse: config.SendBackRawResponse, + } +} + +// GetProviderKey returns the provider identifier for Gemini. +func (provider *GeminiProvider) GetProviderKey() schemas.ModelProvider { + return providerUtils.GetProviderName(schemas.Gemini, provider.customProviderConfig) +} + +// completeRequest handles the common HTTP request pattern for Gemini API calls +func (provider *GeminiProvider) completeRequest(ctx context.Context, model string, key schemas.Key, jsonBody []byte, endpoint string) (*GenerateContentResponse, interface{}, time.Duration, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Use Gemini's generateContent endpoint + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/models/"+model+endpoint)) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("x-goog-api-key", key.Value) + } + + req.SetBody(jsonBody) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, nil, latency, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + return nil, nil, latency, parseGeminiError(providerName, resp) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + // Copy the response body before releasing the response + // to avoid use-after-free since, respBody references fasthttp's internal buffer + responseBody := append([]byte(nil), body...) + + // Parse Gemini's response + var geminiResponse GenerateContentResponse + if err := sonic.Unmarshal(responseBody, &geminiResponse); err != nil { + return nil, nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, providerName) + } + + var rawResponse interface{} + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + if err := sonic.Unmarshal(responseBody, &rawResponse); err != nil { + return nil, nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, providerName) + } + } + + return &geminiResponse, rawResponse, latency, nil +} + +// listModelsByKey performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +func (provider *GeminiProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Build URL using centralized URL construction + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, fmt.Sprintf("/models?pageSize=%d", schemas.DefaultPageSize))) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("x-goog-api-key", key.Value) + } + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + bifrostErr := parseGeminiError(providerName, resp) + return nil, bifrostErr + } + + // Parse Gemini's response + var geminiResponse GeminiListModelsResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(resp.Body(), &geminiResponse, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + response := geminiResponse.ToBifrostListModelsResponse(providerName) + + response.ExtraFields.Latency = latency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ListModels performs a list models request to Gemini's API. +// Requests are made concurrently for improved performance. +func (provider *GeminiProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.ListModelsRequest); err != nil { + return nil, err + } + if provider.customProviderConfig != nil && provider.customProviderConfig.IsKeyLess { + return provider.listModelsByKey(ctx, schemas.Key{}, request) + } + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion is not supported by the Gemini provider. +func (provider *GeminiProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream performs a streaming text completion request to Gemini's API. +// It formats the request, sends it to Gemini, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *GeminiProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to the Gemini API. +func (provider *GeminiProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + // Check if chat completion is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.ChatCompletionRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + jsonData, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return openai.ToOpenAIChatRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/openai/chat/completions")) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + req.SetBody(jsonData) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + var errorResp []GeminiGenerationError + + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + errorMessage := "" + for _, error := range errorResp { + errorMessage += error.Error.Message + "\n" + } + bifrostErr.Error.Message = errorMessage + return nil, bifrostErr + } + + body, decodeErr := providerUtils.CheckAndDecodeBody(resp) + if decodeErr != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, decodeErr, providerName) + } + + response := &schemas.BifrostChatResponse{} + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + for _, choice := range response.Choices { + if choice.ChatNonStreamResponseChoice != nil && choice.ChatNonStreamResponseChoice.Message != nil && choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage != nil && choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ToolCalls != nil { + for i, toolCall := range choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ToolCalls { + if (toolCall.ID == nil || *toolCall.ID == "") && toolCall.Function.Name != nil && *toolCall.Function.Name != "" { + id := "" + if toolCall.Function.Name != nil { + id = *toolCall.Function.Name + } + (choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage.ToolCalls)[i].ID = &id + } + } + } + } + + response.ExtraFields.RequestType = schemas.ChatCompletionRequest + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.Latency = latency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ChatCompletionStream performs a streaming chat completion request to the Gemini API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Gemini's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *GeminiProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if chat completion stream is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.ChatCompletionStreamRequest); err != nil { + return nil, err + } + + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/openai/chat/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a chat completion request to Anthropic's API. +// It formats the request, sends it to Anthropic, and processes the response. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *GeminiProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Gemini API. +func (provider *GeminiProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding performs an embedding request to the Gemini API. +func (provider *GeminiProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + // Check if embedding is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.EmbeddingRequest); err != nil { + return nil, err + } + + // Use the shared embedding request handler + return openai.HandleOpenAIEmbeddingRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/openai/embeddings"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// Speech performs a speech synthesis request to the Gemini API. +func (provider *GeminiProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + // Check if speech is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.SpeechRequest); err != nil { + return nil, err + } + + // Prepare request body using speech-specific function + jsonData, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToGeminiSpeechRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + // Use common request function + geminiResponse, rawResponse, latency, bifrostErr := provider.completeRequest(ctx, request.Model, key, jsonData, ":generateContent") + if bifrostErr != nil { + return nil, bifrostErr + } + + response := geminiResponse.ToBifrostSpeechResponse() + + // Set ExtraFields + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.RequestType = schemas.SpeechRequest + response.ExtraFields.Latency = latency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// SpeechStream performs a streaming speech synthesis request to the Gemini API. +func (provider *GeminiProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if speech stream is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.SpeechStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + // Prepare request body using speech-specific function + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToGeminiSpeechRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/models/"+request.Model+":streamGenerateContent?alt=sse")) + req.Header.SetContentType("application/json") + + // Set headers for streaming + if key.Value != "" { + req.Header.Set("x-goog-api-key", key.Value) + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Set headers + req.SetBody(jsonBody) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamGeminiError(providerName, resp) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + // Increase buffer size to handle large chunks (especially for audio data) + buf := make([]byte, 0, 1024*1024) // 1MB initial buffer + scanner.Buffer(buf, 10*1024*1024) // Allow up to 10MB tokens + chunkIndex := -1 + usage := &schemas.SpeechUsage{} + startTime := time.Now() + lastChunkTime := startTime + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + line := scanner.Text() + + // Skip empty lines + if line == "" { + continue + } + + var jsonData string + // Parse SSE data + if strings.HasPrefix(line, "data: ") { + jsonData = strings.TrimPrefix(line, "data: ") + } else { + // Handle raw JSON errors (without "data: " prefix) + jsonData = line + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // Process chunk using shared function + geminiResponse, err := processGeminiStreamChunk(jsonData) + if err != nil { + if strings.Contains(err.Error(), "gemini api error") { + // Handle API error + bifrostErr := &schemas.BifrostError{ + Type: schemas.Ptr("gemini_api_error"), + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: err.Error(), + Error: err, + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + RequestType: schemas.SpeechStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + }, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + return + } + provider.logger.Warn(fmt.Sprintf("Failed to process chunk: %v", err)) + continue + } + + // Extract audio data from Gemini response for regular chunks + var audioChunk []byte + if len(geminiResponse.Candidates) > 0 { + candidate := geminiResponse.Candidates[0] + if candidate.Content != nil && len(candidate.Content.Parts) > 0 { + var buf []byte + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.Data != nil { + buf = append(buf, part.InlineData.Data...) + } + } + if len(buf) > 0 { + audioChunk = buf + } + } + } + + // Check if this is the final chunk (has finishReason) + if len(geminiResponse.Candidates) > 0 && (geminiResponse.Candidates[0].FinishReason != "" || geminiResponse.UsageMetadata != nil) { + // Extract usage metadata using shared function + inputTokens, outputTokens, totalTokens := extractGeminiUsageMetadata(geminiResponse) + usage.InputTokens = inputTokens + usage.OutputTokens = outputTokens + usage.TotalTokens = totalTokens + } + + // Only send response if we have actual audio content + if len(audioChunk) > 0 { + chunkIndex++ + + // Create Bifrost speech response for streaming + response := &schemas.BifrostSpeechStreamResponse{ + Type: schemas.SpeechStreamResponseTypeDelta, + Audio: audioChunk, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.SpeechStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + }, + } + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = jsonData + } + + // Process response through post-hooks and send to channel + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, response, nil), responseChan) + } + } + + // Handle scanner errors. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.SpeechStreamRequest, providerName, request.Model, provider.logger) + } else if ctx.Err() == nil { + response := &schemas.BifrostSpeechStreamResponse{ + Type: schemas.SpeechStreamResponseTypeDone, + Usage: usage, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.SpeechStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex + 1, + Latency: time.Since(startTime).Milliseconds(), + }, + } + + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, response, nil), responseChan) + } + }() + + return responseChan, nil +} + +// Transcription performs a speech-to-text request to the Gemini API. +func (provider *GeminiProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + // Check if transcription is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.TranscriptionRequest); err != nil { + return nil, err + } + + // Prepare request body using transcription-specific function + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToGeminiTranscriptionRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Use common request function + geminiResponse, rawResponse, latency, bifrostErr := provider.completeRequest(ctx, request.Model, key, jsonData, ":generateContent") + if bifrostErr != nil { + return nil, bifrostErr + } + + response := geminiResponse.ToBifrostTranscriptionResponse() + + // Set ExtraFields + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.RequestType = schemas.TranscriptionRequest + response.ExtraFields.Latency = latency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// TranscriptionStream performs a streaming speech-to-text request to the Gemini API. +func (provider *GeminiProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if transcription stream is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.Gemini, provider.customProviderConfig, schemas.TranscriptionStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + // Prepare request body using transcription-specific function + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToGeminiTranscriptionRequest(request), nil }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/models/"+request.Model+":streamGenerateContent?alt=sse")) + req.Header.SetContentType("application/json") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Set headers for streaming + if key.Value != "" { + req.Header.Set("x-goog-api-key", key.Value) + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + req.SetBody(jsonBody) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, provider.GetProviderKey()) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamGeminiError(providerName, resp) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + // Increase buffer size to handle large chunks (especially for audio data) + buf := make([]byte, 0, 1024*1024) // 1MB initial buffer + scanner.Buffer(buf, 10*1024*1024) // Allow up to 10MB tokens + chunkIndex := -1 + usage := &schemas.TranscriptionUsage{} + startTime := time.Now() + lastChunkTime := startTime + + var fullTranscriptionText string + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + line := scanner.Text() + + // Skip empty lines + if line == "" { + continue + } + var jsonData string + // Parse SSE data + if after, ok := strings.CutPrefix(line, "data: "); ok { + jsonData = after + } else { + // Handle raw JSON errors (without "data: " prefix) + jsonData = line + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // First, check if this is an error response + var errorCheck map[string]interface{} + if err := sonic.Unmarshal([]byte(jsonData), &errorCheck); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse stream data as JSON: %v", err)) + continue + } + + // Handle error responses + if _, hasError := errorCheck["error"]; hasError { + bifrostErr := &schemas.BifrostError{ + Type: schemas.Ptr("gemini_api_error"), + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("Gemini API error: %v", errorCheck["error"]), + Error: fmt.Errorf("stream error: %v", errorCheck["error"]), + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + RequestType: schemas.TranscriptionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + }, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, provider.logger) + return + } + + // Parse Gemini streaming response + var geminiResponse GenerateContentResponse + if err := sonic.Unmarshal([]byte(jsonData), &geminiResponse); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse Gemini stream response: %v", err)) + continue + } + + // Extract text from Gemini response for regular chunks + var deltaText string + if len(geminiResponse.Candidates) > 0 && geminiResponse.Candidates[0].Content != nil { + if len(geminiResponse.Candidates[0].Content.Parts) > 0 { + var sb strings.Builder + for _, p := range geminiResponse.Candidates[0].Content.Parts { + if p.Text != "" { + sb.WriteString(p.Text) + } + } + if sb.Len() > 0 { + deltaText = sb.String() + fullTranscriptionText += deltaText + } + } + } + + // Check if this is the final chunk (has finishReason) + if len(geminiResponse.Candidates) > 0 && (geminiResponse.Candidates[0].FinishReason != "" || geminiResponse.UsageMetadata != nil) { + // Extract usage metadata from Gemini response + inputTokens, outputTokens, totalTokens := extractGeminiUsageMetadata(&geminiResponse) + usage.InputTokens = schemas.Ptr(inputTokens) + usage.OutputTokens = schemas.Ptr(outputTokens) + usage.TotalTokens = schemas.Ptr(totalTokens) + } + + // Only send response if we have actual text content + if deltaText != "" { + chunkIndex++ + + // Create Bifrost transcription response for streaming + response := &schemas.BifrostTranscriptionStreamResponse{ + Type: schemas.TranscriptionStreamResponseTypeDelta, + Delta: &deltaText, // Delta text for this chunk + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.TranscriptionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + }, + } + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = jsonData + } + + // Process response through post-hooks and send to channel + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, nil, response), responseChan) + } + } + + // Handle scanner errors. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.TranscriptionStreamRequest, providerName, request.Model, provider.logger) + } else if ctx.Err() == nil { + response := &schemas.BifrostTranscriptionStreamResponse{ + Type: schemas.TranscriptionStreamResponseTypeDone, + Text: fullTranscriptionText, + Usage: &schemas.TranscriptionUsage{ + Type: "tokens", + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.TranscriptionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex + 1, + Latency: time.Since(startTime).Milliseconds(), + }, + } + + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, nil, response), responseChan) + } + }() + + return responseChan, nil +} + +// processGeminiStreamChunk processes a single chunk from Gemini streaming response +func processGeminiStreamChunk(jsonData string) (*GenerateContentResponse, error) { + // First, check if this is an error response + var errorCheck map[string]interface{} + if err := sonic.Unmarshal([]byte(jsonData), &errorCheck); err != nil { + return nil, fmt.Errorf("failed to parse stream data as JSON: %v", err) + } + + // Handle error responses + if _, hasError := errorCheck["error"]; hasError { + return nil, fmt.Errorf("gemini api error: %v", errorCheck["error"]) + } + + // Parse Gemini streaming response + var geminiResponse GenerateContentResponse + if err := sonic.Unmarshal([]byte(jsonData), &geminiResponse); err != nil { + return nil, fmt.Errorf("failed to parse Gemini stream response: %v", err) + } + + return &geminiResponse, nil +} + +// extractGeminiUsageMetadata extracts usage metadata (as ints) from Gemini response +func extractGeminiUsageMetadata(geminiResponse *GenerateContentResponse) (int, int, int) { + var inputTokens, outputTokens, totalTokens int + if geminiResponse.UsageMetadata != nil { + usageMetadata := geminiResponse.UsageMetadata + inputTokens = int(usageMetadata.PromptTokenCount) + outputTokens = int(usageMetadata.CandidatesTokenCount) + totalTokens = int(usageMetadata.TotalTokenCount) + } + return inputTokens, outputTokens, totalTokens +} + +// parseStreamGeminiError parses Gemini streaming error responses +func parseStreamGeminiError(providerName schemas.ModelProvider, resp *fasthttp.Response) *schemas.BifrostError { + body := resp.Body() + + // Try to parse as JSON first + var errorResp GeminiGenerationError + if err := sonic.Unmarshal(body, &errorResp); err == nil { + bifrostErr := &schemas.BifrostError{ + IsBifrostError: false, + StatusCode: schemas.Ptr(int(resp.StatusCode())), + Error: &schemas.ErrorField{ + Code: schemas.Ptr(strconv.Itoa(errorResp.Error.Code)), + Message: errorResp.Error.Message, + }, + } + return bifrostErr + } + + // If JSON parsing fails, use the raw response body + var rawResponse interface{} + if err := sonic.Unmarshal(body, &rawResponse); err != nil { + return providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, providerName) + } + + return providerUtils.NewBifrostOperationError(fmt.Sprintf("Gemini streaming error (HTTP %d): %v", resp.StatusCode(), rawResponse), fmt.Errorf("HTTP %d", resp.StatusCode()), providerName) +} + +// parseGeminiError parses Gemini error responses +func parseGeminiError(providerName schemas.ModelProvider, resp *fasthttp.Response) *schemas.BifrostError { + body := resp.Body() + + // Try to parse as JSON first + var errorResp GeminiGenerationError + if err := sonic.Unmarshal(body, &errorResp); err == nil { + bifrostErr := &schemas.BifrostError{ + IsBifrostError: false, + StatusCode: schemas.Ptr(resp.StatusCode()), + Error: &schemas.ErrorField{ + Code: schemas.Ptr(strconv.Itoa(errorResp.Error.Code)), + Message: errorResp.Error.Message, + }, + } + return bifrostErr + } + + var rawResponse map[string]interface{} + if err := sonic.Unmarshal(body, &rawResponse); err != nil { + return providerUtils.NewBifrostOperationError("failed to parse error response", err, providerName) + } + + return providerUtils.NewBifrostOperationError(fmt.Sprintf("Gemini error: %v", rawResponse), fmt.Errorf("HTTP %d", resp.StatusCode()), providerName) +} diff --git a/core/providers/gemini/models.go b/core/providers/gemini/models.go new file mode 100644 index 000000000..db9e38f70 --- /dev/null +++ b/core/providers/gemini/models.go @@ -0,0 +1,68 @@ +package gemini + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" +) + +func (response *GeminiListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider) *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Models)), + } + + for _, model := range response.Models { + contextLength := model.InputTokenLimit + model.OutputTokenLimit + // Remove prefix models/ from model.Name + modelName := strings.TrimPrefix(model.Name, "models/") + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(providerKey) + "/" + modelName, + Name: schemas.Ptr(model.DisplayName), + Description: schemas.Ptr(model.Description), + ContextLength: schemas.Ptr(int(contextLength)), + MaxInputTokens: schemas.Ptr(model.InputTokenLimit), + MaxOutputTokens: schemas.Ptr(model.OutputTokenLimit), + SupportedMethods: model.SupportedGenerationMethods, + }) + } + + return bifrostResponse +} + +func ToGeminiListModelsResponse(resp *schemas.BifrostListModelsResponse) *GeminiListModelsResponse { + if resp == nil { + return nil + } + + geminiResponse := &GeminiListModelsResponse{ + Models: make([]GeminiModel, 0, len(resp.Data)), + NextPageToken: resp.NextPageToken, + } + + for _, model := range resp.Data { + geminiModel := GeminiModel{ + Name: model.ID, + SupportedGenerationMethods: model.SupportedMethods, + } + if model.Name != nil { + geminiModel.DisplayName = *model.Name + } + if model.Description != nil { + geminiModel.Description = *model.Description + } + if model.MaxInputTokens != nil { + geminiModel.InputTokenLimit = *model.MaxInputTokens + } + if model.MaxOutputTokens != nil { + geminiModel.OutputTokenLimit = *model.MaxOutputTokens + } + + geminiResponse.Models = append(geminiResponse.Models, geminiModel) + } + + return geminiResponse +} diff --git a/core/providers/gemini/responses.go b/core/providers/gemini/responses.go new file mode 100644 index 000000000..49e36ae71 --- /dev/null +++ b/core/providers/gemini/responses.go @@ -0,0 +1,735 @@ +package gemini + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +func ToGeminiResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) (*GeminiGenerationRequest, error) { + if bifrostReq == nil { + return nil, nil + } + + // Create the base Gemini generation request + geminiReq := &GeminiGenerationRequest{ + Model: bifrostReq.Model, + } + + // Convert parameters to generation config + if bifrostReq.Params != nil { + geminiReq.GenerationConfig = convertParamsToGenerationConfigResponses(bifrostReq.Params) + + // Handle tool-related parameters + if len(bifrostReq.Params.Tools) > 0 { + geminiReq.Tools = convertResponsesToolsToGemini(bifrostReq.Params.Tools) + + // Convert tool choice if present + if bifrostReq.Params.ToolChoice != nil { + geminiReq.ToolConfig = convertResponsesToolChoiceToGemini(bifrostReq.Params.ToolChoice) + } + } + } + + // Convert ResponsesInput messages to Gemini contents + if bifrostReq.Input != nil { + contents, systemInstruction, err := convertResponsesMessagesToGeminiContents(bifrostReq.Input) + if err != nil { + return nil, fmt.Errorf("failed to convert messages: %w", err) + } + geminiReq.Contents = contents + + if systemInstruction != nil { + geminiReq.SystemInstruction = systemInstruction + } + } + + return geminiReq, nil +} + +// ToResponsesBifrostResponsesResponse converts a Gemini GenerateContentResponse to a BifrostResponsesResponse +func (response *GenerateContentResponse) ToResponsesBifrostResponsesResponse() *schemas.BifrostResponsesResponse { + if response == nil { + return nil + } + + // Parse model string to get provider and model + + // Create the BifrostResponse with Responses structure + bifrostResp := &schemas.BifrostResponsesResponse{} + + // Convert usage information + if response.UsageMetadata != nil { + bifrostResp.Usage = &schemas.ResponsesResponseUsage{ + TotalTokens: int(response.UsageMetadata.TotalTokenCount), + InputTokens: int(response.UsageMetadata.PromptTokenCount), + OutputTokens: int(response.UsageMetadata.CandidatesTokenCount), + InputTokensDetails: &schemas.ResponsesResponseInputTokens{}, + } + + // Handle cached tokens if present + if response.UsageMetadata.CachedContentTokenCount > 0 { + bifrostResp.Usage.InputTokensDetails.CachedTokens = int(response.UsageMetadata.CachedContentTokenCount) + } + } + + // Convert candidates to Responses output messages + if len(response.Candidates) > 0 { + outputMessages := convertGeminiCandidatesToResponsesOutput(response.Candidates) + if len(outputMessages) > 0 { + bifrostResp.Output = outputMessages + } + } + + return bifrostResp +} + +// Helper functions for Responses conversion +// convertGeminiCandidatesToResponsesOutput converts Gemini candidates to Responses output messages +func convertGeminiCandidatesToResponsesOutput(candidates []*Candidate) []schemas.ResponsesMessage { + var messages []schemas.ResponsesMessage + + for _, candidate := range candidates { + if candidate.Content == nil || len(candidate.Content.Parts) == 0 { + continue + } + + for _, part := range candidate.Content.Parts { + // Handle different types of parts + switch { + case part.Text != "": + // Regular text message + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: &part.Text, + }, + }, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + } + messages = append(messages, msg) + + case part.Thought: + // Thinking/reasoning message + if part.Text != "" { + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeReasoning, + Text: &part.Text, + }, + }, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning), + } + messages = append(messages, msg) + } + + case part.FunctionCall != nil: + // Function call message + // Convert Args to JSON string if it's not already a string + argumentsStr := "" + if part.FunctionCall.Args != nil { + if argsBytes, err := json.Marshal(part.FunctionCall.Args); err == nil { + argumentsStr = string(argsBytes) + } + } + + // Create copies of the values to avoid range loop variable capture + functionCallID := part.FunctionCall.ID + functionCallName := part.FunctionCall.Name + + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{}, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: &functionCallID, + Name: &functionCallName, + Arguments: &argumentsStr, + }, + } + messages = append(messages, msg) + + case part.FunctionResponse != nil: + // Function response message + output := "" + if part.FunctionResponse.Response != nil { + if outputVal, ok := part.FunctionResponse.Response["output"]; ok { + if outputStr, ok := outputVal.(string); ok { + output = outputStr + } + } + } + + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: &output, + }, + }, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput), + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: schemas.Ptr(part.FunctionResponse.ID), + }, + } + + // Also set the tool name if present (Gemini associates on name) + if name := strings.TrimSpace(part.FunctionResponse.Name); name != "" { + msg.ResponsesToolMessage.Name = schemas.Ptr(name) + } + + messages = append(messages, msg) + + case part.InlineData != nil: + // Handle inline data (images, audio, etc.) + contentBlocks := []schemas.ResponsesMessageContentBlock{ + { + Type: func() schemas.ResponsesMessageContentBlockType { + if strings.HasPrefix(part.InlineData.MIMEType, "image/") { + return schemas.ResponsesInputMessageContentBlockTypeImage + } else if strings.HasPrefix(part.InlineData.MIMEType, "audio/") { + return schemas.ResponsesInputMessageContentBlockTypeAudio + } + return schemas.ResponsesInputMessageContentBlockTypeText + }(), + ResponsesInputMessageContentBlockImage: func() *schemas.ResponsesInputMessageContentBlockImage { + if strings.HasPrefix(part.InlineData.MIMEType, "image/") { + return &schemas.ResponsesInputMessageContentBlockImage{ + ImageURL: schemas.Ptr("data:" + part.InlineData.MIMEType + ";base64," + base64.StdEncoding.EncodeToString(part.InlineData.Data)), + } + } + return nil + }(), + Audio: func() *schemas.ResponsesInputMessageContentBlockAudio { + if strings.HasPrefix(part.InlineData.MIMEType, "audio/") { + // Extract format from MIME type (e.g., "audio/wav" -> "wav") + format := strings.TrimPrefix(part.InlineData.MIMEType, "audio/") + return &schemas.ResponsesInputMessageContentBlockAudio{ + Format: format, + Data: base64.StdEncoding.EncodeToString(part.InlineData.Data), + } + } + return nil + }(), + }, + } + + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: contentBlocks, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + } + messages = append(messages, msg) + + case part.FileData != nil: + // Handle file data + block := schemas.ResponsesMessageContentBlock{ + Type: schemas.ResponsesInputMessageContentBlockTypeFile, + ResponsesInputMessageContentBlockFile: &schemas.ResponsesInputMessageContentBlockFile{ + FileURL: schemas.Ptr(part.FileData.FileURI), + }, + } + if strings.HasPrefix(part.FileData.MIMEType, "image/") { + block.Type = schemas.ResponsesInputMessageContentBlockTypeImage + block.ResponsesInputMessageContentBlockImage = &schemas.ResponsesInputMessageContentBlockImage{ + ImageURL: schemas.Ptr(part.FileData.FileURI), + } + } + contentBlocks := []schemas.ResponsesMessageContentBlock{block} + + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: contentBlocks, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + } + messages = append(messages, msg) + + case part.CodeExecutionResult != nil: + // Handle code execution results + output := part.CodeExecutionResult.Output + if part.CodeExecutionResult.Outcome != OutcomeOK { + output = "Error: " + output + } + + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: &output, + }, + }, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeCodeInterpreterCall), + } + messages = append(messages, msg) + + case part.ExecutableCode != nil: + // Handle executable code + codeContent := "```" + part.ExecutableCode.Language + "\n" + part.ExecutableCode.Code + "\n```" + + msg := schemas.ResponsesMessage{ + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + { + Type: schemas.ResponsesOutputMessageContentTypeText, + Text: &codeContent, + }, + }, + }, + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + } + messages = append(messages, msg) + } + } + } + + return messages +} + +// convertParamsToGenerationConfigResponses converts ChatParameters to GenerationConfig for Responses +func convertParamsToGenerationConfigResponses(params *schemas.ResponsesParameters) GenerationConfig { + config := GenerationConfig{} + + if params.Temperature != nil { + config.Temperature = schemas.Ptr(float64(*params.Temperature)) + } + if params.TopP != nil { + config.TopP = schemas.Ptr(float64(*params.TopP)) + } + if params.MaxOutputTokens != nil { + config.MaxOutputTokens = int32(*params.MaxOutputTokens) + } + + if params.ExtraParams != nil { + if topK, ok := params.ExtraParams["top_k"]; ok { + if val, success := schemas.SafeExtractInt(topK); success { + config.TopK = schemas.Ptr(val) + } + } + if frequencyPenalty, ok := params.ExtraParams["frequency_penalty"]; ok { + if val, success := schemas.SafeExtractFloat64(frequencyPenalty); success { + config.FrequencyPenalty = schemas.Ptr(val) + } + } + if presencePenalty, ok := params.ExtraParams["presence_penalty"]; ok { + if val, success := schemas.SafeExtractFloat64(presencePenalty); success { + config.PresencePenalty = schemas.Ptr(val) + } + } + if stopSequences, ok := params.ExtraParams["stop_sequences"]; ok { + if val, success := schemas.SafeExtractStringSlice(stopSequences); success { + config.StopSequences = val + } + } + } + + return config +} + +// convertResponsesToolsToGemini converts Responses tools to Gemini tools +func convertResponsesToolsToGemini(tools []schemas.ResponsesTool) []Tool { + var geminiTools []Tool + + for _, tool := range tools { + if tool.Type == "function" { + geminiTool := Tool{} + + // Extract function information from ResponsesExtendedTool + if tool.ResponsesToolFunction != nil { + if tool.Name != nil && tool.ResponsesToolFunction != nil { + funcDecl := &FunctionDeclaration{ + Name: *tool.Name, + Description: func() string { + if tool.Description != nil { + return *tool.Description + } + return "" + }(), + Parameters: func() *Schema { + if tool.ResponsesToolFunction.Parameters != nil { + return convertFunctionParametersToGeminiSchema(*tool.ResponsesToolFunction.Parameters) + } + return nil + }(), + } + geminiTool.FunctionDeclarations = []*FunctionDeclaration{funcDecl} + } + } + + if len(geminiTool.FunctionDeclarations) > 0 { + geminiTools = append(geminiTools, geminiTool) + } + } + } + + return geminiTools +} + +// convertResponsesToolChoiceToGemini converts Responses tool choice to Gemini tool config +func convertResponsesToolChoiceToGemini(toolChoice *schemas.ResponsesToolChoice) ToolConfig { + config := ToolConfig{} + + if toolChoice.ResponsesToolChoiceStruct != nil { + funcConfig := &FunctionCallingConfig{} + ext := toolChoice.ResponsesToolChoiceStruct + + if ext.Mode != nil { + switch *ext.Mode { + case "auto": + funcConfig.Mode = FunctionCallingConfigModeAuto + case "required": + funcConfig.Mode = FunctionCallingConfigModeAny + case "none": + funcConfig.Mode = FunctionCallingConfigModeNone + } + } + + if ext.Name != nil { + funcConfig.Mode = FunctionCallingConfigModeAny + funcConfig.AllowedFunctionNames = []string{*ext.Name} + } + + config.FunctionCallingConfig = funcConfig + return config + } + + // Handle string-based tool choice modes + if toolChoice.ResponsesToolChoiceStr != nil { + funcConfig := &FunctionCallingConfig{} + switch *toolChoice.ResponsesToolChoiceStr { + case "none": + funcConfig.Mode = FunctionCallingConfigModeNone + case "required", "any": + funcConfig.Mode = FunctionCallingConfigModeAny + default: // "auto" or any other value + funcConfig.Mode = FunctionCallingConfigModeAuto + } + config.FunctionCallingConfig = funcConfig + } + + return config +} + +// convertFunctionParametersToGeminiSchema converts function parameters to Gemini Schema +func convertFunctionParametersToGeminiSchema(params schemas.ToolFunctionParameters) *Schema { + schema := &Schema{ + Type: Type(params.Type), + } + + if params.Description != nil { + schema.Description = *params.Description + } + + if params.Properties != nil { + schema.Properties = make(map[string]*Schema) + for key, prop := range *params.Properties { + propSchema := convertPropertyToGeminiSchema(prop) + schema.Properties[key] = propSchema + } + } + + if params.Required != nil { + schema.Required = params.Required + } + + return schema +} + +// convertPropertyToGeminiSchema converts a property to Gemini Schema +func convertPropertyToGeminiSchema(prop interface{}) *Schema { + schema := &Schema{} + + // Handle property as map[string]interface{} + if propMap, ok := prop.(map[string]interface{}); ok { + if propType, exists := propMap["type"]; exists { + if typeStr, ok := propType.(string); ok { + schema.Type = Type(typeStr) + } + } + + if desc, exists := propMap["description"]; exists { + if descStr, ok := desc.(string); ok { + schema.Description = descStr + } + } + + if enum, exists := propMap["enum"]; exists { + if enumSlice, ok := enum.([]interface{}); ok { + var enumStrs []string + for _, item := range enumSlice { + if str, ok := item.(string); ok { + enumStrs = append(enumStrs, str) + } + } + schema.Enum = enumStrs + } + } + + // Handle nested properties for object types + if props, exists := propMap["properties"]; exists { + if propsMap, ok := props.(map[string]interface{}); ok { + schema.Properties = make(map[string]*Schema) + for key, nestedProp := range propsMap { + schema.Properties[key] = convertPropertyToGeminiSchema(nestedProp) + } + } + } + + // Handle array items + if items, exists := propMap["items"]; exists { + schema.Items = convertPropertyToGeminiSchema(items) + } + } + + return schema +} + +// convertResponsesMessagesToGeminiContents converts Responses messages to Gemini contents +func convertResponsesMessagesToGeminiContents(messages []schemas.ResponsesMessage) ([]Content, *Content, error) { + var contents []Content + var systemInstruction *Content + + for _, msg := range messages { + // Handle system messages separately + if msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleSystem { + if systemInstruction == nil { + systemInstruction = &Content{} + } + + // Convert system message content + if msg.Content != nil { + if msg.Content.ContentStr != nil { + systemInstruction.Parts = append(systemInstruction.Parts, &Part{ + Text: *msg.Content.ContentStr, + }) + } + if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + part, err := convertContentBlockToGeminiPart(block) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert system message content block: %w", err) + } + if part != nil { + systemInstruction.Parts = append(systemInstruction.Parts, part) + } + } + } + } + + continue + } + + // Handle regular messages + content := Content{} + + if msg.Role != nil { + content.Role = string(*msg.Role) + } else { + content.Role = "user" // Default role if msg.Role is nil + } + + // Convert message content + if msg.Content != nil { + if msg.Content.ContentStr != nil { + content.Parts = append(content.Parts, &Part{ + Text: *msg.Content.ContentStr, + }) + } + + if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + part, err := convertContentBlockToGeminiPart(block) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert message content block: %w", err) + } + if part != nil { + content.Parts = append(content.Parts, part) + } + } + } + } + + // Handle tool calls from assistant messages + if msg.ResponsesToolMessage != nil && msg.Type != nil { + switch *msg.Type { + case schemas.ResponsesMessageTypeFunctionCall: + // Convert function call to Gemini FunctionCall + if msg.ResponsesToolMessage.Name != nil { + argsMap := map[string]any{} + if msg.ResponsesToolMessage.Arguments != nil { + if err := sonic.Unmarshal([]byte(*msg.ResponsesToolMessage.Arguments), &argsMap); err != nil { + return nil, nil, fmt.Errorf("failed to decode function call arguments: %w", err) + } + } + + part := &Part{ + FunctionCall: &FunctionCall{ + Name: *msg.ResponsesToolMessage.Name, + Args: argsMap, + }, + } + if msg.ResponsesToolMessage.CallID != nil { + part.FunctionCall.ID = *msg.ResponsesToolMessage.CallID + } + content.Parts = append(content.Parts, part) + } + case schemas.ResponsesMessageTypeFunctionCallOutput: + // Convert function response to Gemini FunctionResponse + if msg.ResponsesToolMessage.CallID != nil { + responseMap := make(map[string]any) + if msg.Content != nil && msg.Content.ContentStr != nil { + responseMap["output"] = *msg.Content.ContentStr + } + + // Prefer the declared tool name; fallback to CallID if the name is absent + funcName := "" + if msg.ResponsesToolMessage.Name != nil && strings.TrimSpace(*msg.ResponsesToolMessage.Name) != "" { + funcName = *msg.ResponsesToolMessage.Name + } else { + funcName = *msg.ResponsesToolMessage.CallID + } + + part := &Part{ + FunctionResponse: &FunctionResponse{ + Name: funcName, + Response: responseMap, + }, + } + // Keep ID = CallID + part.FunctionResponse.ID = *msg.ResponsesToolMessage.CallID + content.Parts = append(content.Parts, part) + } + } + } + + if len(content.Parts) > 0 { + contents = append(contents, content) + } + } + + return contents, systemInstruction, nil +} + +// convertContentBlockToGeminiPart converts a content block to Gemini part +func convertContentBlockToGeminiPart(block schemas.ResponsesMessageContentBlock) (*Part, error) { + switch block.Type { + case schemas.ResponsesInputMessageContentBlockTypeText: + if block.Text != nil { + return &Part{ + Text: *block.Text, + }, nil + } + + case schemas.ResponsesInputMessageContentBlockTypeImage: + if block.ResponsesInputMessageContentBlockImage != nil && block.ResponsesInputMessageContentBlockImage.ImageURL != nil { + imageURL := *block.ResponsesInputMessageContentBlockImage.ImageURL + + // Use existing utility functions to handle URL parsing + sanitizedURL, err := schemas.SanitizeImageURL(imageURL) + if err != nil { + return nil, fmt.Errorf("failed to sanitize image URL: %w", err) + } + + urlInfo := schemas.ExtractURLTypeInfo(sanitizedURL) + mimeType := "image/jpeg" // default + if urlInfo.MediaType != nil { + mimeType = *urlInfo.MediaType + } + + if urlInfo.Type == schemas.ImageContentTypeBase64 { + data := "" + if urlInfo.DataURLWithoutPrefix != nil { + data = *urlInfo.DataURLWithoutPrefix + } + + // Decode base64 data + decodedData, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 image data: %w", err) + } + + return &Part{ + InlineData: &Blob{ + MIMEType: mimeType, + Data: decodedData, + }, + }, nil + } else { + return &Part{ + FileData: &FileData{ + MIMEType: mimeType, + FileURI: sanitizedURL, + }, + }, nil + } + } + + case schemas.ResponsesInputMessageContentBlockTypeAudio: + if block.Audio != nil { + // Decode base64 audio data + decodedData, err := base64.StdEncoding.DecodeString(block.Audio.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 audio data: %w", err) + } + + return &Part{ + InlineData: &Blob{ + MIMEType: func() string { + f := strings.ToLower(strings.TrimSpace(block.Audio.Format)) + if f == "" { + return "audio/mpeg" + } + if strings.HasPrefix(f, "audio/") { + return f + } + return "audio/" + f + }(), + Data: decodedData, + }, + }, nil + } + + case schemas.ResponsesInputMessageContentBlockTypeFile: + if block.ResponsesInputMessageContentBlockFile != nil { + if block.ResponsesInputMessageContentBlockFile.FileURL != nil { + return &Part{ + FileData: &FileData{ + MIMEType: "application/octet-stream", // default + FileURI: *block.ResponsesInputMessageContentBlockFile.FileURL, + }, + }, nil + } else if block.ResponsesInputMessageContentBlockFile.FileData != nil { + return &Part{ + InlineData: &Blob{ + MIMEType: "application/octet-stream", // default + Data: []byte(*block.ResponsesInputMessageContentBlockFile.FileData), + }, + }, nil + } + } + } + + return nil, nil +} diff --git a/core/providers/gemini/speech.go b/core/providers/gemini/speech.go new file mode 100644 index 000000000..b6c5c1a93 --- /dev/null +++ b/core/providers/gemini/speech.go @@ -0,0 +1,96 @@ +package gemini + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" +) + +func ToGeminiSpeechRequest(bifrostReq *schemas.BifrostSpeechRequest) *GeminiGenerationRequest { + if bifrostReq == nil { + return nil + } + + // Create the base Gemini generation request + geminiReq := &GeminiGenerationRequest{ + Model: bifrostReq.Model, + } + + // Convert parameters to generation config + geminiReq.GenerationConfig.ResponseModalities = []Modality{ModalityAudio} + + // Convert speech input to Gemini format + if bifrostReq.Input.Input != "" { + geminiReq.Contents = []Content{ + { + Parts: []*Part{ + { + Text: bifrostReq.Input.Input, + }, + }, + }, + } + + // Add speech config to generation config if voice config is provided + if bifrostReq.Params != nil && bifrostReq.Params.VoiceConfig != nil && bifrostReq.Params.VoiceConfig.Voice != nil { + addSpeechConfigToGenerationConfig(&geminiReq.GenerationConfig, bifrostReq.Params.VoiceConfig) + } + } + + return geminiReq +} + +// ToBifrostSpeechResponse converts a GenerateContentResponse to a BifrostSpeechResponse +func (response *GenerateContentResponse) ToBifrostSpeechResponse() *schemas.BifrostSpeechResponse { + bifrostResp := &schemas.BifrostSpeechResponse{} + + // Process candidates to extract audio content + if len(response.Candidates) > 0 { + candidate := response.Candidates[0] + if candidate.Content != nil && len(candidate.Content.Parts) > 0 { + var audioData []byte + + // Extract audio data from all parts + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.Data != nil { + // Check if this is audio data + if strings.HasPrefix(part.InlineData.MIMEType, "audio/") { + audioData = append(audioData, part.InlineData.Data...) + } + } + } + + if len(audioData) > 0 { + bifrostResp.Audio = audioData + } + } + } + + return bifrostResp +} + +// ToGeminiSpeechResponse converts a BifrostSpeechResponse to Gemini's GenerateContentResponse +func ToGeminiSpeechResponse(bifrostResp *schemas.BifrostSpeechResponse) *GenerateContentResponse { + if bifrostResp == nil { + return nil + } + + genaiResp := &GenerateContentResponse{} + + candidate := &Candidate{ + Content: &Content{ + Parts: []*Part{ + { + InlineData: &Blob{ + Data: bifrostResp.Audio, + MIMEType: detectAudioMimeType(bifrostResp.Audio), + }, + }, + }, + Role: string(RoleModel), + }, + } + + genaiResp.Candidates = []*Candidate{candidate} + return genaiResp +} diff --git a/core/providers/gemini/transcription.go b/core/providers/gemini/transcription.go new file mode 100644 index 000000000..b627a4f20 --- /dev/null +++ b/core/providers/gemini/transcription.go @@ -0,0 +1,154 @@ +package gemini + +import "github.com/maximhq/bifrost/core/schemas" + +func ToGeminiTranscriptionRequest(bifrostReq *schemas.BifrostTranscriptionRequest) *GeminiGenerationRequest { + if bifrostReq == nil { + return nil + } + + // Create the base Gemini generation request + geminiReq := &GeminiGenerationRequest{ + Model: bifrostReq.Model, + } + + // Convert parameters to generation config + if bifrostReq.Params != nil { + + // Handle extra parameters + if bifrostReq.Params.ExtraParams != nil { + // Safety settings + if safetySettings, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "safety_settings"); ok { + if settings, ok := safetySettings.([]SafetySetting); ok { + geminiReq.SafetySettings = settings + } + } + + // Cached content + if cachedContent, ok := schemas.SafeExtractString(bifrostReq.Params.ExtraParams["cached_content"]); ok { + geminiReq.CachedContent = cachedContent + } + + // Labels + if labels, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "labels"); ok { + if labelMap, ok := labels.(map[string]string); ok { + geminiReq.Labels = labelMap + } + } + } + } + + // Determine the prompt text + var prompt string + if bifrostReq.Params != nil && bifrostReq.Params.Prompt != nil { + prompt = *bifrostReq.Params.Prompt + } else { + prompt = "Generate a transcript of the speech." + } + + // Create parts for the transcription request + parts := []*Part{ + { + Text: prompt, + }, + } + + // Add audio file if present + if len(bifrostReq.Input.File) > 0 { + parts = append(parts, &Part{ + InlineData: &Blob{ + MIMEType: detectAudioMimeType(bifrostReq.Input.File), + Data: bifrostReq.Input.File, + }, + }) + } + + geminiReq.Contents = []Content{ + { + Parts: parts, + }, + } + + return geminiReq +} + +// ToBifrostTranscriptionResponse converts a GenerateContentResponse to a BifrostTranscriptionResponse +func (response *GenerateContentResponse) ToBifrostTranscriptionResponse() *schemas.BifrostTranscriptionResponse { + bifrostResp := &schemas.BifrostTranscriptionResponse{} + + // Extract usage metadata + inputTokens, outputTokens, totalTokens, _, _ := response.extractUsageMetadata() + + // Process candidates to extract text content + if len(response.Candidates) > 0 { + candidate := response.Candidates[0] + if candidate.Content != nil && len(candidate.Content.Parts) > 0 { + var textContent string + + // Extract text content from all parts + for _, part := range candidate.Content.Parts { + if part.Text != "" { + textContent += part.Text + } + } + + if textContent != "" { + bifrostResp.Text = textContent + bifrostResp.Task = schemas.Ptr("transcribe") + + // Set usage information + bifrostResp.Usage = &schemas.TranscriptionUsage{ + Type: "tokens", + InputTokens: &inputTokens, + OutputTokens: &outputTokens, + TotalTokens: &totalTokens, + } + } + } + } + + return bifrostResp +} + +// ToGeminiTranscriptionResponse converts a BifrostTranscriptionResponse to Gemini's GenerateContentResponse +func ToGeminiTranscriptionResponse(bifrostResp *schemas.BifrostTranscriptionResponse) *GenerateContentResponse { + if bifrostResp == nil { + return nil + } + + genaiResp := &GenerateContentResponse{} + + candidate := &Candidate{ + Content: &Content{ + Parts: []*Part{ + { + Text: bifrostResp.Text, + }, + }, + Role: string(RoleModel), + }, + } + + // Set usage metadata from transcription usage + if bifrostResp.Usage != nil { + var promptTokens, candidatesTokens, totalTokens int32 + if bifrostResp.Usage.InputTokens != nil { + promptTokens = int32(*bifrostResp.Usage.InputTokens) + } + if bifrostResp.Usage.OutputTokens != nil { + candidatesTokens = int32(*bifrostResp.Usage.OutputTokens) + } + if bifrostResp.Usage.TotalTokens != nil { + totalTokens = int32(*bifrostResp.Usage.TotalTokens) + } + + genaiResp.UsageMetadata = &GenerateContentResponseUsageMetadata{ + PromptTokenCount: promptTokens, + CandidatesTokenCount: candidatesTokens, + TotalTokenCount: totalTokens, + } + } + + genaiResp.Candidates = []*Candidate{candidate} + return genaiResp +} diff --git a/core/providers/gemini/types.go b/core/providers/gemini/types.go new file mode 100644 index 000000000..a49fa0f7a --- /dev/null +++ b/core/providers/gemini/types.go @@ -0,0 +1,1275 @@ +package gemini + +import ( + "encoding/json" + "reflect" + "time" +) + +type Role string + +const ( + RoleUser = "user" + RoleModel = "model" +) + +// The reason why the model stopped generating tokens. +// If empty, the model has not stopped generating the tokens. +type FinishReason string + +const ( + // The finish reason is unspecified. + FinishReasonUnspecified FinishReason = "FINISH_REASON_UNSPECIFIED" + // Token generation reached a natural stopping point or a configured stop sequence. + FinishReasonStop FinishReason = "STOP" + // Token generation reached the configured maximum output tokens. + FinishReasonMaxTokens FinishReason = "MAX_TOKENS" + // Token generation stopped because the content potentially contains safety violations. + // NOTE: When streaming, [content][] is empty if content filters blocks the output. + FinishReasonSafety FinishReason = "SAFETY" + // The token generation stopped because of potential recitation. + FinishReasonRecitation FinishReason = "RECITATION" + // The token generation stopped because of using an unsupported language. + FinishReasonLanguage FinishReason = "LANGUAGE" + // All other reasons that stopped the token generation. + FinishReasonOther FinishReason = "OTHER" + // Token generation stopped because the content contains forbidden terms. + FinishReasonBlocklist FinishReason = "BLOCKLIST" + // Token generation stopped for potentially containing prohibited content. + FinishReasonProhibitedContent FinishReason = "PROHIBITED_CONTENT" + // Token generation stopped because the content potentially contains Sensitive Personally + // Identifiable Information (SPII). + FinishReasonSPII FinishReason = "SPII" + // The function call generated by the model is invalid. + FinishReasonMalformedFunctionCall FinishReason = "MALFORMED_FUNCTION_CALL" + // Token generation stopped because generated images have safety violations. + FinishReasonImageSafety FinishReason = "IMAGE_SAFETY" + // The tool call generated by the model is invalid. + FinishReasonUnexpectedToolCall FinishReason = "UNEXPECTED_TOOL_CALL" +) + +type GeminiGenerationRequest struct { + Model string `json:"model,omitempty"` // Model field for explicit model specification + Contents []Content `json:"contents,omitempty"` // For chat completion requests + Requests []GeminiEmbeddingRequest `json:"requests,omitempty"` // For batch embedding requests + SystemInstruction *Content `json:"systemInstruction,omitempty"` + GenerationConfig GenerationConfig `json:"generationConfig,omitempty"` + SafetySettings []SafetySetting `json:"safetySettings,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ToolConfig ToolConfig `json:"toolConfig,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + CachedContent string `json:"cachedContent,omitempty"` + Stream bool `json:"-"` // Internal field to track streaming requests + IsEmbedding bool `json:"-"` // Internal field to track if this is an embedding request +} + +// IsStreamingRequested implements the StreamingRequest interface +func (r *GeminiGenerationRequest) IsStreamingRequested() bool { + return r.Stream +} + +// Safety settings. +type SafetySetting struct { + // Optional. Determines if the harm block method uses probability or probability + // and severity scores. + Method string `json:"method,omitempty"` + // Required. Harm category. + Category string `json:"category,omitempty"` + // Required. The harm block threshold. + Threshold string `json:"threshold,omitempty"` +} + +// Function calling config. +type FunctionCallingConfig struct { + // Optional. Function calling mode. + Mode FunctionCallingConfigMode `json:"mode,omitempty"` + // Optional. Function names to call. Only set when the Mode is ANY. Function names should + // match [FunctionDeclaration.Name]. With mode set to ANY, model will predict a function + // call from the set of function names provided. + AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"` +} + +// Config for the function calling config mode. +type FunctionCallingConfigMode string + +const ( + // The function calling config mode is unspecified. Should not be used. + FunctionCallingConfigModeUnspecified FunctionCallingConfigMode = "MODE_UNSPECIFIED" + // Default model behavior, model decides to predict either function calls or natural + // language response. + FunctionCallingConfigModeAuto FunctionCallingConfigMode = "AUTO" + // Model is constrained to always predicting function calls only. If "allowed_function_names" + // are set, the predicted function calls will be limited to any one of "allowed_function_names", + // else the predicted function calls will be any one of the provided "function_declarations". + FunctionCallingConfigModeAny FunctionCallingConfigMode = "ANY" + // Model will not predict any function calls. Model behavior is same as when not passing + // any function declarations. + FunctionCallingConfigModeNone FunctionCallingConfigMode = "NONE" + // Model decides to predict either a function call or a natural language response, but + // will validate function calls with constrained decoding. If "allowed_function_names" + // are set, the predicted function call will be limited to any one of "allowed_function_names", + // else the predicted function call will be any one of the provided "function_declarations". + FunctionCallingConfigModeValidated FunctionCallingConfigMode = "VALIDATED" +) + +// An object that represents a latitude/longitude pair. +// This is expressed as a pair of doubles to represent degrees latitude and +// degrees longitude. Unless specified otherwise, this object must conform to the +// +// WGS84 standard. Values must be within normalized ranges. +type LatLng struct { + // Optional. The latitude in degrees. It must be in the range [-90.0, +90.0]. + Latitude *float64 `json:"latitude,omitempty"` + // Optional. The longitude in degrees. It must be in the range [-180.0, +180.0] + Longitude *float64 `json:"longitude,omitempty"` +} + +// Retrieval config. +type RetrievalConfig struct { + // Optional. The location of the user. + LatLng *LatLng `json:"latLng,omitempty"` + // The language code of the user. + LanguageCode string `json:"languageCode,omitempty"` +} + +// Tool config. +// This config is shared for all tools provided in the request. +type ToolConfig struct { + // Optional. Function calling config. + FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` + // Optional. Retrieval config. + RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` +} + +// Defines a function that the model can generate JSON inputs for. +// The inputs are based on `OpenAPI 3.0 specifications +// `_. +type FunctionDeclaration struct { + // Optional. Defines the function behavior. + Behavior Behavior `json:"behavior,omitempty"` + // Optional. Description and purpose of the function. Model uses it to decide how and + // whether to call the function. + Description string `json:"description,omitempty"` + // Required. The name of the function to call. Must start with a letter or an underscore. + // Must be a-z, A-Z, 0-9, or contain underscores, dots and dashes, with a maximum length + // of 64. + Name string `json:"name,omitempty"` + // Optional. Describes the parameters to this function in JSON Schema Object format. + // Reflects the Open API 3.03 Parameter Object. string Key: the name of the parameter. + // Parameter names are case sensitive. Schema Value: the Schema defining the type used + // for the parameter. For function with no parameters, this can be left unset. Parameter + // names must start with a letter or an underscore and must only contain chars a-z, + // A-Z, 0-9, or underscores with a maximum length of 64. Example with 1 required and + // 1 optional parameter: type: OBJECT properties: param1: type: STRING param2: type: + // INTEGER required: - param1 + Parameters *Schema `json:"parameters,omitempty"` + // Optional. Describes the parameters to the function in JSON Schema format. The schema + // must describe an object where the properties are the parameters to the function. + // For example: ``` { "type": "object", "properties": { "name": { "type": "string" }, + // "age": { "type": "integer" } }, "additionalProperties": false, "required": ["name", + // "age"], "propertyOrdering": ["name", "age"] } ``` This field is mutually exclusive + // with `parameters`. + ParametersJsonSchema any `json:"parametersJsonSchema,omitempty"` + // Optional. Describes the output from this function in JSON Schema format. Reflects + // the Open API 3.03 Response Object. The Schema defines the type used for the response + // value of the function. + Response *Schema `json:"response,omitempty"` + // Optional. Describes the output from this function in JSON Schema format. The value + // specified by the schema is the response value of the function. This field is mutually + // exclusive with `response`. + ResponseJsonSchema any `json:"responseJsonSchema,omitempty"` +} + +// Defines the function behavior. Defaults to `BLOCKING`. +type Behavior string + +const ( + // This value is unused. + BehaviorUnspecified Behavior = "UNSPECIFIED" + // If set, the system will wait to receive the function response before continuing the + // conversation. + BehaviorBlocking Behavior = "BLOCKING" + // If set, the system will not wait to receive the function response. Instead, it will + // attempt to handle function responses as they become available while maintaining the + // conversation between the user and the model. + BehaviorNonBlocking Behavior = "NON_BLOCKING" +) + +// Represents a time interval, encoded as a start time (inclusive) and an end time (exclusive). +// The start time must be less than or equal to the end time. +// When the start equals the end time, the interval is an empty interval. +// (matches no time) +// When both start and end are unspecified, the interval matches any time. +type Interval struct { + // Optional. The start time of the interval. + StartTime time.Time `json:"startTime,omitempty"` + // Optional. The end time of the interval. + EndTime time.Time `json:"endTime,omitempty"` +} + +func (i *Interval) UnmarshalJSON(data []byte) error { + type Alias Interval + aux := &struct { + StartTime *time.Time `json:"startTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` + *Alias + }{ + Alias: (*Alias)(i), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if !reflect.ValueOf(aux.StartTime).IsZero() { + i.StartTime = time.Time(*aux.StartTime) + } + + if !reflect.ValueOf(aux.EndTime).IsZero() { + i.EndTime = time.Time(*aux.EndTime) + } + + return nil +} + +func (i *Interval) MarshalJSON() ([]byte, error) { + type Alias Interval + aux := &struct { + StartTime *time.Time `json:"startTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` + *Alias + }{ + Alias: (*Alias)(i), + } + + if !reflect.ValueOf(i.StartTime).IsZero() { + aux.StartTime = (*time.Time)(&i.StartTime) + } + + if !reflect.ValueOf(i.EndTime).IsZero() { + aux.EndTime = (*time.Time)(&i.EndTime) + } + + return json.Marshal(aux) +} + +// Tool to support Google Search in Model. Powered by Google. +type GoogleSearch struct { + // Optional. Filter search results to a specific time range. + // If customers set a start time, they must set an end time (and vice versa). + TimeRangeFilter *Interval `json:"timeRangeFilter,omitempty"` + // Optional. List of domains to be excluded from the search results. + // The default limit is 2000 domains. + ExcludeDomains []string `json:"excludeDomains,omitempty"` +} + +// Describes the options to customize dynamic retrieval. +type DynamicRetrievalConfig struct { + // Optional. The mode of the predictor to be used in dynamic retrieval. + Mode string `json:"mode,omitempty"` + // Optional. The threshold to be used in dynamic retrieval. If empty, a system default + // value is used. + DynamicThreshold *float32 `json:"dynamicThreshold,omitempty"` +} + +// Tool to retrieve public web data for grounding, powered by Google. +type GoogleSearchRetrieval struct { + // Optional. Specifies the dynamic retrieval configuration for the given source. + DynamicRetrievalConfig *DynamicRetrievalConfig `json:"dynamicRetrievalConfig,omitempty"` +} + +// Tool to search public web data, powered by Vertex AI Search and Sec4 compliance. +type EnterpriseWebSearch struct { + // Optional. List of domains to be excluded from the search results. The default limit + // is 2000 domains. + ExcludeDomains []string `json:"excludeDomains,omitempty"` +} + +// Config for authentication with API key. +type APIKeyConfig struct { + // Optional. The API key to be used in the request directly. + APIKeyString string `json:"apiKeyString,omitempty"` +} + +// Config for Google Service Account Authentication. +type AuthConfigGoogleServiceAccountConfig struct { + // Optional. The service account that the extension execution service runs as. - If + // the service account is specified, the `iam.serviceAccounts.getAccessToken` permission + // should be granted to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) + // on the specified service account. - If not specified, the Vertex AI Extension Service + // Agent will be used to execute the Extension. + ServiceAccount string `json:"serviceAccount,omitempty"` +} + +// Config for HTTP Basic Authentication. +type AuthConfigHTTPBasicAuthConfig struct { + // Required. The name of the SecretManager secret version resource storing the base64 + // encoded credentials. Format: `projects/{project}/secrets/{secrete}/versions/{version}` + // - If specified, the `secretmanager.versions.access` permission should be granted + // to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) + // on the specified resource. + CredentialSecret string `json:"credentialSecret,omitempty"` +} + +// Config for user oauth. +type AuthConfigOauthConfig struct { + // Access token for extension endpoint. Only used to propagate token from [[ExecuteExtensionRequest.runtime_auth_config]] + // at request time. + AccessToken string `json:"accessToken,omitempty"` + // The service account used to generate access tokens for executing the Extension. - + // If the service account is specified, the `iam.serviceAccounts.getAccessToken` permission + // should be granted to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) + // on the provided service account. + ServiceAccount string `json:"serviceAccount,omitempty"` +} + +// Config for user OIDC auth. +type AuthConfigOidcConfig struct { + // OpenID Connect formatted ID token for extension endpoint. Only used to propagate + // token from [[ExecuteExtensionRequest.runtime_auth_config]] at request time. + IDToken string `json:"idToken,omitempty"` + // The service account used to generate an OpenID Connect (OIDC)-compatible JWT token + // signed by the Google OIDC Provider (accounts.google.com) for extension endpoint (https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-oidc). + // - The audience for the token will be set to the URL in the server URL defined in + // the OpenAPI spec. - If the service account is provided, the service account should + // grant `iam.serviceAccounts.getOpenIDToken` permission to Vertex AI Extension Service + // Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents). + ServiceAccount string `json:"serviceAccount,omitempty"` +} + +// Auth configuration to run the extension. +type AuthConfig struct { + // Optional. Config for API key auth. + APIKeyConfig *APIKeyConfig `json:"apiKeyConfig,omitempty"` + // Type of auth scheme. + AuthType AuthType `json:"authType,omitempty"` + // Config for Google Service Account auth. + GoogleServiceAccountConfig *AuthConfigGoogleServiceAccountConfig `json:"googleServiceAccountConfig,omitempty"` + // Config for HTTP Basic auth. + HTTPBasicAuthConfig *AuthConfigHTTPBasicAuthConfig `json:"httpBasicAuthConfig,omitempty"` + // Config for user oauth. + OauthConfig *AuthConfigOauthConfig `json:"oauthConfig,omitempty"` + // Config for user OIDC auth. + OidcConfig *AuthConfigOidcConfig `json:"oidcConfig,omitempty"` +} + +// Type of auth scheme. +type AuthType string + +const ( + AuthTypeUnspecified AuthType = "AUTH_TYPE_UNSPECIFIED" + // No Auth. + AuthTypeNoAuth AuthType = "NO_AUTH" + // API Key Auth. + AuthTypeAPIKeyAuth AuthType = "API_KEY_AUTH" + // HTTP Basic Auth. + AuthTypeHTTPBasicAuth AuthType = "HTTP_BASIC_AUTH" + // Google Service Account Auth. + AuthTypeGoogleServiceAccountAuth AuthType = "GOOGLE_SERVICE_ACCOUNT_AUTH" + // OAuth auth. + AuthTypeOauth AuthType = "OAUTH" + // OpenID Connect (OIDC) Auth. + AuthTypeOidcAuth AuthType = "OIDC_AUTH" +) + +// Tool to support Google Maps in Model. +type GoogleMaps struct { + // Optional. Auth config for the Google Maps tool. + AuthConfig *AuthConfig `json:"authConfig,omitempty"` +} + +// Tool to support URL context retrieval. +type URLContext struct { +} + +// Tool to support computer use. +type ToolComputerUse struct { + // Optional. Required. The environment being operated. + Environment Environment `json:"environment,omitempty"` +} + +// The environment being operated. +type Environment string + +const ( + // Defaults to browser. + EnvironmentUnspecified Environment = "ENVIRONMENT_UNSPECIFIED" + // Operates in a web browser. + EnvironmentBrowser Environment = "ENVIRONMENT_BROWSER" +) + +// The API secret. +type APIAuthAPIKeyConfig struct { + // Required. The SecretManager secret version resource name storing API key. e.g. projects/{project}/secrets/{secret}/versions/{version} + APIKeySecretVersion string `json:"apiKeySecretVersion,omitempty"` + // The API key string. Either this or `api_key_secret_version` must be set. + APIKeyString string `json:"apiKeyString,omitempty"` +} + +// The generic reusable API auth config. Deprecated. Please use AuthConfig (google/cloud/aiplatform/master/auth.proto) +// instead. +type APIAuth struct { + // The API secret. + APIKeyConfig *APIAuthAPIKeyConfig `json:"apiKeyConfig,omitempty"` +} + +// The search parameters to use for the ELASTIC_SEARCH spec. +type ExternalAPIElasticSearchParams struct { + // The ElasticSearch index to use. + Index string `json:"index,omitempty"` + // Optional. Number of hits (chunks) to request. When specified, it is passed to Elasticsearch + // as the `num_hits` param. + NumHits *int32 `json:"numHits,omitempty"` + // The ElasticSearch search template to use. + SearchTemplate string `json:"searchTemplate,omitempty"` +} + +// The search parameters to use for SIMPLE_SEARCH spec. +type ExternalAPISimpleSearchParams struct { +} + +// Retrieve from data source powered by external API for grounding. The external API +// is not owned by Google, but need to follow the pre-defined API spec. +type ExternalAPI struct { + // The authentication config to access the API. Deprecated. Please use auth_config instead. + APIAuth *APIAuth `json:"apiAuth,omitempty"` + // The API spec that the external API implements. + APISpec APISpec `json:"apiSpec,omitempty"` + // The authentication config to access the API. + AuthConfig *AuthConfig `json:"authConfig,omitempty"` + // Parameters for the elastic search API. + ElasticSearchParams *ExternalAPIElasticSearchParams `json:"elasticSearchParams,omitempty"` + // The endpoint of the external API. The system will call the API at this endpoint to + // retrieve the data for grounding. Example: https://acme.com:443/search + Endpoint string `json:"endpoint,omitempty"` + // Parameters for the simple search API. + SimpleSearchParams *ExternalAPISimpleSearchParams `json:"simpleSearchParams,omitempty"` +} + +// The API spec that the external API implements. +type APISpec string + +const ( + // Unspecified API spec. This value should not be used. + APISpecUnspecified APISpec = "API_SPEC_UNSPECIFIED" + // Simple search API spec. + APISpecSimpleSearch APISpec = "SIMPLE_SEARCH" + // Elastic search API spec. + APISpecElasticSearch APISpec = "ELASTIC_SEARCH" +) + +// Define data stores within engine to filter on in a search call and configurations +// for those data stores. For more information, see https://cloud.google.com/generative-ai-app-builder/docs/reference/rpc/google.cloud.discoveryengine.v1#datastorespec +type VertexAISearchDataStoreSpec struct { + // Full resource name of DataStore, such as Format: `projects/{project}/locations/{location}/collections/{collection}/dataStores/{dataStore}` + DataStore string `json:"dataStore,omitempty"` + // Optional. Filter specification to filter documents in the data store specified by + // data_store field. For more information on filtering, see [Filtering](https://cloud.google.com/generative-ai-app-builder/docs/filter-search-metadata) + Filter string `json:"filter,omitempty"` +} + +// Retrieve from Vertex AI Search datastore or engine for grounding. datastore and engine +// are mutually exclusive. See https://cloud.google.com/products/agent-builder +type VertexAISearch struct { + // Specifications that define the specific DataStores to be searched, along with configurations + // for those data stores. This is only considered for Engines with multiple data stores. + // It should only be set if engine is used. + DataStoreSpecs []*VertexAISearchDataStoreSpec `json:"dataStoreSpecs,omitempty"` + // Optional. Fully-qualified Vertex AI Search data store resource ID. Format: `projects/{project}/locations/{location}/collections/{collection}/dataStores/{dataStore}` + Datastore string `json:"datastore,omitempty"` + // Optional. Fully-qualified Vertex AI Search engine resource ID. Format: `projects/{project}/locations/{location}/collections/{collection}/engines/{engine}` + Engine string `json:"engine,omitempty"` + // Optional. Filter strings to be passed to the search API. + Filter string `json:"filter,omitempty"` + // Optional. Number of search results to return per query. The default value is 10. + // The maximumm allowed value is 10. + MaxResults *int32 `json:"maxResults,omitempty"` +} + +// The definition of the RAG resource. +type VertexRAGStoreRAGResource struct { + // Optional. RAGCorpora resource name. Format: `projects/{project}/locations/{location}/ragCorpora/{rag_corpus}` + RAGCorpus string `json:"ragCorpus,omitempty"` + // Optional. rag_file_id. The files should be in the same rag_corpus set in rag_corpus + // field. + RAGFileIDs []string `json:"ragFileIds,omitempty"` +} + +// Config for filters. +type RAGRetrievalConfigFilter struct { + // Optional. String for metadata filtering. + MetadataFilter string `json:"metadataFilter,omitempty"` + // Optional. Only returns contexts with vector distance smaller than the threshold. + VectorDistanceThreshold *float64 `json:"vectorDistanceThreshold,omitempty"` + // Optional. Only returns contexts with vector similarity larger than the threshold. + VectorSimilarityThreshold *float64 `json:"vectorSimilarityThreshold,omitempty"` +} + +// Config for Hybrid Search. +type RAGRetrievalConfigHybridSearch struct { + // Optional. Alpha value controls the weight between dense and sparse vector search + // results. The range is [0, 1], while 0 means sparse vector search only and 1 means + // dense vector search only. The default value is 0.5 which balances sparse and dense + // vector search equally. + Alpha *float64 `json:"alpha,omitempty"` +} + +// Config for LlmRanker. +type RAGRetrievalConfigRankingLlmRanker struct { + // Optional. The model name used for ranking. See [Supported models](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#supported-models). + ModelName string `json:"modelName,omitempty"` +} + +// Config for Rank Service. +type RAGRetrievalConfigRankingRankService struct { + // Optional. The model name of the rank service. Format: `semantic-ranker-512@latest` + ModelName string `json:"modelName,omitempty"` +} + +// Config for ranking and reranking. +type RAGRetrievalConfigRanking struct { + // Optional. Config for LlmRanker. + LlmRanker *RAGRetrievalConfigRankingLlmRanker `json:"llmRanker,omitempty"` + // Optional. Config for Rank Service. + RankService *RAGRetrievalConfigRankingRankService `json:"rankService,omitempty"` +} + +// Specifies the context retrieval config. +type RAGRetrievalConfig struct { + // Optional. Config for filters. + Filter *RAGRetrievalConfigFilter `json:"filter,omitempty"` + // Optional. Config for Hybrid Search. + HybridSearch *RAGRetrievalConfigHybridSearch `json:"hybridSearch,omitempty"` + // Optional. Config for ranking and reranking. + Ranking *RAGRetrievalConfigRanking `json:"ranking,omitempty"` + // Optional. The number of contexts to retrieve. + TopK *int32 `json:"topK,omitempty"` +} + +// Retrieve from Vertex RAG Store for grounding. You can find API default values and +// more details at https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/rag-api-v1#parameters-list +type VertexRAGStore struct { + // Optional. Deprecated. Please use rag_resources instead. + RAGCorpora []string `json:"ragCorpora,omitempty"` + // Optional. The representation of the RAG source. It can be used to specify corpus + // only or ragfiles. Currently only support one corpus or multiple files from one corpus. + // In the future we may open up multiple corpora support. + RAGResources []*VertexRAGStoreRAGResource `json:"ragResources,omitempty"` + // Optional. The retrieval config for the RAG query. + RAGRetrievalConfig *RAGRetrievalConfig `json:"ragRetrievalConfig,omitempty"` + // Optional. Number of top k results to return from the selected corpora. + SimilarityTopK *int32 `json:"similarityTopK,omitempty"` + // Optional. Currently only supported for Gemini Multimodal Live API. In Gemini Multimodal + // Live API, if `store_context` bool is specified, Gemini will leverage it to automatically + // memorize the interactions between the client and Gemini, and retrieve context when + // needed to augment the response generation for users' ongoing and future interactions. + StoreContext *bool `json:"storeContext,omitempty"` + // Optional. Only return results with vector distance smaller than the threshold. + VectorDistanceThreshold *float64 `json:"vectorDistanceThreshold,omitempty"` +} + +// Defines a retrieval tool that model can call to access external knowledge. +type Retrieval struct { + // Optional. Deprecated. This option is no longer supported. + DisableAttribution bool `json:"disableAttribution,omitempty"` + // Use data source powered by external API for grounding. + ExternalAPI *ExternalAPI `json:"externalApi,omitempty"` + // Set to use data source powered by Vertex AI Search. + VertexAISearch *VertexAISearch `json:"vertexAiSearch,omitempty"` + // Set to use data source powered by Vertex RAG store. User data is uploaded via the + // VertexRAGDataService. + VertexRAGStore *VertexRAGStore `json:"vertexRagStore,omitempty"` +} + +// Tool that executes code generated by the model, and automatically returns the result +// to the model. See also [ExecutableCode]and [CodeExecutionResult] which are input +// and output to this tool. +type ToolCodeExecution struct { +} + +// Tool details of a tool that the model may use to generate a response. +type Tool struct { + // Optional. List of function declarations that the tool supports. + FunctionDeclarations []*FunctionDeclaration `json:"functionDeclarations,omitempty"` + // Optional. Retrieval tool type. System will always execute the provided retrieval + // tool(s) to get external knowledge to answer the prompt. Retrieval results are presented + // to the model for generation. + Retrieval *Retrieval `json:"retrieval,omitempty"` + // Optional. Google Search tool type. Specialized retrieval tool + // that is powered by Google Search. + GoogleSearch *GoogleSearch `json:"googleSearch,omitempty"` + // Optional. GoogleSearchRetrieval tool type. Specialized retrieval tool that is powered + // by Google search. + GoogleSearchRetrieval *GoogleSearchRetrieval `json:"googleSearchRetrieval,omitempty"` + // Optional. Enterprise web search tool type. Specialized retrieval + // tool that is powered by Vertex AI Search and Sec4 compliance. + EnterpriseWebSearch *EnterpriseWebSearch `json:"enterpriseWebSearch,omitempty"` + // Optional. Google Maps tool type. Specialized retrieval tool + // that is powered by Google Maps. + GoogleMaps *GoogleMaps `json:"googleMaps,omitempty"` + // Optional. Tool to support URL context retrieval. + URLContext *URLContext `json:"urlContext,omitempty"` + // Optional. Tool to support the model interacting directly with the + // computer. If enabled, it automatically populates computer-use specific + // Function Declarations. + ComputerUse *ToolComputerUse `json:"computerUse,omitempty"` + // Optional. CodeExecution tool type. Enables the model to execute code as part of generation. + CodeExecution *ToolCodeExecution `json:"codeExecution,omitempty"` +} + +// Generation config. You can find API default values and more details at https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig +// and https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/content-generation-parameters. +type GenerationConfig struct { + // Optional. Config for model selection. + ModelSelectionConfig *ModelSelectionConfig `json:"modelSelectionConfig,omitempty"` + // Optional. If enabled, audio timestamp will be included in the request to the model. + AudioTimestamp bool `json:"audioTimestamp,omitempty"` + // Optional. Number of candidates to generate. If empty, the system will choose a default + // value (currently 1). + CandidateCount int32 `json:"candidateCount,omitempty"` + // Optional. If enabled, the model will detect emotions and adapt its responses accordingly. + EnableAffectiveDialog *bool `json:"enableAffectiveDialog,omitempty"` + // Optional. Frequency penalties. + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` + // Optional. Logit probabilities. + Logprobs *int32 `json:"logprobs,omitempty"` + // Optional. The maximum number of output tokens to generate per message. If empty, + // API will use a default value. The default value varies by model. + MaxOutputTokens int32 `json:"maxOutputTokens,omitempty"` + // Optional. If specified, the media resolution specified will be used. + MediaResolution string `json:"mediaResolution,omitempty"` + // Optional. Positive penalties. + PresencePenalty *float64 `json:"presencePenalty,omitempty"` + // Optional. Output schema of the generated response. This is an alternative to `response_schema` + // that accepts [JSON Schema](https://json-schema.org/). If set, `response_schema` must + // be omitted, but `response_mime_type` is required. While the full JSON Schema may + // be sent, not all features are supported. Specifically, only the following properties + // are supported: - `$id` - `$defs` - `$ref` - `$anchor` - `type` - `format` - `title` + // - `description` - `enum` (for strings and numbers) - `items` - `prefixItems` - `minItems` + // - `maxItems` - `minimum` - `maximum` - `anyOf` - `oneOf` (interpreted the same as + // `anyOf`) - `properties` - `additionalProperties` - `required` The non-standard `propertyOrdering` + // property may also be set. Cyclic references are unrolled to a limited degree and, + // as such, may only be used within non-required properties. (Nullable properties are + // not sufficient.) If `$ref` is set on a sub-schema, no other properties, except for + // than those starting as a `$`, may be set. + ResponseJsonSchema any `json:"responseJsonSchema,omitempty"` + // Optional. If true, export the logprobs results in response. + ResponseLogprobs bool `json:"responseLogprobs,omitempty"` + // Optional. Output response mimetype of the generated candidate text. Supported mimetype: + // - `text/plain`: (default) Text output. - `application/json`: JSON response in the + // candidates. The model needs to be prompted to output the appropriate response type, + // otherwise the behavior is undefined. This is a preview feature. + ResponseMIMEType string `json:"responseMimeType,omitempty"` + // Optional. The modalities of the response. + ResponseModalities []Modality `json:"responseModalities,omitempty"` + // Optional. The `Schema` object allows the definition of input and output data types. + // These types can be objects, but also primitives and arrays. Represents a select subset + // of an [OpenAPI 3.0 schema object](https://spec.openapis.org/oas/v3.0.3#schema). If + // set, a compatible response_mime_type must also be set. Compatible mimetypes: `application/json`: + // Schema for JSON response. + ResponseSchema *Schema `json:"responseSchema,omitempty"` + // Optional. Routing configuration. + RoutingConfig *GenerationConfigRoutingConfig `json:"routingConfig,omitempty"` + // Optional. Seed. + Seed *int32 `json:"seed,omitempty"` + // Optional. The speech generation config. + SpeechConfig *SpeechConfig `json:"speechConfig,omitempty"` + // Optional. Stop sequences. + StopSequences []string `json:"stopSequences,omitempty"` + // Optional. Controls the randomness of predictions. + Temperature *float64 `json:"temperature,omitempty"` + // Optional. Config for thinking features. An error will be returned if this field is + // set for models that don't support thinking. + ThinkingConfig *GenerationConfigThinkingConfig `json:"thinkingConfig,omitempty"` + // Optional. If specified, top-k sampling will be used. + TopK *int `json:"topK,omitempty"` + // Optional. If specified, nucleus sampling will be used. + TopP *float64 `json:"topP,omitempty"` +} + +// Config for model selection. +type ModelSelectionConfig struct { + // Optional. Options for feature selection preference. + FeatureSelectionPreference string `json:"featureSelectionPreference,omitempty"` +} + +// Server content modalities. +type Modality string + +const ( + // The modality is unspecified. + ModalityUnspecified Modality = "MODALITY_UNSPECIFIED" + // Indicates the model should return text + ModalityText Modality = "TEXT" + // Indicates the model should return images. + ModalityImage Modality = "IMAGE" + // Indicates the model should return audio. + ModalityAudio Modality = "AUDIO" +) + +// Schema is used to define the format of input/output data. +// Represents a select subset of an [OpenAPI 3.0 schema +// object](https://spec.openapis.org/oas/v3.0.3#schema-object). More fields may +// be added in the future as needed. +// You can find more details and examples at https://spec.openapis.org/oas/v3.0.3.html#schema-object +type Schema struct { + // Optional. The value should be validated against any (one or more) of the subschemas + // in the list. + AnyOf []*Schema `json:"anyOf,omitempty"` + // Optional. Default value of the data. + Default any `json:"default,omitempty"` + // Optional. The description of the data. + Description string `json:"description,omitempty"` + // Optional. Possible values of the element of primitive type with enum format. Examples: + // 1. We can define direction as : {type:STRING, format:enum, enum:["EAST", NORTH", + // "SOUTH", "WEST"]} 2. We can define apartment number as : {type:INTEGER, format:enum, + // enum:["101", "201", "301"]} + Enum []string `json:"enum,omitempty"` + // Optional. Example of the object. Will only populated when the object is the root. + Example any `json:"example,omitempty"` + // Optional. The format of the data. Supported formats: for NUMBER type: "float", "double" + // for INTEGER type: "int32", "int64" for STRING type: "email", "byte", etc + Format string `json:"format,omitempty"` + // Optional. SCHEMA FIELDS FOR TYPE ARRAY Schema of the elements of Type.ARRAY. + Items *Schema `json:"items,omitempty"` + // Optional. Maximum number of the elements for Type.ARRAY. + MaxItems *int64 `json:"maxItems,omitempty,string"` + // Optional. Maximum length of the Type.STRING + MaxLength *int64 `json:"maxLength,omitempty,string"` + // Optional. Maximum number of the properties for Type.OBJECT. + MaxProperties *int64 `json:"maxProperties,omitempty,string"` + // Optional. Maximum value of the Type.INTEGER and Type.NUMBER + Maximum *float64 `json:"maximum,omitempty"` + // Optional. Minimum number of the elements for Type.ARRAY. + MinItems *int64 `json:"minItems,omitempty,string"` + // Optional. SCHEMA FIELDS FOR TYPE STRING Minimum length of the Type.STRING + MinLength *int64 `json:"minLength,omitempty,string"` + // Optional. Minimum number of the properties for Type.OBJECT. + MinProperties *int64 `json:"minProperties,omitempty,string"` + // Optional. Minimum value of the Type.INTEGER and Type.NUMBER. + Minimum *float64 `json:"minimum,omitempty"` + // Optional. Indicates if the value may be null. + Nullable *bool `json:"nullable,omitempty"` + // Optional. Pattern of the Type.STRING to restrict a string to a regular expression. + Pattern string `json:"pattern,omitempty"` + // Optional. SCHEMA FIELDS FOR TYPE OBJECT Properties of Type.OBJECT. + Properties map[string]*Schema `json:"properties,omitempty"` + // Optional. The order of the properties. Not a standard field in open API spec. Only + // used to support the order of the properties. + PropertyOrdering []string `json:"propertyOrdering,omitempty"` + // Optional. Required properties of Type.OBJECT. + Required []string `json:"required,omitempty"` + // Optional. The title of the Schema. + Title string `json:"title,omitempty"` + // Optional. The type of the data. + Type Type `json:"type,omitempty"` +} + +// The type of the data. +type Type string + +const ( + // Not specified, should not be used. + TypeUnspecified Type = "TYPE_UNSPECIFIED" + // OpenAPI string type + TypeString Type = "STRING" + // OpenAPI number type + TypeNumber Type = "NUMBER" + // OpenAPI integer type + TypeInteger Type = "INTEGER" + // OpenAPI boolean type + TypeBoolean Type = "BOOLEAN" + // OpenAPI array type + TypeArray Type = "ARRAY" + // OpenAPI object type + TypeObject Type = "OBJECT" + // NULL type + TypeNULL Type = "NULL" +) + +// The configuration for routing the request to a specific model. +type GenerationConfigRoutingConfig struct { + // Automated routing. + AutoMode *GenerationConfigRoutingConfigAutoRoutingMode `json:"autoMode,omitempty"` + // Manual routing. + ManualMode *GenerationConfigRoutingConfigManualRoutingMode `json:"manualMode,omitempty"` +} + +// Automated routing. +type GenerationConfigRoutingConfigAutoRoutingMode struct { + // The model routing preference. + ModelRoutingPreference string `json:"modelRoutingPreference,omitempty"` +} + +// Manual routing. +type GenerationConfigRoutingConfigManualRoutingMode struct { + // The model name to use. Only the public LLM models are accepted. See [Supported models](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#supported-models). + ModelName string `json:"modelName,omitempty"` +} + +// The configuration for the prebuilt speaker to use. +type PrebuiltVoiceConfig struct { + // Optional. The name of the prebuilt voice to use. + VoiceName string `json:"voiceName,omitempty"` +} + +// The configuration for the voice to use. +type VoiceConfig struct { + // The configuration for the speaker to use. + PrebuiltVoiceConfig *PrebuiltVoiceConfig `json:"prebuiltVoiceConfig,omitempty"` +} + +// The configuration for the speaker to use. +type SpeakerVoiceConfig struct { + // The name of the speaker to use. Should be the same as in the + // prompt. + Speaker string `json:"speaker,omitempty"` + // The configuration for the voice to use. + VoiceConfig *VoiceConfig `json:"voiceConfig,omitempty"` +} + +// The configuration for the multi-speaker setup. +type MultiSpeakerVoiceConfig struct { + // The configuration for the speaker to use. + SpeakerVoiceConfigs []*SpeakerVoiceConfig `json:"speakerVoiceConfigs,omitempty"` +} + +// The speech generation configuration. +type SpeechConfig struct { + // Optional. The configuration for the speaker to use. + VoiceConfig *VoiceConfig `json:"voiceConfig,omitempty"` + // Optional. The configuration for the multi-speaker setup. + // It is mutually exclusive with the voice_config field. + MultiSpeakerVoiceConfig *MultiSpeakerVoiceConfig `json:"multiSpeakerVoiceConfig,omitempty"` + // Optional. Language code (ISO 639. e.g. en-US) for the speech synthesization. + // Only available for Live API. + LanguageCode string `json:"languageCode,omitempty"` +} + +// Config for thinking features. +type GenerationConfigThinkingConfig struct { + // Optional. Indicates whether to include thoughts in the response. If true, thoughts + // are returned only when available. + IncludeThoughts bool `json:"includeThoughts,omitempty"` + // Optional. Indicates the thinking budget in tokens. + ThinkingBudget *int32 `json:"thinkingBudget,omitempty"` +} + +// EmbeddingRequest represents a single embedding request in a batch +type GeminiEmbeddingRequest struct { + Content *Content `json:"content,omitempty"` + TaskType *string `json:"taskType,omitempty"` + Title *string `json:"title,omitempty"` + OutputDimensionality *int `json:"outputDimensionality,omitempty"` + Model string `json:"model,omitempty"` +} + +// Contains the multi-part content of a message. +type Content struct { + // Optional. List of parts that constitute a single message. Each part may have + // a different IANA MIME type. + Parts []*Part `json:"parts,omitempty"` + // Optional. The producer of the content. Must be either 'user' or + // 'model'. Useful to set for multi-turn conversations, otherwise can be + // empty. If role is not specified, SDK will determine the role. + Role string `json:"role,omitempty"` +} + +// A datatype containing media content. +// Exactly one field within a Part should be set, representing the specific type +// of content being conveyed. Using multiple fields within the same `Part` +// instance is considered invalid. +type Part struct { + // Optional. Metadata for a given video. + VideoMetadata *VideoMetadata `json:"videoMetadata,omitempty"` + // Optional. Indicates if the part is thought from the model. + Thought bool `json:"thought,omitempty"` + // Optional. Inlined bytes data. + InlineData *Blob `json:"inlineData,omitempty"` + // Optional. URI based data. + FileData *FileData `json:"fileData,omitempty"` + // Optional. An opaque signature for the thought so it can be reused in subsequent requests. + ThoughtSignature []byte `json:"thoughtSignature,omitempty"` + // Optional. Result of executing the [ExecutableCode]. + CodeExecutionResult *CodeExecutionResult `json:"codeExecutionResult,omitempty"` + // Optional. Code generated by the model that is meant to be executed. + ExecutableCode *ExecutableCode `json:"executableCode,omitempty"` + // Optional. A predicted [FunctionCall] returned from the model that contains a string + // representing the [FunctionDeclaration.Name] with the parameters and their values. + FunctionCall *FunctionCall `json:"functionCall,omitempty"` + // Optional. The result output of a [FunctionCall] that contains a string representing + // the [FunctionDeclaration.Name] and a structured JSON object containing any output + // from the function call. It is used as context to the model. + FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"` + // Optional. Text part (can be code). + Text string `json:"text,omitempty"` +} + +// Content blob. +type Blob struct { + // Optional. Display name of the blob. Used to provide a label or filename to distinguish + // blobs. This field is not currently used in the Gemini GenerateContent calls. + DisplayName string `json:"displayName,omitempty"` + // Required. Raw bytes. + Data []byte `json:"data,omitempty"` + // Required. The IANA standard MIME type of the source data. + MIMEType string `json:"mimeType,omitempty"` +} + +// Describes how the video in the Part should be used by the model. +type VideoMetadata struct { + // Optional. The frame rate of the video sent to the model. If not specified, the + // default value will be 1.0. The FPS range is (0.0, 24.0]. + FPS *float64 `json:"fps,omitempty"` + // Optional. The end offset of the video. + EndOffset time.Duration `json:"endOffset,omitempty"` + // Optional. The start offset of the video. + StartOffset time.Duration `json:"startOffset,omitempty"` +} + +// Result of executing the [ExecutableCode]. Only generated when using the [CodeExecution] +// tool, and always follows a `part` containing the [ExecutableCode]. +type CodeExecutionResult struct { + // Required. Outcome of the code execution. + Outcome Outcome `json:"outcome,omitempty"` + // Optional. Contains stdout when code execution is successful, stderr or other description + // otherwise. + Output string `json:"output,omitempty"` +} + +// Outcome of the code execution. +type Outcome string + +const ( + // Unspecified status. This value should not be used. + OutcomeUnspecified Outcome = "OUTCOME_UNSPECIFIED" + // Code execution completed successfully. + OutcomeOK Outcome = "OUTCOME_OK" + // Code execution finished but with a failure. `stderr` should contain the reason. + OutcomeFailed Outcome = "OUTCOME_FAILED" + // Code execution ran for too long, and was cancelled. There may or may not be a partial + // output present. + OutcomeDeadlineExceeded Outcome = "OUTCOME_DEADLINE_EXCEEDED" +) + +// Code generated by the model that is meant to be executed, and the result returned +// to the model. Generated when using the [CodeExecution] tool, in which the code will +// be automatically executed, and a corresponding [CodeExecutionResult] will also be +// generated. +type ExecutableCode struct { + // Required. The code to be executed. + Code string `json:"code,omitempty"` + // Required. Programming language of the `code`. + Language string `json:"language,omitempty"` +} + +// URI based data. +type FileData struct { + // Optional. Display name of the file data. Used to provide a label or filename to distinguish + // file datas. It is not currently used in the Gemini GenerateContent calls. + DisplayName string `json:"displayName,omitempty"` + // Optional. Required. URI. + FileURI string `json:"fileUri,omitempty"` + // Optional. Required. The IANA standard MIME type of the source data. + MIMEType string `json:"mimeType,omitempty"` +} + +// A function call. +type FunctionCall struct { + // Optional. The unique ID of the function call. If populated, the client to execute + // the + // `function_call` and return the response with the matching `id`. + ID string `json:"id,omitempty"` + // Optional. The function parameters and values in JSON object format. See [FunctionDeclaration.parameters] + // for parameter details. + Args map[string]any `json:"args,omitempty"` + // Required. The name of the function to call. Matches [FunctionDeclaration.Name]. + Name string `json:"name,omitempty"` +} + +// A function response. +type FunctionResponse struct { + // Optional. Signals that function call continues, and more responses will be returned, + // turning the function call into a generator. Is only applicable to NON_BLOCKING function + // calls (see FunctionDeclaration.behavior for details), ignored otherwise. If false, + // the default, future responses will not be considered. Is only applicable to NON_BLOCKING + // function calls, is ignored otherwise. If set to false, future responses will not + // be considered. It is allowed to return empty `response` with `will_continue=False` + // to signal that the function call is finished. + WillContinue *bool `json:"willContinue,omitempty"` + // Optional. Specifies how the response should be scheduled in the conversation. Only + // applicable to NON_BLOCKING function calls, is ignored otherwise. Defaults to WHEN_IDLE. + Scheduling string `json:"scheduling,omitempty"` + // Optional. The ID of the function call this response is for. Populated by the client + // to match the corresponding function call `id`. + ID string `json:"id,omitempty"` + // Required. The name of the function to call. Matches [FunctionDeclaration.name] and + // [FunctionCall.name]. + Name string `json:"name,omitempty"` + // Required. The function response in JSON object format. Use "output" key to specify + // function output and "error" key to specify error details (if any). If "output" and + // "error" keys are not specified, then whole "response" is treated as function output. + Response map[string]any `json:"response,omitempty"` +} + +// ==================== RESPONSE TYPES ==================== +// GeminiEmbeddingResponse represents a Google GenAI embedding response +type GeminiEmbeddingResponse struct { + Embeddings []GeminiEmbedding `json:"embeddings"` + Metadata *EmbedContentMetadata `json:"metadata,omitempty"` +} + +// GeminiEmbedding represents a single embedding in the response +type GeminiEmbedding struct { + Values []float32 `json:"values"` + Statistics *ContentEmbeddingStatistics `json:"statistics,omitempty"` +} + +// EmbedContentMetadata represents request-level metadata for Vertex API +type EmbedContentMetadata struct { + BillableCharacterCount int32 `json:"billableCharacterCount,omitempty"` +} + +// ContentEmbeddingStatistics represents statistics of the input text +type ContentEmbeddingStatistics struct { + TokenCount int32 `json:"tokenCount,omitempty"` +} + +// Candidate for the logprobs token and score. +type LogprobsResultCandidate struct { + // The candidate's log probability. + LogProbability float32 `json:"logProbability,omitempty"` + // The candidate's token string value. + Token string `json:"token,omitempty"` + // The candidate's token ID value. + TokenID int32 `json:"tokenId,omitempty"` +} + +// Candidates with top log probabilities at each decoding step. +type LogprobsResultTopCandidates struct { + // Sorted by log probability in descending order. + Candidates []*LogprobsResultCandidate `json:"candidates,omitempty"` +} + +// Logprobs Result +type LogprobsResult struct { + // Length = total number of decoding steps. The chosen candidates may or may not be + // in top_candidates. + ChosenCandidates []*LogprobsResultCandidate `json:"chosenCandidates,omitempty"` + // Length = total number of decoding steps. + TopCandidates []*LogprobsResultTopCandidates `json:"topCandidates,omitempty"` +} + +// Safety rating corresponding to the generated content. +type SafetyRating struct { + // Output only. Indicates whether the content was filtered out because of this rating. + Blocked bool `json:"blocked,omitempty"` + // Output only. Harm category. + Category string `json:"category,omitempty"` + // Output only. The overwritten threshold for the safety category of Gemini 2.0 image + // out. If minors are detected in the output image, the threshold of each safety category + // will be overwritten if user sets a lower threshold. + OverwrittenThreshold string `json:"overwrittenThreshold,omitempty"` + // Output only. Harm probability levels in the content. + Probability string `json:"probability,omitempty"` + // Output only. Harm probability score. + ProbabilityScore float32 `json:"probabilityScore,omitempty"` + // Output only. Harm severity levels in the content. + Severity string `json:"severity,omitempty"` + // Output only. Harm severity score. + SeverityScore float32 `json:"severityScore,omitempty"` +} + +// Context for a single URL retrieval. +type URLMetadata struct { + // Optional. The URL retrieved by the tool. + RetrievedURL string `json:"retrievedUrl,omitempty"` + // Optional. Status of the URL retrieval. + URLRetrievalStatus string `json:"urlRetrievalStatus,omitempty"` +} + +// Metadata related to URL context retrieval tool. +type URLContextMetadata struct { + // Optional. List of URL context. + URLMetadata []*URLMetadata `json:"urlMetadata,omitempty"` +} + +// A response candidate generated from the model. +type Candidate struct { + // Optional. Contains the multi-part content of the response. + Content *Content `json:"content,omitempty"` + // Optional. Source attribution of the generated content. + CitationMetadata *map[string]any `json:"citationMetadata,omitempty"` + // Optional. Describes the reason the model stopped generating tokens. + FinishMessage string `json:"finishMessage,omitempty"` + // Optional. Number of tokens for this candidate. + // This field is only available in the Gemini API. + TokenCount int32 `json:"tokenCount,omitempty"` + // Optional. The reason why the model stopped generating tokens. + // If empty, the model has not stopped generating the tokens. + FinishReason FinishReason `json:"finishReason,omitempty"` + // Optional. Metadata related to URL context retrieval tool. + URLContextMetadata *URLContextMetadata `json:"urlContextMetadata,omitempty"` + // Output only. Average log probability score of the candidate. + AvgLogprobs float64 `json:"avgLogprobs,omitempty"` + // Output only. Metadata specifies sources used to ground generated content. + GroundingMetadata *map[string]any `json:"groundingMetadata,omitempty"` + // Output only. Index of the candidate. + Index int32 `json:"index,omitempty"` + // Output only. Log-likelihood scores for the response tokens and top tokens + LogprobsResult *LogprobsResult `json:"logprobsResult,omitempty"` + // Output only. List of ratings for the safety of a response candidate. There is at + // most one rating per category. + SafetyRatings []*SafetyRating `json:"safetyRatings,omitempty"` +} + +// Content filter results for a prompt sent in the request. +type GenerateContentResponsePromptFeedback struct { + // Output only. Blocked reason. + BlockReason string `json:"blockReason,omitempty"` + // Output only. A readable block reason message. + BlockReasonMessage string `json:"blockReasonMessage,omitempty"` + // Output only. Safety ratings. + SafetyRatings []*SafetyRating `json:"safetyRatings,omitempty"` +} + +// Represents token counting info for a single modality. +type ModalityTokenCount struct { + // Optional. The modality associated with this token count. + Modality string `json:"modality,omitempty"` + // Number of tokens. + TokenCount int32 `json:"tokenCount,omitempty"` +} + +// Usage metadata about response(s). +type GenerateContentResponseUsageMetadata struct { + // Output only. List of modalities of the cached content in the request input. + CacheTokensDetails []*ModalityTokenCount `json:"cacheTokensDetails,omitempty"` + // Output only. Number of tokens in the cached part in the input (the cached content). + CachedContentTokenCount int32 `json:"cachedContentTokenCount,omitempty"` + // Number of tokens in the response(s). This includes all the generated response candidates. + CandidatesTokenCount int32 `json:"candidatesTokenCount,omitempty"` + // Output only. List of modalities that were returned in the response. + CandidatesTokensDetails []*ModalityTokenCount `json:"candidatesTokensDetails,omitempty"` + // Number of tokens in the prompt. When cached_content is set, this is still the total + // effective prompt size meaning this includes the number of tokens in the cached content. + PromptTokenCount int32 `json:"promptTokenCount,omitempty"` + // Output only. List of modalities that were processed in the request input. + PromptTokensDetails []*ModalityTokenCount `json:"promptTokensDetails,omitempty"` + // Output only. Number of tokens present in thoughts output. + ThoughtsTokenCount int32 `json:"thoughtsTokenCount,omitempty"` + // Output only. Number of tokens present in tool-use prompt(s). + ToolUsePromptTokenCount int32 `json:"toolUsePromptTokenCount,omitempty"` + // Output only. List of modalities that were processed for tool-use request inputs. + ToolUsePromptTokensDetails []*ModalityTokenCount `json:"toolUsePromptTokensDetails,omitempty"` + // Total token count for prompt, response candidates, and tool-use prompts (if present). + TotalTokenCount int32 `json:"totalTokenCount,omitempty"` + // Output only. Traffic type. This shows whether a request consumes Pay-As-You-Go or + // Provisioned Throughput quota. + TrafficType string `json:"trafficType,omitempty"` +} + +// Response message for PredictionService.GenerateContent. +type GenerateContentResponse struct { + // Response variations returned by the model. + Candidates []*Candidate `json:"candidates,omitempty"` + // Timestamp when the request is made to the server. + CreateTime time.Time `json:"createTime,omitempty"` + // Output only. The model version used to generate the response. + ModelVersion string `json:"modelVersion,omitempty"` + // Output only. Content filter results for a prompt sent in the request. Note: Sent + // only in the first stream chunk. Only happens when no candidates were generated due + // to content violations. + PromptFeedback *GenerateContentResponsePromptFeedback `json:"promptFeedback,omitempty"` + // Output only. response_id is used to identify each response. It is the encoding of + // the event_id. + ResponseID string `json:"responseId,omitempty"` + // Usage metadata about the response(s). + UsageMetadata *GenerateContentResponseUsageMetadata `json:"usageMetadata,omitempty"` +} + +func (g *GenerateContentResponse) UnmarshalJSON(data []byte) error { + type Alias GenerateContentResponse + aux := &struct { + CreateTime *time.Time `json:"createTime,omitempty"` + *Alias + }{ + Alias: (*Alias)(g), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if !reflect.ValueOf(aux.CreateTime).IsZero() { + g.CreateTime = time.Time(*aux.CreateTime) + } + + return nil +} + +func (g *GenerateContentResponse) MarshalJSON() ([]byte, error) { + type Alias GenerateContentResponse + aux := &struct { + CreateTime *time.Time `json:"createTime,omitempty"` + *Alias + }{ + Alias: (*Alias)(g), + } + + if !reflect.ValueOf(g.CreateTime).IsZero() { + aux.CreateTime = (*time.Time)(&g.CreateTime) + } + + return json.Marshal(aux) +} + +// GeminiChatRequestError represents a Gemini chat completion error response +type GeminiChatRequestError struct { + Error GeminiChatRequestErrorStruct `json:"error"` // Error details following Google API format +} + +// GeminiChatRequestErrorStruct represents the error structure of a Gemini chat completion error response +type GeminiChatRequestErrorStruct struct { + Code int `json:"code"` // HTTP status code + Message string `json:"message"` // Error message + Status string `json:"status"` // Error status string (e.g., "INVALID_REQUEST") +} + +type GeminiGenerationError struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + Details []struct { + Type string `json:"@type"` + FieldViolations []struct { + Description string `json:"description"` + } `json:"fieldViolations"` + } `json:"details"` + } `json:"error"` +} + +// ==================== MODEL TYPES ==================== + +type GeminiModel struct { + Name string `json:"name"` + BaseModelID string `json:"baseModelId"` + Version string `json:"version"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + InputTokenLimit int `json:"inputTokenLimit"` + OutputTokenLimit int `json:"outputTokenLimit"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` + Thinking bool `json:"thinking"` + Temperature float64 `json:"temperature"` + MaxTemperature float64 `json:"maxTemperature"` + TopP float64 `json:"topP"` + TopK int `json:"topK"` +} + +// GeminiListModelsResponse represents the response from Google Gemini's list models API. +type GeminiListModelsResponse struct { + Models []GeminiModel `json:"models"` + NextPageToken string `json:"nextPageToken"` +} diff --git a/core/providers/gemini/utils.go b/core/providers/gemini/utils.go new file mode 100644 index 000000000..fb36fb749 --- /dev/null +++ b/core/providers/gemini/utils.go @@ -0,0 +1,805 @@ +package gemini + +import ( + "bytes" + "strings" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +// convertGenerationConfigToChatParameters converts Gemini GenerationConfig to ChatParameters +func (r *GeminiGenerationRequest) convertGenerationConfigToChatParameters() *schemas.ChatParameters { + params := &schemas.ChatParameters{ + ExtraParams: make(map[string]interface{}), + } + + config := r.GenerationConfig + + // Map generation config fields to parameters + if config.Temperature != nil { + params.Temperature = config.Temperature + } + if config.TopP != nil { + params.TopP = config.TopP + } + if config.TopK != nil { + params.ExtraParams["top_k"] = *config.TopK + } + if config.MaxOutputTokens > 0 { + params.MaxCompletionTokens = schemas.Ptr(int(config.MaxOutputTokens)) + } + if config.CandidateCount > 0 { + params.ExtraParams["candidate_count"] = config.CandidateCount + } + if len(config.StopSequences) > 0 { + params.Stop = config.StopSequences + } + if config.PresencePenalty != nil { + params.PresencePenalty = config.PresencePenalty + } + if config.FrequencyPenalty != nil { + params.FrequencyPenalty = config.FrequencyPenalty + } + if config.Seed != nil { + params.Seed = schemas.Ptr(int(*config.Seed)) + } + if config.ResponseMIMEType != "" { + params.ExtraParams["response_mime_type"] = config.ResponseMIMEType + + // Convert Gemini's response format to OpenAI's response_format for compatibility + switch config.ResponseMIMEType { + case "application/json": + params.ResponseFormat = buildOpenAIResponseFormat(config.ResponseSchema, config.ResponseJsonSchema) + case "text/plain": + // Gemini text/plain β†’ OpenAI text format + var responseFormat interface{} = map[string]interface{}{ + "type": "text", + } + params.ResponseFormat = &responseFormat + } + } + if config.ResponseSchema != nil { + params.ExtraParams["response_schema"] = config.ResponseSchema + } + if config.ResponseJsonSchema != nil { + params.ExtraParams["response_json_schema"] = config.ResponseJsonSchema + } + if config.ResponseLogprobs { + params.ExtraParams["response_logprobs"] = config.ResponseLogprobs + } + if config.Logprobs != nil { + params.ExtraParams["logprobs"] = *config.Logprobs + } + + return params +} + +// convertSchemaToFunctionParameters converts genai.Schema to schemas.FunctionParameters +func (r *GeminiGenerationRequest) convertSchemaToFunctionParameters(schema *Schema) schemas.ToolFunctionParameters { + params := schemas.ToolFunctionParameters{ + Type: string(schema.Type), + } + + if schema.Description != "" { + params.Description = &schema.Description + } + + if len(schema.Required) > 0 { + params.Required = schema.Required + } + + if len(schema.Properties) > 0 { + params.Properties = schemas.Ptr(convertSchemaToMap(schema)) + } + + if len(schema.Enum) > 0 { + params.Enum = schema.Enum + } + + return params +} + +func convertSchemaToMap(schema *Schema) map[string]interface{} { + // Convert map[string]*Schema to map[string]interface{} using JSON marshaling + data, err := sonic.Marshal(schema.Properties) + if err != nil { + return make(map[string]interface{}) + } + + var properties map[string]interface{} + if err := sonic.Unmarshal(data, &properties); err != nil { + return make(map[string]interface{}) + } + + return properties +} + +// isImageMimeType checks if a MIME type represents an image format +func isImageMimeType(mimeType string) bool { + if mimeType == "" { + return false + } + + // Convert to lowercase for case-insensitive comparison + mimeType = strings.ToLower(mimeType) + + // Remove any parameters (e.g., "image/jpeg; charset=utf-8" -> "image/jpeg") + if idx := strings.Index(mimeType, ";"); idx != -1 { + mimeType = strings.TrimSpace(mimeType[:idx]) + } + + // If it starts with "image/", it's an image + if strings.HasPrefix(mimeType, "image/") { + return true + } + + // Check for common image formats that might not have the "image/" prefix + commonImageTypes := []string{ + "jpeg", + "jpg", + "png", + "gif", + "webp", + "bmp", + "svg", + "tiff", + "ico", + "avif", + } + + // Check if the mimeType contains any of the common image type strings + for _, imageType := range commonImageTypes { + if strings.Contains(mimeType, imageType) { + return true + } + } + + return false +} + +// ensureExtraParams ensures that bifrostReq.Params and bifrostReq.Params.ExtraParams are initialized +func ensureExtraParams(bifrostReq *schemas.BifrostChatRequest) { + if bifrostReq.Params == nil { + bifrostReq.Params = &schemas.ChatParameters{ + ExtraParams: make(map[string]interface{}), + } + } + if bifrostReq.Params.ExtraParams == nil { + bifrostReq.Params.ExtraParams = make(map[string]interface{}) + } +} + +// extractUsageMetadata extracts usage metadata from the Gemini response +func (r *GenerateContentResponse) extractUsageMetadata() (int, int, int, int, int) { + var inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens int + if r.UsageMetadata != nil { + inputTokens = int(r.UsageMetadata.PromptTokenCount) + outputTokens = int(r.UsageMetadata.CandidatesTokenCount) + totalTokens = int(r.UsageMetadata.TotalTokenCount) + cachedTokens = int(r.UsageMetadata.CachedContentTokenCount) + reasoningTokens = int(r.UsageMetadata.ThoughtsTokenCount) + } + return inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens +} + +// convertParamsToGenerationConfig converts Bifrost parameters to Gemini GenerationConfig +func convertParamsToGenerationConfig(params *schemas.ChatParameters, responseModalities []string) GenerationConfig { + config := GenerationConfig{} + + // Add response modalities if specified + if len(responseModalities) > 0 { + var modalities []Modality + for _, mod := range responseModalities { + modalities = append(modalities, Modality(mod)) + } + config.ResponseModalities = modalities + } + + // Map standard parameters + if params.Stop != nil { + config.StopSequences = params.Stop + } + if params.MaxCompletionTokens != nil { + config.MaxOutputTokens = int32(*params.MaxCompletionTokens) + } + if params.Temperature != nil { + temp := float64(*params.Temperature) + config.Temperature = &temp + } + if params.TopP != nil { + topP := float64(*params.TopP) + config.TopP = &topP + } + if params.PresencePenalty != nil { + penalty := float64(*params.PresencePenalty) + config.PresencePenalty = &penalty + } + if params.FrequencyPenalty != nil { + penalty := float64(*params.FrequencyPenalty) + config.FrequencyPenalty = &penalty + } + + // Handle response_format to response_schema conversion + if params.ResponseFormat != nil { + formatMap, ok := (*params.ResponseFormat).(map[string]interface{}) + if ok { + formatType, typeOk := formatMap["type"].(string) + if typeOk { + switch formatType { + case "json_schema": + // OpenAI Structured Outputs: {"type": "json_schema", "json_schema": {...}} + if schema := extractSchemaFromResponseFormat(params.ResponseFormat); schema != nil { + config.ResponseMIMEType = "application/json" + config.ResponseSchema = schema + } + case "json_object": + // Maps to Gemini's responseMimeType without schema + config.ResponseMIMEType = "application/json" + } + } + } + } + + if params.ExtraParams != nil { + if topK, ok := params.ExtraParams["top_k"]; ok { + if val, success := schemas.SafeExtractInt(topK); success { + config.TopK = schemas.Ptr(val) + } + } + if responseMimeType, ok := schemas.SafeExtractString(params.ExtraParams["response_mime_type"]); ok { + config.ResponseMIMEType = responseMimeType + } + // Override with explicit response_schema if provided in ExtraParams + if responseSchema, ok := params.ExtraParams["response_schema"]; ok { + if schemaBytes, err := sonic.Marshal(responseSchema); err == nil { + schema := &Schema{} + if err := sonic.Unmarshal(schemaBytes, schema); err == nil { + config.ResponseSchema = schema + } + } + } + if responseJsonSchema, ok := params.ExtraParams["response_json_schema"]; ok { + config.ResponseJsonSchema = responseJsonSchema + } + } + + return config +} + +// convertBifrostToolsToGemini converts Bifrost tools to Gemini format +func convertBifrostToolsToGemini(bifrostTools []schemas.ChatTool) []Tool { + var geminiTools []Tool + + for _, tool := range bifrostTools { + if tool.Type == "" { + continue + } + if tool.Type == "function" && tool.Function != nil { + fd := &FunctionDeclaration{ + Name: tool.Function.Name, + } + if tool.Function.Parameters != nil { + fd.Parameters = convertFunctionParametersToSchema(*tool.Function.Parameters) + } + if tool.Function.Description != nil { + fd.Description = *tool.Function.Description + } + geminiTool := Tool{ + FunctionDeclarations: []*FunctionDeclaration{fd}, + } + geminiTools = append(geminiTools, geminiTool) + } + } + + return geminiTools +} + +// convertFunctionParametersToSchema converts Bifrost function parameters to Gemini Schema +func convertFunctionParametersToSchema(params schemas.ToolFunctionParameters) *Schema { + schema := &Schema{ + Type: Type(params.Type), + } + + if params.Description != nil { + schema.Description = *params.Description + } + + if len(params.Required) > 0 { + schema.Required = params.Required + } + + if params.Properties != nil && len(*params.Properties) > 0 { + schema.Properties = make(map[string]*Schema) + // Note: This is a simplified conversion. In practice, you'd need to + // recursively convert nested schemas + for k, v := range *params.Properties { + // Convert interface{} to Schema - this would need more sophisticated logic + if propMap, ok := v.(map[string]interface{}); ok { + propSchema := &Schema{} + if propType, ok := propMap["type"].(string); ok { + propSchema.Type = Type(propType) + } + if propDesc, ok := propMap["description"].(string); ok { + propSchema.Description = propDesc + } + schema.Properties[k] = propSchema + } + } + } + + return schema +} + +// convertToolChoiceToToolConfig converts Bifrost tool choice to Gemini tool config +func convertToolChoiceToToolConfig(toolChoice *schemas.ChatToolChoice) ToolConfig { + config := ToolConfig{} + functionCallingConfig := FunctionCallingConfig{} + + if toolChoice.ChatToolChoiceStr != nil { + // Map string values to Gemini's enum values + switch *toolChoice.ChatToolChoiceStr { + case "none": + functionCallingConfig.Mode = FunctionCallingConfigModeNone + case "auto": + functionCallingConfig.Mode = FunctionCallingConfigModeAuto + case "any", "required": + functionCallingConfig.Mode = FunctionCallingConfigModeAny + default: + functionCallingConfig.Mode = FunctionCallingConfigModeAuto + } + } else if toolChoice.ChatToolChoiceStruct != nil { + switch toolChoice.ChatToolChoiceStruct.Type { + case schemas.ChatToolChoiceTypeNone: + functionCallingConfig.Mode = FunctionCallingConfigModeNone + case schemas.ChatToolChoiceTypeFunction: + functionCallingConfig.Mode = FunctionCallingConfigModeAny + case schemas.ChatToolChoiceTypeRequired: + functionCallingConfig.Mode = FunctionCallingConfigModeAny + default: + functionCallingConfig.Mode = FunctionCallingConfigModeAuto + } + + // Handle specific function selection + if toolChoice.ChatToolChoiceStruct.Function.Name != "" { + functionCallingConfig.AllowedFunctionNames = []string{toolChoice.ChatToolChoiceStruct.Function.Name} + } + } + + config.FunctionCallingConfig = &functionCallingConfig + return config +} + +// addSpeechConfigToGenerationConfig adds speech configuration to the generation config +func addSpeechConfigToGenerationConfig(config *GenerationConfig, voiceConfig *schemas.SpeechVoiceInput) { + speechConfig := SpeechConfig{} + + // Handle single voice configuration + if voiceConfig != nil && voiceConfig.Voice != nil { + speechConfig.VoiceConfig = &VoiceConfig{ + PrebuiltVoiceConfig: &PrebuiltVoiceConfig{ + VoiceName: *voiceConfig.Voice, + }, + } + } + + // Handle multi-speaker voice configuration + if voiceConfig != nil && len(voiceConfig.MultiVoiceConfig) > 0 { + var speakerVoiceConfigs []*SpeakerVoiceConfig + for _, vc := range voiceConfig.MultiVoiceConfig { + speakerVoiceConfigs = append(speakerVoiceConfigs, &SpeakerVoiceConfig{ + Speaker: vc.Speaker, + VoiceConfig: &VoiceConfig{ + PrebuiltVoiceConfig: &PrebuiltVoiceConfig{ + VoiceName: vc.Voice, + }, + }, + }) + } + + speechConfig.MultiSpeakerVoiceConfig = &MultiSpeakerVoiceConfig{ + SpeakerVoiceConfigs: speakerVoiceConfigs, + } + } + + config.SpeechConfig = &speechConfig +} + +// convertBifrostMessagesToGemini converts Bifrost messages to Gemini format +func convertBifrostMessagesToGemini(messages []schemas.ChatMessage) []Content { + var contents []Content + + for _, message := range messages { + var parts []*Part + + // Handle content + if message.Content.ContentStr != nil && *message.Content.ContentStr != "" { + parts = append(parts, &Part{ + Text: *message.Content.ContentStr, + }) + } else if message.Content.ContentBlocks != nil { + for _, block := range message.Content.ContentBlocks { + if block.Text != nil { + parts = append(parts, &Part{ + Text: *block.Text, + }) + } + // Handle other content block types as needed + } + } + + // Handle tool calls for assistant messages + if message.ChatAssistantMessage != nil && message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range message.ChatAssistantMessage.ToolCalls { + // Convert tool call to function call part + if toolCall.Function.Name != nil { + // Create function call part - simplified implementation + argsMap := make(map[string]any) + if toolCall.Function.Arguments != "" { + sonic.Unmarshal([]byte(toolCall.Function.Arguments), &argsMap) + } + // Handle ID: use it if available, otherwise fallback to function name + callID := *toolCall.Function.Name + if toolCall.ID != nil && strings.TrimSpace(*toolCall.ID) != "" { + callID = *toolCall.ID + } + parts = append(parts, &Part{ + FunctionCall: &FunctionCall{ + ID: callID, + Name: *toolCall.Function.Name, + Args: argsMap, + }, + }) + } + } + } + + // Handle tool response messages + if message.Role == schemas.ChatMessageRoleTool && message.ChatToolMessage != nil { + // Parse the response content + var responseData map[string]any + var contentStr string + + // Extract content string from ContentStr or ContentBlocks + if message.Content.ContentStr != nil && *message.Content.ContentStr != "" { + contentStr = *message.Content.ContentStr + } else if message.Content.ContentBlocks != nil { + // Fallback: try to extract text from content blocks + var textParts []string + for _, block := range message.Content.ContentBlocks { + if block.Text != nil && *block.Text != "" { + textParts = append(textParts, *block.Text) + } + } + if len(textParts) > 0 { + contentStr = strings.Join(textParts, "\n") + } + } + + // Try to unmarshal as JSON + if contentStr != "" { + err := sonic.Unmarshal([]byte(contentStr), &responseData) + if err != nil { + // If unmarshaling fails, wrap the original string to preserve it + responseData = map[string]any{ + "content": contentStr, + } + } + } else { + // If no content at all, use empty map to avoid nil + responseData = map[string]any{} + } + + // Use ToolCallID if available, ensuring it's not nil + callID := "" + if message.ChatToolMessage.ToolCallID != nil { + callID = *message.ChatToolMessage.ToolCallID + } + + parts = append(parts, &Part{ + FunctionResponse: &FunctionResponse{ + ID: callID, + Name: callID, // Gemini uses name for correlation + Response: responseData, + }, + }) + } + + if len(parts) > 0 { + content := Content{ + Parts: parts, + Role: string(message.Role), + } + contents = append(contents, content) + } + } + + return contents +} + +var ( + riff = []byte("RIFF") + wave = []byte("WAVE") + id3 = []byte("ID3") + form = []byte("FORM") + aiff = []byte("AIFF") + aifc = []byte("AIFC") + flac = []byte("fLaC") + oggs = []byte("OggS") + adif = []byte("ADIF") +) + +// detectAudioMimeType attempts to detect the MIME type from audio file headers +// Gemini supports: WAV, MP3, AIFF, AAC, OGG Vorbis, FLAC +func detectAudioMimeType(audioData []byte) string { + if len(audioData) < 4 { + return "audio/mp3" + } + // WAV (RIFF/WAVE) + if len(audioData) >= 12 && + bytes.Equal(audioData[:4], riff) && + bytes.Equal(audioData[8:12], wave) { + return "audio/wav" + } + // MP3: ID3v2 tag (keep this check for MP3) + if len(audioData) >= 3 && bytes.Equal(audioData[:3], id3) { + return "audio/mp3" + } + // AAC: ADIF or ADTS (0xFFF sync) - check before MP3 frame sync to avoid misclassification + if bytes.HasPrefix(audioData, adif) { + return "audio/aac" + } + if len(audioData) >= 2 && audioData[0] == 0xFF && (audioData[1]&0xF6) == 0xF0 { + return "audio/aac" + } + // AIFF / AIFC (map both to audio/aiff) + if len(audioData) >= 12 && bytes.Equal(audioData[:4], form) && + (bytes.Equal(audioData[8:12], aiff) || bytes.Equal(audioData[8:12], aifc)) { + return "audio/aiff" + } + // FLAC + if bytes.HasPrefix(audioData, flac) { + return "audio/flac" + } + // OGG container + if bytes.HasPrefix(audioData, oggs) { + return "audio/ogg" + } + // MP3: MPEG frame sync (cover common variants) - check after AAC to avoid misclassification + if len(audioData) >= 2 && audioData[0] == 0xFF && + (audioData[1] == 0xFB || audioData[1] == 0xF3 || audioData[1] == 0xF2 || audioData[1] == 0xFA) { + return "audio/mp3" + } + // Fallback within supported set + return "audio/mp3" +} + +// convertGeminiSchemaToJSONSchema converts Gemini Schema to JSON Schema format +// This converts uppercase type values (STRING, NUMBER, etc.) to lowercase (string, number, etc.) +// and converts the struct to a map[string]interface{} format +func convertGeminiSchemaToJSONSchema(geminiSchema *Schema) map[string]interface{} { + if geminiSchema == nil { + return nil + } + + // First, marshal the schema to JSON and unmarshal to map to get all fields + schemaBytes, err := sonic.Marshal(geminiSchema) + if err != nil { + return nil + } + + var schemaMap map[string]interface{} + if err := sonic.Unmarshal(schemaBytes, &schemaMap); err != nil { + return nil + } + + // Convert type from uppercase to lowercase + if typeVal, ok := schemaMap["type"].(string); ok { + schemaMap["type"] = convertGeminiTypeToJSONSchemaType(typeVal) + } + + // Recursively convert nested properties + if properties, ok := schemaMap["properties"].(map[string]interface{}); ok { + convertedProps := make(map[string]interface{}) + for key, prop := range properties { + if propMap, ok := prop.(map[string]interface{}); ok { + // Check if this is a Schema struct that was marshaled + if propType, hasType := propMap["type"].(string); hasType { + // Convert the type + propMap["type"] = convertGeminiTypeToJSONSchemaType(propType) + // Recursively convert nested properties and items + convertedProps[key] = convertNestedSchema(propMap) + } else { + convertedProps[key] = propMap + } + } else { + convertedProps[key] = prop + } + } + schemaMap["properties"] = convertedProps + } + + // Recursively convert items + if items, ok := schemaMap["items"]; ok { + if itemsMap, ok := items.(map[string]interface{}); ok { + schemaMap["items"] = convertNestedSchema(itemsMap) + } + } + + // Recursively convert anyOf + if anyOf, ok := schemaMap["anyOf"].([]interface{}); ok { + convertedAnyOf := make([]interface{}, 0, len(anyOf)) + for _, item := range anyOf { + if itemMap, ok := item.(map[string]interface{}); ok { + convertedAnyOf = append(convertedAnyOf, convertNestedSchema(itemMap)) + } else { + convertedAnyOf = append(convertedAnyOf, item) + } + } + schemaMap["anyOf"] = convertedAnyOf + } + + return schemaMap +} + +// convertNestedSchema recursively converts nested schema structures +func convertNestedSchema(schemaMap map[string]interface{}) map[string]interface{} { + // Convert type if present + if typeVal, ok := schemaMap["type"].(string); ok { + schemaMap["type"] = convertGeminiTypeToJSONSchemaType(typeVal) + } + + // Recursively convert properties + if properties, ok := schemaMap["properties"].(map[string]interface{}); ok { + convertedProps := make(map[string]interface{}) + for key, prop := range properties { + if propMap, ok := prop.(map[string]interface{}); ok { + convertedProps[key] = convertNestedSchema(propMap) + } else { + convertedProps[key] = prop + } + } + schemaMap["properties"] = convertedProps + } + + // Recursively convert items + if items, ok := schemaMap["items"]; ok { + if itemsMap, ok := items.(map[string]interface{}); ok { + schemaMap["items"] = convertNestedSchema(itemsMap) + } + } + + // Recursively convert anyOf + if anyOf, ok := schemaMap["anyOf"].([]interface{}); ok { + convertedAnyOf := make([]interface{}, 0, len(anyOf)) + for _, item := range anyOf { + if itemMap, ok := item.(map[string]interface{}); ok { + convertedAnyOf = append(convertedAnyOf, convertNestedSchema(itemMap)) + } else { + convertedAnyOf = append(convertedAnyOf, item) + } + } + schemaMap["anyOf"] = convertedAnyOf + } + + return schemaMap +} + +// convertGeminiTypeToJSONSchemaType converts Gemini's uppercase type values to JSON Schema lowercase +func convertGeminiTypeToJSONSchemaType(geminiType string) string { + switch geminiType { + case "STRING": + return "string" + case "NUMBER": + return "number" + case "INTEGER": + return "integer" + case "BOOLEAN": + return "boolean" + case "ARRAY": + return "array" + case "OBJECT": + return "object" + case "NULL": + return "null" + case "TYPE_UNSPECIFIED": + return "" // Empty string for unspecified + default: + // If already lowercase or unknown, return as-is + return geminiType + } +} + +// buildOpenAIResponseFormat builds OpenAI response_format for JSON types +func buildOpenAIResponseFormat(responseSchema *Schema, responseJsonSchema interface{}) *interface{} { + var schema interface{} + name := "response_schema" + + // Prefer responseSchema over responseJsonSchema + if responseSchema != nil { + // Convert Gemini Schema to JSON Schema format + schema = convertGeminiSchemaToJSONSchema(responseSchema) + if responseSchema.Title != "" { + name = responseSchema.Title + } + } else if responseJsonSchema != nil { + if schemaMap, ok := responseJsonSchema.(map[string]interface{}); ok { + // Create a deep copy to avoid modifying the original + schemaBytes, err := sonic.Marshal(schemaMap) + if err == nil { + var copiedMap map[string]interface{} + if err := sonic.Unmarshal(schemaBytes, &copiedMap); err == nil { + // Recursively convert the schema to ensure all types are lowercase + schema = convertNestedSchema(copiedMap) + if title, ok := copiedMap["title"].(string); ok && title != "" { + name = title + } + } else { + schema = responseJsonSchema + } + } else { + schema = responseJsonSchema + } + } else { + schema = responseJsonSchema + } + } else { + // No schema provided - use older json_object mode + var format interface{} = map[string]interface{}{ + "type": "json_object", + } + return &format + } + + // Has schema - use json_schema mode (Structured Outputs) + var format interface{} = map[string]interface{}{ + "type": "json_schema", + "json_schema": map[string]interface{}{ + "name": name, + "strict": false, + "schema": schema, + }, + } + return &format +} + +// extractSchemaFromResponseFormat extracts Gemini Schema from OpenAI's response_format structure +func extractSchemaFromResponseFormat(responseFormat *interface{}) *Schema { + formatMap, ok := (*responseFormat).(map[string]interface{}) + if !ok { + return nil + } + + formatType, ok := formatMap["type"].(string) + if !ok || formatType != "json_schema" { + return nil + } + + jsonSchemaObj, ok := formatMap["json_schema"].(map[string]interface{}) + if !ok { + return nil + } + + schemaObj, ok := jsonSchemaObj["schema"] + if !ok { + return nil + } + + schemaMap, ok := schemaObj.(map[string]interface{}) + if !ok { + return nil + } + + // Convert map to Gemini Schema type via JSON marshaling + schemaBytes, err := sonic.Marshal(schemaMap) + if err != nil { + return nil + } + + schema := &Schema{} + if err := sonic.Unmarshal(schemaBytes, schema); err != nil { + return nil + } + + return schema +} diff --git a/core/providers/groq.go b/core/providers/groq.go new file mode 100644 index 000000000..e89c2b40b --- /dev/null +++ b/core/providers/groq.go @@ -0,0 +1,246 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the Groq provider implementation. +package providers + +import ( + "context" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// GroqProvider implements the Provider interface for Groq's API. +type GroqProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewGroqProvider creates a new Groq provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewGroqProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*GroqProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // // Pre-warm response pools + // for range config.ConcurrencyAndBufferSize.Concurrency { + // groqResponsePool.Put(&schemas.BifrostResponse{}) + // } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.groq.com/openai" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &GroqProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Groq. +func (provider *GroqProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Groq +} + +// ListModels performs a list models request to Groq's API. +func (provider *GroqProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return openai.HandleOpenAIListModelsRequest( + ctx, + provider.client, + request, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/models"), + keys, + provider.networkConfig.ExtraHeaders, + schemas.Groq, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletion is not supported by the Groq provider. +func (provider *GroqProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + // Checking if litellm fallback is set + if _, ok := ctx.Value(schemas.BifrostContextKey("x-litellm-fallback")).(string); !ok { + return nil, providerUtils.NewUnsupportedOperationError("text completion", "groq") + } + // Here we will call the chat.completions endpoint and mock it as a text-completion response + chatRequest := request.ToBifrostChatRequest() + if chatRequest == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "invalid text completion request: missing or empty prompt", + }, + } + } + chatResponse, err := provider.ChatCompletion(ctx, key, chatRequest) + if err != nil { + return nil, err + } + response := chatResponse.ToTextCompletionResponse() + response.ExtraFields.RequestType = schemas.TextCompletionRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + return response, nil +} + +// TextCompletionStream performs a streaming text completion request to Groq's API. +// It formats the request, sends it to Groq, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *GroqProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Checking if litellm fallback is set + if _, ok := ctx.Value(schemas.BifrostContextKey("x-litellm-fallback")).(string); !ok { + return nil, providerUtils.NewUnsupportedOperationError("text completion", "groq") + } + // Here we will call the chat.completions endpoint and mock it as a text-completion stream response + chatRequest := request.ToBifrostChatRequest() + if chatRequest == nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "invalid text completion request: missing or empty prompt", + }, + } + } + response, err := provider.ChatCompletionStream(ctx, postHookRunner, key, chatRequest) + if err != nil { + return nil, err + } + // Creating a converter from chat completion stream to text completion stream + responseChan := make(chan *schemas.BifrostStream, 1) + go func() { + defer close(responseChan) + for response := range response { + if response.BifrostError != nil { + responseChan <- response + continue + } + if response.BifrostChatResponse != nil { + textCompletionResponse := response.BifrostChatResponse.ToTextCompletionResponse() + if textCompletionResponse != nil { + textCompletionResponse.ExtraFields.RequestType = schemas.TextCompletionRequest + textCompletionResponse.ExtraFields.Provider = provider.GetProviderKey() + textCompletionResponse.ExtraFields.ModelRequested = request.Model + + responseChan <- &schemas.BifrostStream{ + BifrostTextCompletionResponse: textCompletionResponse, + } + } + } + } + }() + return responseChan, nil +} + +// ChatCompletion performs a chat completion request to the Groq API. +func (provider *GroqProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the Groq API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Groq's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *GroqProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/chat/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Groq, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the Groq API. +func (provider *GroqProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Groq API. +func (provider *GroqProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding is not supported by the Groq provider. +func (provider *GroqProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.EmbeddingRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the Groq provider. +func (provider *GroqProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Groq provider. +func (provider *GroqProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Groq provider. +func (provider *GroqProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Groq provider. +func (provider *GroqProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/mistral/mistral.go b/core/providers/mistral/mistral.go new file mode 100644 index 000000000..edd7919b9 --- /dev/null +++ b/core/providers/mistral/mistral.go @@ -0,0 +1,248 @@ +// Package mistral implements the Mistral provider. +package mistral + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// MistralProvider implements the Provider interface for Mistral's API. +type MistralProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewMistralProvider creates a new Mistral provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewMistralProvider(config *schemas.ProviderConfig, logger schemas.Logger) *MistralProvider { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Pre-warm response pools + // for range config.ConcurrencyAndBufferSize.Concurrency { + // mistralResponsePool.Put(&schemas.BifrostResponse{}) + // } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.mistral.ai" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &MistralProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + } +} + +// GetProviderKey returns the provider identifier for Mistral. +func (provider *MistralProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Mistral +} + +// listModelsByKey performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +func (provider *MistralProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/v1/models")) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + bifrostErr := openai.ParseOpenAIError(resp, schemas.ListModelsRequest, providerName, "") + return nil, bifrostErr + } + + // Copy response body before releasing + responseBody := append([]byte(nil), resp.Body()...) + + // Parse Mistral's response + var mistralResponse MistralListModelsResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, &mistralResponse, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create final response + response := mistralResponse.ToBifrostListModelsResponse() + + response.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// ListModels performs a list models request to Mistral's API. +// Requests are made concurrently for improved performance. +func (provider *MistralProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion is not supported by the Mistral provider. +func (provider *MistralProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream performs a streaming text completion request to Mistral's API. +// It formats the request, sends it to Mistral, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *MistralProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to the Mistral API. +func (provider *MistralProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the Mistral API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Mistral's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *MistralProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/chat/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Mistral, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the Mistral API. +func (provider *MistralProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Mistral API. +func (provider *MistralProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding generates embeddings for the given input text(s) using the Mistral API. +// Supports Mistral's embedding models and returns a BifrostResponse containing the embedding(s). +func (provider *MistralProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + // Use the shared embedding request handler + return openai.HandleOpenAIEmbeddingRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/embeddings"), + request, + key, + provider.networkConfig.ExtraHeaders, + schemas.Mistral, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// Speech is not supported by the Mistral provider. +func (provider *MistralProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Mistral provider. +func (provider *MistralProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Mistral provider. +func (provider *MistralProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Mistral provider. +func (provider *MistralProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/mistral/models.go b/core/providers/mistral/models.go new file mode 100644 index 000000000..181b9f2b7 --- /dev/null +++ b/core/providers/mistral/models.go @@ -0,0 +1,27 @@ +package mistral + +import "github.com/maximhq/bifrost/core/schemas" + +func (response *MistralListModelsResponse) ToBifrostListModelsResponse() *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Data)), + } + + for _, model := range response.Data { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(schemas.Mistral) + "/" + model.ID, + Name: schemas.Ptr(model.Name), + Description: schemas.Ptr(model.Description), + Created: schemas.Ptr(model.Created), + ContextLength: schemas.Ptr(int(model.MaxContextLength)), + OwnedBy: schemas.Ptr(model.OwnedBy), + }) + + } + + return bifrostResponse +} diff --git a/core/providers/mistral/types.go b/core/providers/mistral/types.go new file mode 100644 index 000000000..3a8431ab0 --- /dev/null +++ b/core/providers/mistral/types.go @@ -0,0 +1,34 @@ +package mistral + +// MistralModel represents a single model in the Mistral Models API response +type MistralModel struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Capabilities Capabilities `json:"capabilities"` + Name string `json:"name"` + Description string `json:"description"` + MaxContextLength int `json:"max_context_length"` + Aliases []string `json:"aliases"` + Deprecation *string `json:"deprecation,omitempty"` + DeprecationReplacementModel *string `json:"deprecation_replacement_model,omitempty"` + DefaultModelTemperature float64 `json:"default_model_temperature"` + Type string `json:"type"` +} + +// Capabilities describes the model's supported features +type Capabilities struct { + CompletionChat bool `json:"completion_chat"` + CompletionFim bool `json:"completion_fim"` + FunctionCalling bool `json:"function_calling"` + FineTuning bool `json:"fine_tuning"` + Vision bool `json:"vision"` + Classification bool `json:"classification"` +} + +// MistralListModelsResponse is the root response object from the Mistral Models API +type MistralListModelsResponse struct { + Object string `json:"object"` + Data []MistralModel `json:"data"` +} diff --git a/core/providers/ollama.go b/core/providers/ollama.go new file mode 100644 index 000000000..701fd2e10 --- /dev/null +++ b/core/providers/ollama.go @@ -0,0 +1,217 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the Ollama provider implementation. +package providers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// OllamaProvider implements the Provider interface for Ollama's API. +type OllamaProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewOllamaProvider creates a new Ollama provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewOllamaProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*OllamaProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // // Pre-warm response pools + // for range config.ConcurrencyAndBufferSize.Concurrency { + // ollamaResponsePool.Put(&schemas.BifrostResponse{}) + // } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + // BaseURL is required for Ollama + if config.NetworkConfig.BaseURL == "" { + return nil, fmt.Errorf("base_url is required for ollama provider") + } + + return &OllamaProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Ollama. +func (provider *OllamaProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Ollama +} + +// ListModels performs a list models request to Ollama's API. +func (provider *OllamaProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if provider.networkConfig.BaseURL == "" { + return nil, providerUtils.NewConfigurationError("base_url is not set", provider.GetProviderKey()) + } + return openai.HandleOpenAIListModelsRequest( + ctx, + provider.client, + request, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/models"), + keys, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletion performs a text completion request to the Ollama API. +func (provider *OllamaProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return openai.HandleOpenAITextCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletionStream performs a streaming text completion request to Ollama's API. +// It formats the request, sends it to Ollama, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *OllamaProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return openai.HandleOpenAITextCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/completions", + request, + nil, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// ChatCompletion performs a chat completion request to the Ollama API. +func (provider *OllamaProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the Ollama API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Ollama's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *OllamaProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/chat/completions", + request, + nil, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Ollama, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the Ollama API. +func (provider *OllamaProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Ollama API. +func (provider *OllamaProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding performs an embedding request to the Ollama API. +func (provider *OllamaProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return openai.HandleOpenAIEmbeddingRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/embeddings"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// Speech is not supported by the Ollama provider. +func (provider *OllamaProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Ollama provider. +func (provider *OllamaProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Ollama provider. +func (provider *OllamaProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Ollama provider. +func (provider *OllamaProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/openai.go b/core/providers/openai.go deleted file mode 100644 index ff96a69b7..000000000 --- a/core/providers/openai.go +++ /dev/null @@ -1,242 +0,0 @@ -// Package providers implements various LLM providers and their utility functions. -// This file contains the OpenAI provider implementation. -package providers - -import ( - "sync" - "time" - - "github.com/goccy/go-json" - - schemas "github.com/maximhq/bifrost/core/schemas" - "github.com/valyala/fasthttp" -) - -// OpenAIResponse represents the response structure from the OpenAI API. -// It includes completion choices, model information, and usage statistics. -type OpenAIResponse struct { - ID string `json:"id"` // Unique identifier for the completion - Object string `json:"object"` // Type of completion (text.completion or chat.completion) - Choices []schemas.BifrostResponseChoice `json:"choices"` // Array of completion choices - Model string `json:"model"` // Model used for the completion - Created int `json:"created"` // Unix timestamp of completion creation - ServiceTier *string `json:"service_tier"` // Service tier used for the request - SystemFingerprint *string `json:"system_fingerprint"` // System fingerprint for the request - Usage schemas.LLMUsage `json:"usage"` // Token usage statistics -} - -// OpenAIError represents the error response structure from the OpenAI API. -// It includes detailed error information and event tracking. -type OpenAIError struct { - EventID string `json:"event_id"` // Unique identifier for the error event - Type string `json:"type"` // Type of error - Error struct { - Type string `json:"type"` // Error type - Code string `json:"code"` // Error code - Message string `json:"message"` // Error message - Param interface{} `json:"param"` // Parameter that caused the error - EventID string `json:"event_id"` // Event ID for tracking - } `json:"error"` -} - -// openAIResponsePool provides a pool for OpenAI response objects. -var openAIResponsePool = sync.Pool{ - New: func() interface{} { - return &OpenAIResponse{} - }, -} - -// acquireOpenAIResponse gets an OpenAI response from the pool and resets it. -func acquireOpenAIResponse() *OpenAIResponse { - resp := openAIResponsePool.Get().(*OpenAIResponse) - *resp = OpenAIResponse{} // Reset the struct - return resp -} - -// releaseOpenAIResponse returns an OpenAI response to the pool. -func releaseOpenAIResponse(resp *OpenAIResponse) { - if resp != nil { - openAIResponsePool.Put(resp) - } -} - -// OpenAIProvider implements the Provider interface for OpenAI's API. -type OpenAIProvider struct { - logger schemas.Logger // Logger for provider operations - client *fasthttp.Client // HTTP client for API requests -} - -// NewOpenAIProvider creates a new OpenAI provider instance. -// It initializes the HTTP client with the provided configuration and sets up response pools. -// The client is configured with timeouts, concurrency limits, and optional proxy settings. -func NewOpenAIProvider(config *schemas.ProviderConfig, logger schemas.Logger) *OpenAIProvider { - setConfigDefaults(config) - - client := &fasthttp.Client{ - ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), - MaxConnsPerHost: config.ConcurrencyAndBufferSize.BufferSize, - } - - // Pre-warm response pools - for range config.ConcurrencyAndBufferSize.Concurrency { - openAIResponsePool.Put(&OpenAIResponse{}) - bifrostResponsePool.Put(&schemas.BifrostResponse{}) - } - - // Configure proxy if provided - client = configureProxy(client, config.ProxyConfig, logger) - - return &OpenAIProvider{ - logger: logger, - client: client, - } -} - -// GetProviderKey returns the provider identifier for OpenAI. -func (provider *OpenAIProvider) GetProviderKey() schemas.ModelProvider { - return schemas.OpenAI -} - -// TextCompletion is not supported by the OpenAI provider. -// Returns an error indicating that text completion is not available. -func (provider *OpenAIProvider) TextCompletion(model, key, text string, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: "text completion is not supported by openai provider", - }, - } -} - -// ChatCompletion performs a chat completion request to the OpenAI API. -// It supports both text and image content in messages. -// Returns a BifrostResponse containing the completion results or an error if the request fails. -func (provider *OpenAIProvider) ChatCompletion(model, key string, messages []schemas.Message, params *schemas.ModelParameters) (*schemas.BifrostResponse, *schemas.BifrostError) { - // Format messages for OpenAI API - var formattedMessages []map[string]interface{} - for _, msg := range messages { - if msg.ImageContent != nil { - var content []map[string]interface{} - - // Add text content if present - if msg.Content != nil { - content = append(content, map[string]interface{}{ - "type": "text", - "text": msg.Content, - }) - } - - imageContent := map[string]interface{}{ - "type": "image_url", - "image_url": map[string]interface{}{ - "url": msg.ImageContent.URL, - }, - } - - if msg.ImageContent.Detail != nil { - imageContent["image_url"].(map[string]interface{})["detail"] = msg.ImageContent.Detail - } - - content = append(content, imageContent) - - formattedMessages = append(formattedMessages, map[string]interface{}{ - "role": msg.Role, - "content": content, - }) - } else { - formattedMessages = append(formattedMessages, map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - }) - } - } - - preparedParams := prepareParams(params) - - requestBody := mergeConfig(map[string]interface{}{ - "model": model, - "messages": formattedMessages, - }, preparedParams) - - jsonBody, err := json.Marshal(requestBody) - if err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderJSONMarshaling, - Error: err, - }, - } - } - - // Create request - req := fasthttp.AcquireRequest() - resp := fasthttp.AcquireResponse() - defer fasthttp.ReleaseRequest(req) - defer fasthttp.ReleaseResponse(resp) - - req.SetRequestURI("https://api.openai.com/v1/chat/completions") - req.Header.SetMethod("POST") - req.Header.SetContentType("application/json") - req.Header.Set("Authorization", "Bearer "+key) - req.SetBody(jsonBody) - - // Make request - if err := provider.client.Do(req, resp); err != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: false, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderRequest, - Error: err, - }, - } - } - - // Handle error response - if resp.StatusCode() != fasthttp.StatusOK { - var errorResp OpenAIError - - bifrostErr := handleProviderAPIError(resp, &errorResp) - - bifrostErr.EventID = &errorResp.EventID - bifrostErr.Error.Type = &errorResp.Error.Type - bifrostErr.Error.Code = &errorResp.Error.Code - bifrostErr.Error.Message = errorResp.Error.Message - bifrostErr.Error.Param = errorResp.Error.Param - bifrostErr.Error.EventID = &errorResp.Error.EventID - - return nil, bifrostErr - } - - responseBody := resp.Body() - - // Pre-allocate response structs from pools - response := acquireOpenAIResponse() - defer releaseOpenAIResponse(response) - - result := acquireBifrostResponse() - defer releaseBifrostResponse(result) - - // Use enhanced response handler with pre-allocated response - rawResponse, bifrostErr := handleProviderResponse(responseBody, response) - if bifrostErr != nil { - return nil, bifrostErr - } - - // Populate result from response - result.ID = response.ID - result.Choices = response.Choices - result.Object = response.Object - result.Usage = response.Usage - result.ServiceTier = response.ServiceTier - result.SystemFingerprint = response.SystemFingerprint - result.Model = response.Model - result.Created = response.Created - result.ExtraFields = schemas.BifrostResponseExtraFields{ - Provider: schemas.OpenAI, - RawResponse: rawResponse, - } - - return result, nil -} diff --git a/core/providers/openai/chat.go b/core/providers/openai/chat.go new file mode 100644 index 000000000..96dd0aabd --- /dev/null +++ b/core/providers/openai/chat.go @@ -0,0 +1,93 @@ +package openai + +import ( + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBifrostChatRequest converts an OpenAI chat request to Bifrost format +func (request *OpenAIChatRequest) ToBifrostChatRequest() *schemas.BifrostChatRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.OpenAI) + + bifrostReq := &schemas.BifrostChatRequest{ + Provider: provider, + Model: model, + Input: request.Messages, + Params: &request.ChatParameters, + } + + return bifrostReq +} + +// ToOpenAIChatRequest converts a Bifrost chat completion request to OpenAI format +func ToOpenAIChatRequest(bifrostReq *schemas.BifrostChatRequest) *OpenAIChatRequest { + if bifrostReq == nil || bifrostReq.Input == nil { + return nil + } + + openaiReq := &OpenAIChatRequest{ + Model: bifrostReq.Model, + Messages: bifrostReq.Input, + } + + if bifrostReq.Params != nil { + openaiReq.ChatParameters = *bifrostReq.Params + } + + switch bifrostReq.Provider { + case schemas.OpenAI: + return openaiReq + case schemas.Gemini: + openaiReq.filterOpenAISpecificParameters() + // Removing extra parameters that are not supported by Gemini + openaiReq.ServiceTier = nil + return openaiReq + case schemas.Mistral: + openaiReq.filterOpenAISpecificParameters() + openaiReq.applyMistralCompatibility() + return openaiReq + case schemas.Vertex: + openaiReq.filterOpenAISpecificParameters() + + // Apply Mistral-specific transformations for Vertex Mistral models + if providerUtils.IsVertexMistralModel(bifrostReq.Model) { + openaiReq.applyMistralCompatibility() + } + return openaiReq + default: + openaiReq.filterOpenAISpecificParameters() + return openaiReq + } +} + +// Filter OpenAI Specific Parameters +func (request *OpenAIChatRequest) filterOpenAISpecificParameters() { + if request.ChatParameters.ReasoningEffort != nil && *request.ChatParameters.ReasoningEffort == "minimal" { + request.ChatParameters.ReasoningEffort = schemas.Ptr("low") + } + if request.ChatParameters.PromptCacheKey != nil { + request.ChatParameters.PromptCacheKey = nil + } + if request.ChatParameters.Verbosity != nil { + request.ChatParameters.Verbosity = nil + } + if request.ChatParameters.Store != nil { + request.ChatParameters.Store = nil + } +} + +// applyMistralCompatibility applies Mistral-specific transformations to the request +func (request *OpenAIChatRequest) applyMistralCompatibility() { + // Mistral uses max_tokens instead of max_completion_tokens + if request.MaxCompletionTokens != nil { + request.MaxTokens = request.MaxCompletionTokens + request.MaxCompletionTokens = nil + } + + // Mistral does not support ToolChoiceStruct, only simple tool choice strings are supported + if request.ToolChoice != nil && request.ToolChoice.ChatToolChoiceStruct != nil { + request.ToolChoice.ChatToolChoiceStr = schemas.Ptr("any") + request.ToolChoice.ChatToolChoiceStruct = nil + } +} diff --git a/core/providers/openai/embedding.go b/core/providers/openai/embedding.go new file mode 100644 index 000000000..6bad671db --- /dev/null +++ b/core/providers/openai/embedding.go @@ -0,0 +1,40 @@ +package openai + +import ( + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBifrostEmbeddingRequest converts an OpenAI embedding request to Bifrost format +func (request *OpenAIEmbeddingRequest) ToBifrostEmbeddingRequest() *schemas.BifrostEmbeddingRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.OpenAI) + + bifrostReq := &schemas.BifrostEmbeddingRequest{ + Provider: provider, + Model: model, + Input: request.Input, + Params: &request.EmbeddingParameters, + } + + return bifrostReq +} + +// ToOpenAIEmbeddingRequest converts a Bifrost embedding request to OpenAI format +func ToOpenAIEmbeddingRequest(bifrostReq *schemas.BifrostEmbeddingRequest) *OpenAIEmbeddingRequest { + if bifrostReq == nil { + return nil + } + + params := bifrostReq.Params + + openaiReq := &OpenAIEmbeddingRequest{ + Model: bifrostReq.Model, + Input: bifrostReq.Input, + } + + // Map parameters + if params != nil { + openaiReq.EmbeddingParameters = *params + } + + return openaiReq +} diff --git a/core/providers/openai/models.go b/core/providers/openai/models.go new file mode 100644 index 000000000..7d815f35c --- /dev/null +++ b/core/providers/openai/models.go @@ -0,0 +1,54 @@ +package openai + +import "github.com/maximhq/bifrost/core/schemas" + +func (response *OpenAIListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider) *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Data)), + } + + for _, model := range response.Data { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(providerKey) + "/" + model.ID, + Created: model.Created, + OwnedBy: schemas.Ptr(model.OwnedBy), + ContextLength: model.ContextWindow, + }) + + } + + return bifrostResponse +} + +func ToOpenAIListModelsResponse(response *schemas.BifrostListModelsResponse) *OpenAIListModelsResponse { + + if response == nil { + return nil + } + + openaiResponse := &OpenAIListModelsResponse{ + Data: make([]OpenAIModel, 0, len(response.Data)), + } + + for _, model := range response.Data { + openaiModel := OpenAIModel{ + ID: model.ID, + Object: "model", + } + if model.Created != nil { + openaiModel.Created = model.Created + } + if model.OwnedBy != nil { + openaiModel.OwnedBy = *model.OwnedBy + } + + openaiResponse.Data = append(openaiResponse.Data, openaiModel) + + } + + return openaiResponse +} diff --git a/core/providers/openai/openai.go b/core/providers/openai/openai.go new file mode 100644 index 000000000..cc2e57f0e --- /dev/null +++ b/core/providers/openai/openai.go @@ -0,0 +1,2340 @@ +// Package openai provides the OpenAI provider implementation for the Bifrost framework. +package openai + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "maps" + "mime/multipart" + "net/http" + "strings" + "sync" + "time" + + "github.com/bytedance/sonic" + + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// OpenAIProvider implements the Provider interface for OpenAI's GPT API. +type OpenAIProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse + customProviderConfig *schemas.CustomProviderConfig // Custom provider config +} + +// NewOpenAIProvider creates a new OpenAI provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewOpenAIProvider(config *schemas.ProviderConfig, logger schemas.Logger) *OpenAIProvider { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // // Pre-warm response pools + // for range config.ConcurrencyAndBufferSize.Concurrency { + // openAIResponsePool.Put(&schemas.BifrostResponse{}) + // } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.openai.com" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &OpenAIProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + customProviderConfig: config.CustomProviderConfig, + } +} + +// GetProviderKey returns the provider identifier for OpenAI. +func (provider *OpenAIProvider) GetProviderKey() schemas.ModelProvider { + return providerUtils.GetProviderName(schemas.OpenAI, provider.customProviderConfig) +} + +// buildRequestURL constructs the full request URL using the provider's configuration. +func (provider *OpenAIProvider) buildRequestURL(ctx context.Context, defaultPath string, requestType schemas.RequestType) string { + return provider.networkConfig.BaseURL + providerUtils.GetRequestPath(ctx, defaultPath, provider.customProviderConfig, requestType) +} + +func (provider *OpenAIProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.ListModelsRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + if provider.customProviderConfig != nil && provider.customProviderConfig.IsKeyLess { + return listModelsByKeyOpenAI( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/models", schemas.ListModelsRequest), + schemas.Key{}, + provider.networkConfig.ExtraHeaders, + providerName, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + ) + } + + return HandleOpenAIListModelsRequest(ctx, + provider.client, + request, + provider.buildRequestURL(ctx, "/v1/models", schemas.ListModelsRequest), + keys, + provider.networkConfig.ExtraHeaders, + providerName, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// listModelsByKeyOpenAI performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +func listModelsByKeyOpenAI( + ctx context.Context, + client *fasthttp.Client, + url string, + key schemas.Key, + extraHeaders map[string]string, + providerName schemas.ModelProvider, + sendBackRawResponse bool, +) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + bifrostErr := ParseOpenAIError(resp, schemas.ListModelsRequest, providerName, "") + return nil, bifrostErr + } + + // Copy response body before releasing + responseBody := append([]byte(nil), resp.Body()...) + + openaiResponse := &OpenAIListModelsResponse{} + + // Use enhanced response handler with pre-allocated response + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, openaiResponse, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response := openaiResponse.ToBifrostListModelsResponse(providerName) + + response.ExtraFields.Latency = latency.Milliseconds() + if sendBackRawResponse { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// HandleOpenAIListModelsRequest handles a list models request to OpenAI's API. +func HandleOpenAIListModelsRequest( + ctx context.Context, + client *fasthttp.Client, + request *schemas.BifrostListModelsRequest, + url string, + keys []schemas.Key, + extraHeaders map[string]string, + providerName schemas.ModelProvider, + sendBackRawResponse bool, + logger schemas.Logger, +) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + listModelsByKey := func(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return listModelsByKeyOpenAI(ctx, client, url, key, extraHeaders, providerName, sendBackRawResponse) + } + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + listModelsByKey, + logger, + ) +} + +// TextCompletion is not supported by the OpenAI provider. +// Returns an error indicating that text completion is not available. +func (provider *OpenAIProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.TextCompletionRequest); err != nil { + return nil, err + } + return HandleOpenAITextCompletionRequest( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/completions", schemas.TextCompletionRequest), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// HandleOpenAITextCompletionRequest handles a text completion request to OpenAI's API. +func HandleOpenAITextCompletionRequest( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostTextCompletionRequest, + key schemas.Key, + extraHeaders map[string]string, + providerName schemas.ModelProvider, + sendBackRawResponse bool, + logger schemas.Logger, +) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToOpenAITextCompletionRequest(request), nil }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + req.SetBody(jsonData) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + return nil, ParseOpenAIError(resp, schemas.TextCompletionRequest, providerName, request.Model) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + response := &schemas.BifrostTextCompletionResponse{} + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, response, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.RequestType = schemas.TextCompletionRequest + response.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if sendBackRawResponse { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// TextCompletionStream performs a streaming text completion request to OpenAI's API. +// It formats the request, sends it to OpenAI, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *OpenAIProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.TextCompletionStreamRequest); err != nil { + return nil, err + } + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + return HandleOpenAITextCompletionStreaming( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/completions", schemas.TextCompletionStreamRequest), + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// HandleOpenAITextCompletionStreaming handles text completion streaming for OpenAI-compatible APIs. +// This shared function reduces code duplication between providers that use the same SSE format. +func HandleOpenAITextCompletionStreaming( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostTextCompletionRequest, + authHeader map[string]string, + extraHeaders map[string]string, + sendBackRawResponse bool, + providerName schemas.ModelProvider, + postHookRunner schemas.PostHookRunner, + postResponseConverter func(*schemas.BifrostTextCompletionResponse) *schemas.BifrostTextCompletionResponse, + logger schemas.Logger, + inactivityTimeoutSeconds int, +) (chan *schemas.BifrostStream, *schemas.BifrostError) { + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + if authHeader != nil { + maps.Copy(headers, authHeader) + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToOpenAITextCompletionRequest(request) + if reqBody != nil { + reqBody.Stream = schemas.Ptr(true) + reqBody.StreamOptions = &schemas.ChatStreamOptions{ + IncludeUsage: schemas.Ptr(true), + } + } + return reqBody, nil + }, + providerName) + + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(url) + req.Header.SetContentType("application/json") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + req.SetBody(jsonBody) + + // Make the request + err := client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamOpenAIError(resp, schemas.TextCompletionStreamRequest, providerName, request.Model) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(inactivityTimeoutSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + chunkIndex := -1 + usage := &schemas.BifrostLLMUsage{} + + var finishReason *string + var messageID string + startTime := time.Now() + lastChunkTime := startTime + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + // Check if context is done before processing + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Check for end of stream + if line == "data: [DONE]" { + break + } + + var jsonData string + + // Parse SSE data + if after, ok := strings.CutPrefix(line, "data: "); ok { + jsonData = after + } else { + // Handle raw JSON errors (without "data: " prefix) + jsonData = line + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // First, check if this is an error response + var bifrostErr schemas.BifrostError + if err := sonic.Unmarshal([]byte(jsonData), &bifrostErr); err == nil { + if bifrostErr.Error != nil && bifrostErr.Error.Message != "" { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: providerName, + ModelRequested: request.Model, + RequestType: schemas.TextCompletionStreamRequest, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, &bifrostErr, responseChan, logger) + return + } + } + + // Parse into bifrost response + var response schemas.BifrostTextCompletionResponse + if err := sonic.Unmarshal([]byte(jsonData), &response); err != nil { + logger.Warn(fmt.Sprintf("Failed to parse stream response: %v", err)) + continue + } + + if postResponseConverter != nil { + if converted := postResponseConverter(&response); converted != nil { + response = *converted + } else { + logger.Warn("postResponseConverter returned nil; leaving chunk unmodified") + } + } + + // Handle usage-only chunks (when stream_options include_usage is true) + if response.Usage != nil { + // Collect usage information and send at the end of the stream + // Here in some cases usage comes before final message + // So we need to check if the response.Usage is nil and then if usage != nil + // then add up all tokens + if response.Usage.PromptTokens > usage.PromptTokens { + usage.PromptTokens = response.Usage.PromptTokens + } + if response.Usage.CompletionTokens > usage.CompletionTokens { + usage.CompletionTokens = response.Usage.CompletionTokens + } + if response.Usage.TotalTokens > usage.TotalTokens { + usage.TotalTokens = response.Usage.TotalTokens + } + calculatedTotal := usage.PromptTokens + usage.CompletionTokens + if calculatedTotal > usage.TotalTokens { + usage.TotalTokens = calculatedTotal + } + response.Usage = nil + } + + // Skip empty responses or responses without choices + if len(response.Choices) == 0 { + continue + } + + // Handle finish reason, usually in the final chunk + choice := response.Choices[0] + if choice.FinishReason != nil && *choice.FinishReason != "" { + // Collect finish reason and send at the end of the stream + finishReason = choice.FinishReason + response.Choices[0].FinishReason = nil + } + + if response.ID != "" && messageID == "" { + messageID = response.ID + } + + // Handle regular content chunks + if choice.TextCompletionResponseChoice != nil && choice.TextCompletionResponseChoice.Text != nil { + chunkIndex++ + + response.ExtraFields.RequestType = schemas.TextCompletionStreamRequest + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ChunkIndex = chunkIndex + response.ExtraFields.Latency = time.Since(lastChunkTime).Milliseconds() + lastChunkTime = time.Now() + + if sendBackRawResponse { + response.ExtraFields.RawResponse = jsonData + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(&response, nil, nil, nil, nil), responseChan) + } + + // For providers that don't send [DONE] marker break on finish_reason + if !providerUtils.ProviderSendsDoneMarker(providerName) && finishReason != nil { + break + } + } + + // Handle scanner errors first. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.TextCompletionStreamRequest, providerName, request.Model, logger) + } else if ctx.Err() == nil { + response := providerUtils.CreateBifrostTextCompletionChunkResponse(messageID, usage, finishReason, chunkIndex, schemas.TextCompletionStreamRequest, providerName, request.Model) + if postResponseConverter != nil { + response = postResponseConverter(response) + } + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(response, nil, nil, nil, nil), responseChan) + } + }() + + return responseChan, nil +} + +// ChatCompletion performs a chat completion request to the OpenAI API. +// It supports both text and image content in messages. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *OpenAIProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + // Check if chat completion is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.ChatCompletionRequest); err != nil { + return nil, err + } + + return HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/chat/completions", schemas.ChatCompletionRequest), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// HandleOpenAIChatCompletionRequest handles a chat completion request to OpenAI's API. +func HandleOpenAIChatCompletionRequest( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostChatRequest, + key schemas.Key, + extraHeaders map[string]string, + sendBackRawResponse bool, + providerName schemas.ModelProvider, + logger schemas.Logger, +) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToOpenAIChatRequest(request), nil }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + req.SetBody(jsonData) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + logger.Debug(fmt.Sprintf("error from %s provider: %s", providerName, string(resp.Body()))) + return nil, ParseOpenAIError(resp, schemas.ChatCompletionRequest, providerName, request.Model) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + response := &schemas.BifrostChatResponse{} + + // Use enhanced response handler with pre-allocated response + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, response, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Set raw response if enabled + if sendBackRawResponse { + response.ExtraFields.RawResponse = rawResponse + } + + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.RequestType = schemas.ChatCompletionRequest + response.ExtraFields.Latency = latency.Milliseconds() + + return response, nil +} + +// ChatCompletionStream handles streaming for OpenAI chat completions. +// It formats messages, prepares request body, and uses shared streaming logic. +// Returns a channel for streaming responses and any error that occurred. +func (provider *OpenAIProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if chat completion stream is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.ChatCompletionStreamRequest); err != nil { + return nil, err + } + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared streaming logic + return HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/chat/completions", schemas.ChatCompletionStreamRequest), + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// HandleOpenAIChatCompletionStreaming handles streaming for OpenAI-compatible APIs. +// This shared function reduces code duplication between providers that use the same SSE format. +func HandleOpenAIChatCompletionStreaming( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostChatRequest, + authHeader map[string]string, + extraHeaders map[string]string, + sendBackRawResponse bool, + providerName schemas.ModelProvider, + postHookRunner schemas.PostHookRunner, + customRequestConverter func(*schemas.BifrostChatRequest) (any, error), + postResponseConverter func(*schemas.BifrostChatResponse) *schemas.BifrostChatResponse, + logger schemas.Logger, + inactivityTimeoutSeconds int, +) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if the request is a redirect from ResponsesStream to ChatCompletionStream + isResponsesToChatCompletionsFallback := false + var responsesStreamState *schemas.ChatToResponsesStreamState + if ctx.Value(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback) != nil { + isResponsesToChatCompletionsFallbackValue, ok := ctx.Value(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback).(bool) + if ok && isResponsesToChatCompletionsFallbackValue { + isResponsesToChatCompletionsFallback = true + responsesStreamState = schemas.AcquireChatToResponsesStreamState() + defer schemas.ReleaseChatToResponsesStreamState(responsesStreamState) + } + } + + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + if authHeader != nil { + // Copy auth header to headers + maps.Copy(headers, authHeader) + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + if customRequestConverter != nil { + return customRequestConverter(request) + } + reqBody := ToOpenAIChatRequest(request) + if reqBody != nil { + reqBody.Stream = schemas.Ptr(true) + reqBody.StreamOptions = &schemas.ChatStreamOptions{ + IncludeUsage: schemas.Ptr(true), + } + } + return reqBody, nil + }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + // Updating request + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(url) + req.Header.SetContentType("application/json") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + req.SetBody(jsonBody) + + // Make the request + err := client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamOpenAIError(resp, schemas.ChatCompletionStreamRequest, providerName, request.Model) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(inactivityTimeoutSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + chunkIndex := -1 + usage := &schemas.BifrostLLMUsage{} + + startTime := time.Now() + lastChunkTime := startTime + + var finishReason *string + var messageID string + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + // Check if context is done before processing + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Check for end of stream + if line == "data: [DONE]" { + break + } + + var jsonData string + + // Parse SSE data + if after, ok := strings.CutPrefix(line, "data: "); ok { + jsonData = after + } else { + // Handle raw JSON errors (without "data: " prefix) + jsonData = line + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // First, check if this is an error response + var bifrostErr schemas.BifrostError + if err := sonic.Unmarshal([]byte(jsonData), &bifrostErr); err == nil { + if bifrostErr.Error != nil && bifrostErr.Error.Message != "" { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: providerName, + ModelRequested: request.Model, + RequestType: schemas.ChatCompletionStreamRequest, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, &bifrostErr, responseChan, logger) + return + } + } + + // Parse into bifrost response + var response schemas.BifrostChatResponse + if err := sonic.Unmarshal([]byte(jsonData), &response); err != nil { + logger.Warn(fmt.Sprintf("Failed to parse stream response: %v", err)) + continue + } + + if isResponsesToChatCompletionsFallback { + spreadResponses := response.ToBifrostResponsesStreamResponse(responsesStreamState) + for _, response := range spreadResponses { + if response.Type == schemas.ResponsesStreamResponseTypeError { + bifrostErr := &schemas.BifrostError{ + Type: schemas.Ptr(string(schemas.ResponsesStreamResponseTypeError)), + IsBifrostError: false, + Error: &schemas.ErrorField{}, + ExtraFields: schemas.BifrostErrorExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + }, + } + + if response.Message != nil { + bifrostErr.Error.Message = *response.Message + } + if response.Param != nil { + bifrostErr.Error.Param = *response.Param + } + if response.Code != nil { + bifrostErr.Error.Code = response.Code + } + + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, logger) + return + } + + response.ExtraFields.RequestType = schemas.ResponsesStreamRequest + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ChunkIndex = response.SequenceNumber + + if sendBackRawResponse { + response.ExtraFields.RawResponse = jsonData + } + + if response.Type == schemas.ResponsesStreamResponseTypeCompleted { + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + return + } + + response.ExtraFields.Latency = time.Since(lastChunkTime).Milliseconds() + lastChunkTime = time.Now() + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, response, nil, nil), responseChan) + } + } else { + if postResponseConverter != nil { + if converted := postResponseConverter(&response); converted != nil { + response = *converted + } else { + logger.Warn("postResponseConverter returned nil; leaving chunk unmodified") + } + } + + // Handle usage-only chunks (when stream_options include_usage is true) + if response.Usage != nil { + // Collect usage information and send at the end of the stream + // Here in some cases usage comes before final message + // So we need to check if the response.Usage is nil and then if usage != nil + // then add up all tokens + if response.Usage.PromptTokens > usage.PromptTokens { + usage.PromptTokens = response.Usage.PromptTokens + } + if response.Usage.CompletionTokens > usage.CompletionTokens { + usage.CompletionTokens = response.Usage.CompletionTokens + } + if response.Usage.TotalTokens > usage.TotalTokens { + usage.TotalTokens = response.Usage.TotalTokens + } + calculatedTotal := usage.PromptTokens + usage.CompletionTokens + if calculatedTotal > usage.TotalTokens { + usage.TotalTokens = calculatedTotal + } + response.Usage = nil + } + + // Skip empty responses or responses without choices + if len(response.Choices) == 0 { + continue + } + + // Handle finish reason, usually in the final chunk + choice := response.Choices[0] + if choice.FinishReason != nil && *choice.FinishReason != "" { + // Collect finish reason and send at the end of the stream + finishReason = choice.FinishReason + response.Choices[0].FinishReason = nil + } + + if response.ID != "" && messageID == "" { + messageID = response.ID + } + + // Handle regular content chunks + if choice.ChatStreamResponseChoice != nil && + choice.ChatStreamResponseChoice.Delta != nil && + (choice.ChatStreamResponseChoice.Delta.Content != nil || + len(choice.ChatStreamResponseChoice.Delta.ToolCalls) > 0) { + chunkIndex++ + + response.ExtraFields.RequestType = schemas.ChatCompletionStreamRequest + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ChunkIndex = chunkIndex + response.ExtraFields.Latency = time.Since(lastChunkTime).Milliseconds() + lastChunkTime = time.Now() + + if sendBackRawResponse { + response.ExtraFields.RawResponse = jsonData + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, &response, nil, nil, nil), responseChan) + } + + // For providers that don't send [DONE] marker break on finish_reason + if !providerUtils.ProviderSendsDoneMarker(providerName) && finishReason != nil { + break + } + } + } + + // Handle scanner errors first. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ChatCompletionStreamRequest, providerName, request.Model, logger) + } else if ctx.Err() == nil && !isResponsesToChatCompletionsFallback { + response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finishReason, chunkIndex, schemas.ChatCompletionStreamRequest, providerName, request.Model) + if postResponseConverter != nil { + response = postResponseConverter(response) + } + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, response, nil, nil, nil), responseChan) + } + }() + + return responseChan, nil +} + +// Responses performs a responses request to the OpenAI API. +func (provider *OpenAIProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + // Check if chat completion is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.ResponsesRequest); err != nil { + return nil, err + } + + return HandleOpenAIResponsesRequest( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/responses", schemas.ResponsesRequest), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// HandleOpenAIResponsesRequest handles a responses request to OpenAI's API. +func HandleOpenAIResponsesRequest( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostResponsesRequest, + key schemas.Key, + extraHeaders map[string]string, + sendBackRawResponse bool, + providerName schemas.ModelProvider, + logger schemas.Logger, +) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + // Use centralized converter + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToOpenAIResponsesRequest(request), nil }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + req.SetBody(jsonData) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + logger.Debug(fmt.Sprintf("error from %s provider: %s", providerName, string(resp.Body()))) + return nil, ParseOpenAIError(resp, schemas.ResponsesRequest, providerName, request.Model) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + response := &schemas.BifrostResponsesResponse{} + + // Use enhanced response handler with pre-allocated response + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, response, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Set raw response if enabled + if sendBackRawResponse { + response.ExtraFields.RawResponse = rawResponse + } + + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Latency = latency.Milliseconds() + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the OpenAI API. +func (provider *OpenAIProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Check if chat completion stream is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.ResponsesStreamRequest); err != nil { + return nil, err + } + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared streaming logic + return HandleOpenAIResponsesStreaming( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/responses", schemas.ResponsesStreamRequest), + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// HandleOpenAIResponsesStreaming handles streaming for OpenAI-compatible APIs. +// This shared function reduces code duplication between providers that use the same SSE format. +func HandleOpenAIResponsesStreaming( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostResponsesRequest, + authHeader map[string]string, + extraHeaders map[string]string, + sendBackRawResponse bool, + providerName schemas.ModelProvider, + postHookRunner schemas.PostHookRunner, + postRequestConverter func(*OpenAIResponsesRequest) *OpenAIResponsesRequest, + postResponseConverter func(*schemas.BifrostResponsesStreamResponse) *schemas.BifrostResponsesStreamResponse, + logger schemas.Logger, + inactivityTimeoutSeconds int, +) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Prepare SGL headers (SGL typically doesn't require authorization, but we include it if provided) + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + if authHeader != nil { + // Copy auth header to headers + maps.Copy(headers, authHeader) + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToOpenAIResponsesRequest(request) + if reqBody != nil { + if postRequestConverter != nil { + reqBody = postRequestConverter(reqBody) + } + reqBody.Stream = schemas.Ptr(true) + } + return reqBody, nil + }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(url) + req.Header.SetContentType("application/json") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + req.SetBody(jsonBody) + + // Make the request + err := client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamOpenAIError(resp, schemas.ResponsesStreamRequest, providerName, request.Model) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + if ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(inactivityTimeoutSeconds)*time.Second { + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + startTime := time.Now() + lastChunkTime := startTime + + for scanner.Scan() { + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + // Check if context is done before processing + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // Skip empty lines, comments, and event lines + if line == "" || strings.HasPrefix(line, ":") || strings.HasPrefix(line, "event:") { + continue + } + + // Check for end of stream + if line == "data: [DONE]" { + break + } + + var jsonData string + + // Parse SSE data + if after, ok := strings.CutPrefix(line, "data: "); ok { + jsonData = after + } else if !strings.HasPrefix(line, "event:") { + // Handle raw JSON errors (without "data: " prefix) but skip event lines + jsonData = line + } else { + // This is an event line, skip it + continue + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // Parse into bifrost response + var response schemas.BifrostResponsesStreamResponse + if err := sonic.Unmarshal([]byte(jsonData), &response); err != nil { + logger.Warn(fmt.Sprintf("Failed to parse stream response: %v", err)) + continue + } + + if postResponseConverter != nil { + if converted := postResponseConverter(&response); converted != nil { + response = *converted + } else { + logger.Warn("postResponseConverter returned nil; leaving chunk unmodified") + } + } + + if response.Type == schemas.ResponsesStreamResponseTypeError { + bifrostErr := &schemas.BifrostError{ + Type: schemas.Ptr(string(schemas.ResponsesStreamResponseTypeError)), + IsBifrostError: false, + Error: &schemas.ErrorField{}, + ExtraFields: schemas.BifrostErrorExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + }, + } + + if response.Message != nil { + bifrostErr.Error.Message = *response.Message + } + if response.Param != nil { + bifrostErr.Error.Param = *response.Param + } + if response.Code != nil { + bifrostErr.Error.Code = response.Code + } + + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, bifrostErr, responseChan, logger) + return + } + + response.ExtraFields.RequestType = schemas.ResponsesStreamRequest + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.ChunkIndex = response.SequenceNumber + + if sendBackRawResponse { + response.ExtraFields.RawResponse = jsonData + } + + if response.Type == schemas.ResponsesStreamResponseTypeCompleted { + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, &response, nil, nil), responseChan) + return + } + + response.ExtraFields.Latency = time.Since(lastChunkTime).Milliseconds() + lastChunkTime = time.Now() + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, &response, nil, nil), responseChan) + } + // Handle scanner errors first. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.ResponsesStreamRequest, providerName, request.Model, logger) + } + }() + + return responseChan, nil +} + +// Embedding generates embeddings for the given input text(s). +// The input can be either a single string or a slice of strings for batch embedding. +// Returns a BifrostResponse containing the embedding(s) and any error that occurred. +func (provider *OpenAIProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + // Check if embedding is allowed for this provider + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.EmbeddingRequest); err != nil { + return nil, err + } + + // Use the shared embedding request handler + return HandleOpenAIEmbeddingRequest( + ctx, + provider.client, + provider.buildRequestURL(ctx, "/v1/embeddings", schemas.EmbeddingRequest), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// HandleOpenAIEmbeddingRequest handles embedding requests for OpenAI-compatible APIs. +// This shared function reduces code duplication between providers that use the same embedding request format. +func HandleOpenAIEmbeddingRequest( + ctx context.Context, + client *fasthttp.Client, + url string, + request *schemas.BifrostEmbeddingRequest, + key schemas.Key, + extraHeaders map[string]string, + providerName schemas.ModelProvider, + sendBackRawResponse bool, + logger schemas.Logger, +) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, extraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + // Use centralized converter + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToOpenAIEmbeddingRequest(request), nil }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + req.SetBody(jsonData) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + logger.Debug(fmt.Sprintf("error from %s provider: %s", providerName, string(resp.Body()))) + return nil, ParseOpenAIError(resp, schemas.EmbeddingRequest, providerName, request.Model) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + response := &schemas.BifrostEmbeddingResponse{} + + // Use enhanced response handler with pre-allocated response + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(body, response, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.RequestType = schemas.EmbeddingRequest + response.ExtraFields.Latency = latency.Milliseconds() + + if sendBackRawResponse { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil +} + +// Speech handles non-streaming speech synthesis requests. +// It formats the request body, makes the API call, and returns the response. +// Returns the response and any error that occurred. +func (provider *OpenAIProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.SpeechRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(provider.buildRequestURL(ctx, "/v1/audio/speech", schemas.SpeechRequest)) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToOpenAISpeechRequest(request), nil }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + req.SetBody(jsonData) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + provider.logger.Debug(fmt.Sprintf("error from %s provider: %s", providerName, string(resp.Body()))) + return nil, ParseOpenAIError(resp, schemas.SpeechRequest, providerName, request.Model) + } + + // Get the binary audio data from the response body + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + // Create final response with the audio data + // Note: For speech synthesis, we return the binary audio data in the raw response + // The audio data is typically in MP3, WAV, or other audio formats as specified by response_format + bifrostResponse := &schemas.BifrostSpeechResponse{ + Audio: body, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.SpeechRequest, + Provider: providerName, + ModelRequested: request.Model, + Latency: latency.Milliseconds(), + }, + } + + return bifrostResponse, nil +} + +// SpeechStream handles streaming for speech synthesis. +// It formats the request body, creates HTTP request, and uses shared streaming logic. +// Returns a channel for streaming responses and any error that occurred. +func (provider *OpenAIProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.SpeechStreamRequest); err != nil { + return nil, err + } + providerName := provider.GetProviderKey() + // Use centralized converter + reqBody := ToOpenAISpeechRequest(request) + if reqBody == nil { + return nil, providerUtils.NewBifrostOperationError("speech input is not provided", nil, providerName) + } + reqBody.StreamFormat = schemas.Ptr("sse") + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := ToOpenAISpeechRequest(request) + if reqBody != nil { + reqBody.StreamFormat = schemas.Ptr("sse") + } + return reqBody, nil + }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + // Prepare OpenAI headers + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + if key.Value != "" { + headers["Authorization"] = "Bearer " + key.Value + } + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.buildRequestURL(ctx, "/v1/audio/speech", schemas.SpeechStreamRequest)) + req.Header.SetContentType("application/json") + + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Set any extra headers from network config + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + req.SetBody(jsonBody) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamOpenAIError(resp, schemas.SpeechStreamRequest, providerName, request.Model) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + chunkIndex := -1 + + startTime := time.Now() + lastChunkTime := startTime + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + // Check if context is done before processing + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Check for end of stream + if line == "data: [DONE]" { + break + } + + var jsonData string + + // Parse SSE data + if strings.HasPrefix(line, "data: ") { + jsonData = strings.TrimPrefix(line, "data: ") + } else { + // Handle raw JSON errors (without "data: " prefix) + jsonData = line + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // First, check if this is an error response + var bifrostErr schemas.BifrostError + if err := sonic.Unmarshal([]byte(jsonData), &bifrostErr); err == nil { + if bifrostErr.Error != nil && bifrostErr.Error.Message != "" { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: providerName, + ModelRequested: request.Model, + RequestType: schemas.SpeechStreamRequest, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, &bifrostErr, responseChan, provider.logger) + return + } + } + + // Parse into bifrost response + var response schemas.BifrostSpeechStreamResponse + if err := sonic.Unmarshal([]byte(jsonData), &response); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse stream response: %v", err)) + continue + } + + chunkIndex++ + + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.SpeechStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = jsonData + } + + if response.Usage != nil { + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, &response, nil), responseChan) + return + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, &response, nil), responseChan) + } + + // Handle scanner errors. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.SpeechStreamRequest, providerName, request.Model, provider.logger) + } + }() + + return responseChan, nil +} + +// Transcription handles non-streaming transcription requests. +// It creates a multipart form, adds fields, makes the API call, and returns the response. +// Returns the response and any error that occurred. +func (provider *OpenAIProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.TranscriptionRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + // Use centralized converter + reqBody := ToOpenAITranscriptionRequest(request) + if reqBody == nil { + return nil, providerUtils.NewBifrostOperationError("transcription input is not provided", nil, providerName) + } + + // Create multipart form + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := parseTranscriptionFormDataBodyFromRequest(writer, reqBody, providerName); err != nil { + return nil, err + } + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(provider.buildRequestURL(ctx, "/v1/audio/transcriptions", schemas.TranscriptionRequest)) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType(writer.FormDataContentType()) // This sets multipart/form-data with boundary + if key.Value != "" { + req.Header.Set("Authorization", "Bearer "+key.Value) + } + + req.SetBody(body.Bytes()) + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + provider.logger.Debug(fmt.Sprintf("error from %s provider: %s", providerName, string(resp.Body()))) + return nil, ParseOpenAIError(resp, schemas.TranscriptionRequest, providerName, request.Model) + } + + responseBody, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, providerName) + } + + // Parse OpenAI's transcription response directly into BifrostTranscribe + response := &schemas.BifrostTranscriptionResponse{} + + if err := sonic.Unmarshal(responseBody, response); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, providerName) + } + + // Parse raw response for RawResponse field + var rawResponse interface{} + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + if err := sonic.Unmarshal(responseBody, &rawResponse); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRawResponseUnmarshal, err, providerName) + } + } + + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.TranscriptionRequest, + Provider: providerName, + ModelRequested: request.Model, + Latency: latency.Milliseconds(), + } + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil + +} + +// TranscriptionStream performs a streaming transcription request to the OpenAI API. +func (provider *OpenAIProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + if err := providerUtils.CheckOperationAllowed(schemas.OpenAI, provider.customProviderConfig, schemas.TranscriptionStreamRequest); err != nil { + return nil, err + } + + providerName := provider.GetProviderKey() + + // Use centralized converter + reqBody := ToOpenAITranscriptionRequest(request) + if reqBody == nil { + return nil, providerUtils.NewBifrostOperationError("transcription input is not provided", nil, providerName) + } + reqBody.Stream = schemas.Ptr(true) + + // Create multipart form + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + if bifrostErr := parseTranscriptionFormDataBodyFromRequest(writer, reqBody, providerName); bifrostErr != nil { + return nil, bifrostErr + } + + // Prepare OpenAI headers + headers := map[string]string{ + "Content-Type": writer.FormDataContentType(), + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + if key.Value != "" { + headers["Authorization"] = "Bearer " + key.Value + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + resp.StreamBody = true + defer fasthttp.ReleaseRequest(req) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(provider.buildRequestURL(ctx, "/v1/audio/transcriptions", schemas.TranscriptionStreamRequest)) + req.Header.SetContentType("application/json") + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + req.SetBody(body.Bytes()) + + // Make the request + err := provider.client.Do(req, resp) + if err != nil { + defer providerUtils.ReleaseStreamingResponse(resp) + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, providerName) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, providerName) + } + + // Check for HTTP errors + if resp.StatusCode() != fasthttp.StatusOK { + defer providerUtils.ReleaseStreamingResponse(resp) + return nil, parseStreamOpenAIError(resp, schemas.TranscriptionStreamRequest, providerName, request.Model) + } + + // Create response channel + responseChan := make(chan *schemas.BifrostStream, schemas.DefaultStreamBufferSize) + + // Start streaming in a goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Panic from force-closed stream due to inactivity timeout is expected. + // Only re-panic if context wasn't cancelled (unexpected panic). + if ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Stream panic (expected from inactivity timeout): %v", r)) + } + } + }() + defer close(responseChan) + defer providerUtils.ReleaseStreamingResponse(resp) + + // Track last activity time for inactivity timeout detection + lastActivity := time.Now() + activityMutex := &sync.Mutex{} + done := make(chan struct{}) + defer close(done) + + // Monitor stream inactivity and force-close if stream hangs + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + activityMutex.Lock() + inactive := time.Since(lastActivity) + activityMutex.Unlock() + if inactive > time.Duration(provider.networkConfig.StreamInactivityTimeoutInSeconds)*time.Second { + // Stream has been inactive, force close to unblock scanner + resp.CloseBodyStream() + return + } + case <-done: + return + case <-ctx.Done(): + return + } + } + }() + + scanner := bufio.NewScanner(resp.BodyStream()) + chunkIndex := -1 + + startTime := time.Now() + lastChunkTime := startTime + + for scanner.Scan() { + // Update activity time on successful scan + activityMutex.Lock() + lastActivity = time.Now() + activityMutex.Unlock() + + // Check if context is done before processing + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // Skip empty lines and comments + if line == "" { + continue + } + + // Check for end of stream + if line == "data: [DONE]" { + break + } + + var jsonData string + // Parse SSE data + if strings.HasPrefix(line, "data: ") { + jsonData = strings.TrimPrefix(line, "data: ") + } else { + // Handle raw JSON errors (without "data: " prefix) + jsonData = line + } + + // Skip empty data + if strings.TrimSpace(jsonData) == "" { + continue + } + + // First, check if this is an error response + var bifrostErr schemas.BifrostError + if err := sonic.Unmarshal([]byte(jsonData), &bifrostErr); err == nil { + if bifrostErr.Error != nil && bifrostErr.Error.Message != "" { + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: providerName, + ModelRequested: request.Model, + RequestType: schemas.TranscriptionStreamRequest, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendBifrostError(ctx, postHookRunner, &bifrostErr, responseChan, provider.logger) + return + } + } + + var response schemas.BifrostTranscriptionStreamResponse + if err := sonic.Unmarshal([]byte(jsonData), &response); err != nil { + provider.logger.Warn(fmt.Sprintf("Failed to parse stream response: %v", err)) + continue + } + + chunkIndex++ + + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.TranscriptionStreamRequest, + Provider: providerName, + ModelRequested: request.Model, + ChunkIndex: chunkIndex, + Latency: time.Since(lastChunkTime).Milliseconds(), + } + lastChunkTime = time.Now() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = jsonData + } + + if response.Usage != nil { + response.ExtraFields.Latency = time.Since(startTime).Milliseconds() + ctx = context.WithValue(ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, nil, &response), responseChan) + return + } + + providerUtils.ProcessAndSendResponse(ctx, postHookRunner, providerUtils.GetBifrostResponseForStreamResponse(nil, nil, nil, nil, &response), responseChan) + } + + // Handle scanner errors. + // If context was cancelled, scanner errors are expected (from force-closed body stream). + if err := scanner.Err(); err != nil && ctx.Err() == nil { + provider.logger.Warn(fmt.Sprintf("Error reading stream: %v", err)) + providerUtils.ProcessAndSendError(ctx, postHookRunner, err, responseChan, schemas.TranscriptionStreamRequest, providerName, request.Model, provider.logger) + } + }() + + return responseChan, nil +} + +// parseTranscriptionFormDataBodyFromRequest parses the transcription request and writes it to the multipart form. +func parseTranscriptionFormDataBodyFromRequest(writer *multipart.Writer, openaiReq *OpenAITranscriptionRequest, providerName schemas.ModelProvider) *schemas.BifrostError { + // Add file field + fileWriter, err := writer.CreateFormFile("file", "audio.mp3") // OpenAI requires a filename + if err != nil { + return providerUtils.NewBifrostOperationError("failed to create form file", err, providerName) + } + if _, err := fileWriter.Write(openaiReq.File); err != nil { + return providerUtils.NewBifrostOperationError("failed to write file data", err, providerName) + } + + // Add model field + if err := writer.WriteField("model", openaiReq.Model); err != nil { + return providerUtils.NewBifrostOperationError("failed to write model field", err, providerName) + } + + // Add optional fields + if openaiReq.Language != nil { + if err := writer.WriteField("language", *openaiReq.Language); err != nil { + return providerUtils.NewBifrostOperationError("failed to write language field", err, providerName) + } + } + + if openaiReq.Prompt != nil { + if err := writer.WriteField("prompt", *openaiReq.Prompt); err != nil { + return providerUtils.NewBifrostOperationError("failed to write prompt field", err, providerName) + } + } + + if openaiReq.ResponseFormat != nil { + if err := writer.WriteField("response_format", *openaiReq.ResponseFormat); err != nil { + return providerUtils.NewBifrostOperationError("failed to write response_format field", err, providerName) + } + } + + if openaiReq.Stream != nil && *openaiReq.Stream { + if err := writer.WriteField("stream", "true"); err != nil { + return providerUtils.NewBifrostOperationError("failed to write stream field", err, providerName) + } + } + + // Close the multipart writer + if err := writer.Close(); err != nil { + return providerUtils.NewBifrostOperationError("failed to close multipart writer", err, providerName) + } + + return nil +} + +// ParseOpenAIError parses OpenAI error responses. +func ParseOpenAIError(resp *fasthttp.Response, requestType schemas.RequestType, providerName schemas.ModelProvider, model string) *schemas.BifrostError { + var errorResp schemas.BifrostError + + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + + if errorResp.EventID != nil { + bifrostErr.EventID = errorResp.EventID + } + + if errorResp.Error != nil { + if bifrostErr.Error == nil { + bifrostErr.Error = &schemas.ErrorField{} + } + bifrostErr.Error.Type = errorResp.Error.Type + bifrostErr.Error.Code = errorResp.Error.Code + bifrostErr.Error.Message = errorResp.Error.Message + bifrostErr.Error.Param = errorResp.Error.Param + if errorResp.Error.EventID != nil { + bifrostErr.Error.EventID = errorResp.Error.EventID + } + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: providerName, + ModelRequested: model, + RequestType: requestType, + } + } + + return bifrostErr +} + +// parseStreamOpenAIError parses OpenAI streaming error responses. +func parseStreamOpenAIError(resp *fasthttp.Response, requestType schemas.RequestType, providerName schemas.ModelProvider, model string) *schemas.BifrostError { + var errorResp schemas.BifrostError + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp) + if errorResp.EventID != nil { + bifrostErr.EventID = errorResp.EventID + } + if errorResp.Error != nil { + if bifrostErr.Error == nil { + bifrostErr.Error = &schemas.ErrorField{} + } + bifrostErr.Error.Type = errorResp.Error.Type + bifrostErr.Error.Code = errorResp.Error.Code + bifrostErr.Error.Message = errorResp.Error.Message + bifrostErr.Error.Param = errorResp.Error.Param + if errorResp.Error.EventID != nil { + bifrostErr.Error.EventID = errorResp.Error.EventID + } + bifrostErr.ExtraFields = schemas.BifrostErrorExtraFields{ + Provider: providerName, + ModelRequested: model, + RequestType: requestType, + } + } + + return bifrostErr +} diff --git a/core/providers/openai/responses.go b/core/providers/openai/responses.go new file mode 100644 index 000000000..417aa28fe --- /dev/null +++ b/core/providers/openai/responses.go @@ -0,0 +1,85 @@ +package openai + +import "github.com/maximhq/bifrost/core/schemas" + +// ToBifrostResponsesRequest converts an OpenAI responses request to Bifrost format +func (request *OpenAIResponsesRequest) ToBifrostResponsesRequest() *schemas.BifrostResponsesRequest { + if request == nil { + return nil + } + + provider, model := schemas.ParseModelString(request.Model, schemas.OpenAI) + + input := request.Input.OpenAIResponsesRequestInputArray + if len(input) == 0 { + input = []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: request.Input.OpenAIResponsesRequestInputStr}, + }, + } + } + + return &schemas.BifrostResponsesRequest{ + Provider: provider, + Model: model, + Input: input, + Params: &request.ResponsesParameters, + } +} + +// ToOpenAIResponsesRequest converts a Bifrost responses request to OpenAI format +func ToOpenAIResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *OpenAIResponsesRequest { + if bifrostReq == nil || bifrostReq.Input == nil { + return nil + } + // Preparing final input + input := OpenAIResponsesRequestInput{ + OpenAIResponsesRequestInputArray: bifrostReq.Input, + } + // Updating params + params := bifrostReq.Params + // Create the responses request with properly mapped parameters + req := &OpenAIResponsesRequest{ + Model: bifrostReq.Model, + Input: input, + } + + if params != nil { + req.ResponsesParameters = *params + // Filter out tools that OpenAI doesn't support + req.filterUnsupportedTools() + } + + return req +} + +// filterUnsupportedTools removes tool types that OpenAI doesn't support +func (req *OpenAIResponsesRequest) filterUnsupportedTools() { + if len(req.Tools) == 0 { + return + } + + // Define OpenAI-supported tool types + supportedTypes := map[schemas.ResponsesToolType]bool{ + schemas.ResponsesToolTypeFunction: true, + schemas.ResponsesToolTypeFileSearch: true, + schemas.ResponsesToolTypeComputerUsePreview: true, + schemas.ResponsesToolTypeWebSearch: true, + schemas.ResponsesToolTypeMCP: true, + schemas.ResponsesToolTypeCodeInterpreter: true, + schemas.ResponsesToolTypeImageGeneration: true, + schemas.ResponsesToolTypeLocalShell: true, + schemas.ResponsesToolTypeCustom: true, + schemas.ResponsesToolTypeWebSearchPreview: true, + } + + // Filter tools to only include supported types + filteredTools := make([]schemas.ResponsesTool, 0, len(req.Tools)) + for _, tool := range req.Tools { + if supportedTypes[tool.Type] { + filteredTools = append(filteredTools, tool) + } + } + req.Tools = filteredTools +} diff --git a/core/providers/openai/speech.go b/core/providers/openai/speech.go new file mode 100644 index 000000000..4d2c43aca --- /dev/null +++ b/core/providers/openai/speech.go @@ -0,0 +1,38 @@ +package openai + +import "github.com/maximhq/bifrost/core/schemas" + +// ToBifrostSpeechRequest converts an OpenAI speech request to Bifrost format +func (request *OpenAISpeechRequest) ToBifrostSpeechRequest() *schemas.BifrostSpeechRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.OpenAI) + + bifrostReq := &schemas.BifrostSpeechRequest{ + Provider: provider, + Model: model, + Input: &schemas.SpeechInput{Input: request.Input}, + Params: &request.SpeechParameters, + } + + return bifrostReq +} + +// ToOpenAISpeechRequest converts a Bifrost speech request to OpenAI format +func ToOpenAISpeechRequest(bifrostReq *schemas.BifrostSpeechRequest) *OpenAISpeechRequest { + if bifrostReq == nil || bifrostReq.Input.Input == "" { + return nil + } + + speechInput := bifrostReq.Input + params := bifrostReq.Params + + openaiReq := &OpenAISpeechRequest{ + Model: bifrostReq.Model, + Input: speechInput.Input, + } + + if params != nil { + openaiReq.SpeechParameters = *params + } + + return openaiReq +} diff --git a/core/providers/openai/text.go b/core/providers/openai/text.go new file mode 100644 index 000000000..6b59dc8e6 --- /dev/null +++ b/core/providers/openai/text.go @@ -0,0 +1,37 @@ +package openai + +import ( + "github.com/maximhq/bifrost/core/schemas" +) + +// ToOpenAITextCompletionRequest converts a Bifrost text completion request to OpenAI format +func ToOpenAITextCompletionRequest(bifrostReq *schemas.BifrostTextCompletionRequest) *OpenAITextCompletionRequest { + if bifrostReq == nil { + return nil + } + params := bifrostReq.Params + openaiReq := &OpenAITextCompletionRequest{ + Model: bifrostReq.Model, + Prompt: bifrostReq.Input, + } + if params != nil { + openaiReq.TextCompletionParameters = *params + } + return openaiReq +} + +// ToBifrostTextCompletionRequest converts an OpenAI text completion request to Bifrost format +func (request *OpenAITextCompletionRequest) ToBifrostTextCompletionRequest() *schemas.BifrostTextCompletionRequest { + if request == nil { + return nil + } + + provider, model := schemas.ParseModelString(request.Model, schemas.OpenAI) + + return &schemas.BifrostTextCompletionRequest{ + Provider: provider, + Model: model, + Input: request.Prompt, + Params: &request.TextCompletionParameters, + } +} diff --git a/core/providers/openai/transcription.go b/core/providers/openai/transcription.go new file mode 100644 index 000000000..07d361681 --- /dev/null +++ b/core/providers/openai/transcription.go @@ -0,0 +1,40 @@ +package openai + +import "github.com/maximhq/bifrost/core/schemas" + +// ToBifrostTranscriptionRequest converts an OpenAI transcription request to Bifrost format +func (request *OpenAITranscriptionRequest) ToBifrostTranscriptionRequest() *schemas.BifrostTranscriptionRequest { + provider, model := schemas.ParseModelString(request.Model, schemas.OpenAI) + + bifrostReq := &schemas.BifrostTranscriptionRequest{ + Provider: provider, + Model: model, + Input: &schemas.TranscriptionInput{ + File: request.File, + }, + Params: &request.TranscriptionParameters, + } + + return bifrostReq +} + +// ToOpenAITranscriptionRequest converts a Bifrost transcription request to OpenAI format +func ToOpenAITranscriptionRequest(bifrostReq *schemas.BifrostTranscriptionRequest) *OpenAITranscriptionRequest { + if bifrostReq == nil || bifrostReq.Input.File == nil { + return nil + } + + transcriptionInput := bifrostReq.Input + params := bifrostReq.Params + + openaiReq := &OpenAITranscriptionRequest{ + Model: bifrostReq.Model, + File: transcriptionInput.File, + } + + if params != nil { + openaiReq.TranscriptionParameters = *params + } + + return openaiReq +} diff --git a/core/providers/openai/types.go b/core/providers/openai/types.go new file mode 100644 index 000000000..4eefdea21 --- /dev/null +++ b/core/providers/openai/types.go @@ -0,0 +1,143 @@ +package openai + +import ( + "fmt" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +// REQUEST TYPES + +// OpenAITextCompletionRequest represents an OpenAI text completion request +type OpenAITextCompletionRequest struct { + Model string `json:"model"` // Required: Model to use + Prompt *schemas.TextCompletionInput `json:"prompt"` // Required: String or array of strings + + schemas.TextCompletionParameters + Stream *bool `json:"stream,omitempty"` +} + +// IsStreamingRequested implements the StreamingRequest interface +func (r *OpenAITextCompletionRequest) IsStreamingRequested() bool { + return r.Stream != nil && *r.Stream +} + +// OpenAIEmbeddingRequest represents an OpenAI embedding request +type OpenAIEmbeddingRequest struct { + Model string `json:"model"` + Input *schemas.EmbeddingInput `json:"input"` // Can be string or []string + + schemas.EmbeddingParameters +} + +// OpenAIChatRequest represents an OpenAI chat completion request +type OpenAIChatRequest struct { + Model string `json:"model"` + Messages []schemas.ChatMessage `json:"messages"` + + schemas.ChatParameters + Stream *bool `json:"stream,omitempty"` + + //NOTE: MaxCompletionTokens is a new replacement for max_tokens but some providers still use max_tokens. + // This Field is populated only for such providers and is NOT to be used externally. + MaxTokens *int `json:"max_tokens,omitempty"` +} + +// IsStreamingRequested implements the StreamingRequest interface +func (r *OpenAIChatRequest) IsStreamingRequested() bool { + return r.Stream != nil && *r.Stream +} + +// ResponsesRequestInput is a union of string and array of responses messages +type OpenAIResponsesRequestInput struct { + OpenAIResponsesRequestInputStr *string + OpenAIResponsesRequestInputArray []schemas.ResponsesMessage +} + +// UnmarshalJSON unmarshals the responses request input +func (r *OpenAIResponsesRequestInput) UnmarshalJSON(data []byte) error { + var str string + if err := sonic.Unmarshal(data, &str); err == nil { + r.OpenAIResponsesRequestInputStr = &str + r.OpenAIResponsesRequestInputArray = nil + return nil + } + var array []schemas.ResponsesMessage + if err := sonic.Unmarshal(data, &array); err == nil { + r.OpenAIResponsesRequestInputStr = nil + r.OpenAIResponsesRequestInputArray = array + return nil + } + return fmt.Errorf("openai responses request input is neither a string nor an array of responses messages") +} + +// MarshalJSON implements custom JSON marshalling for ResponsesRequestInput. +func (r *OpenAIResponsesRequestInput) MarshalJSON() ([]byte, error) { + if r.OpenAIResponsesRequestInputStr != nil { + return sonic.Marshal(*r.OpenAIResponsesRequestInputStr) + } + if r.OpenAIResponsesRequestInputArray != nil { + return sonic.Marshal(r.OpenAIResponsesRequestInputArray) + } + return sonic.Marshal(nil) +} + +type OpenAIResponsesRequest struct { + Model string `json:"model"` + Input OpenAIResponsesRequestInput `json:"input"` + + schemas.ResponsesParameters + Stream *bool `json:"stream,omitempty"` +} + +// IsStreamingRequested implements the StreamingRequest interface +func (r *OpenAIResponsesRequest) IsStreamingRequested() bool { + return r.Stream != nil && *r.Stream +} + +// OpenAISpeechRequest represents an OpenAI speech synthesis request +type OpenAISpeechRequest struct { + Model string `json:"model"` + Input string `json:"input"` + + schemas.SpeechParameters + StreamFormat *string `json:"stream_format,omitempty"` +} + +// OpenAITranscriptionRequest represents an OpenAI transcription request +// Note: This is used for JSON body parsing, actual form parsing is handled in the router +type OpenAITranscriptionRequest struct { + Model string `json:"model"` + File []byte `json:"file"` // Binary audio data + + schemas.TranscriptionParameters + Stream *bool `json:"stream,omitempty"` +} + +// IsStreamingRequested implements the StreamingRequest interface for speech +func (r *OpenAISpeechRequest) IsStreamingRequested() bool { + return r.StreamFormat != nil && *r.StreamFormat == "sse" +} + +// IsStreamingRequested implements the StreamingRequest interface for transcription +func (r *OpenAITranscriptionRequest) IsStreamingRequested() bool { + return r.Stream != nil && *r.Stream +} + +// MODEL TYPES +type OpenAIModel struct { + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + Created *int64 `json:"created,omitempty"` + + // GROQ specific fields + Active *bool `json:"active,omitempty"` + ContextWindow *int `json:"context_window,omitempty"` +} + +type OpenAIListModelsResponse struct { + Object string `json:"object"` + Data []OpenAIModel `json:"data"` +} diff --git a/core/providers/openrouter.go b/core/providers/openrouter.go new file mode 100644 index 000000000..614bc7de7 --- /dev/null +++ b/core/providers/openrouter.go @@ -0,0 +1,272 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the OpenRouter provider implementation. +package providers + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// OpenRouterProvider implements the Provider interface for OpenRouter's API. +type OpenRouterProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewOpenRouterProvider creates a new OpenRouter provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewOpenRouterProvider(config *schemas.ProviderConfig, logger schemas.Logger) *OpenRouterProvider { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://openrouter.ai/api" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &OpenRouterProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + } +} + +// GetProviderKey returns the provider identifier for OpenRouter. +func (provider *OpenRouterProvider) GetProviderKey() schemas.ModelProvider { + return schemas.OpenRouter +} + +// listModelsByKey performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +func (provider *OpenRouterProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + // Create request + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/v1/models")) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + if key.Value != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key.Value)) + } + + // Make request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + bifrostErr := openai.ParseOpenAIError(resp, schemas.ListModelsRequest, providerName, "") + return nil, bifrostErr + } + + // Copy response body before releasing + responseBody := append([]byte(nil), resp.Body()...) + + var openrouterResponse schemas.BifrostListModelsResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, &openrouterResponse, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + for i := range openrouterResponse.Data { + openrouterResponse.Data[i].ID = string(schemas.OpenRouter) + "/" + openrouterResponse.Data[i].ID + } + + openrouterResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + openrouterResponse.ExtraFields.RawResponse = rawResponse + } + + return &openrouterResponse, nil +} + +// ListModels performs a list models request to OpenRouter's API. +// Requests are made concurrently for improved performance. +func (provider *OpenRouterProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion performs a text completion request to the OpenRouter API. +func (provider *OpenRouterProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return openai.HandleOpenAITextCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletionStream performs a streaming text completion request to OpenRouter's API. +// It formats the request, sends it to OpenRouter, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *OpenRouterProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + return openai.HandleOpenAITextCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// ChatCompletion performs a chat completion request to the OpenRouter API. +func (provider *OpenRouterProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the OpenRouter API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses OpenRouter's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *OpenRouterProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.OpenRouter, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the OpenRouter API. +func (provider *OpenRouterProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + return openai.HandleOpenAIResponsesRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/responses"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ResponsesStream performs a streaming responses request to the OpenRouter API. +func (provider *OpenRouterProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + return openai.HandleOpenAIResponsesStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/responses"), + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Embedding is not supported by the OpenRouter provider. +func (provider *OpenRouterProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.EmbeddingRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the OpenRouter provider. +func (provider *OpenRouterProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the OpenRouter provider. +func (provider *OpenRouterProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the OpenRouter provider. +func (provider *OpenRouterProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the OpenRouter provider. +func (provider *OpenRouterProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/parasail.go b/core/providers/parasail.go new file mode 100644 index 000000000..7ea208172 --- /dev/null +++ b/core/providers/parasail.go @@ -0,0 +1,178 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the Parasail provider implementation. +package providers + +import ( + "context" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// ParasailProvider implements the Provider interface for Parasail's API. +type ParasailProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewParasailProvider creates a new Parasail provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewParasailProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*ParasailProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.parasail.io" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &ParasailProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Parasail. +func (provider *ParasailProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Parasail +} + +// ListModels performs a list models request to Parasail's API. +func (provider *ParasailProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return openai.HandleOpenAIListModelsRequest( + ctx, + provider.client, + request, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/models"), + keys, + provider.networkConfig.ExtraHeaders, + schemas.Parasail, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletion is not supported by the Parasail provider. +func (provider *ParasailProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream performs a streaming text completion request to Parasail's API. +// It formats the request, sends it to Parasail, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *ParasailProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to the Parasail API. +func (provider *ParasailProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the Parasail API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Parasail's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *ParasailProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/chat/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Parasail, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the Parasail API. +func (provider *ParasailProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Parasail API. +func (provider *ParasailProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding is not supported by the Parasail provider. +func (provider *ParasailProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.EmbeddingRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the Parasail provider. +func (provider *ParasailProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Parasail provider. +func (provider *ParasailProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Parasail provider. +func (provider *ParasailProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Parasail provider. +func (provider *ParasailProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/perplexity/chat.go b/core/providers/perplexity/chat.go new file mode 100644 index 000000000..30de31eb6 --- /dev/null +++ b/core/providers/perplexity/chat.go @@ -0,0 +1,237 @@ +package perplexity + +import ( + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// ToPerplexityChatCompletionRequest converts a Bifrost request to Perplexity chat completion request +func ToPerplexityChatCompletionRequest(bifrostReq *schemas.BifrostChatRequest) *PerplexityChatRequest { + if bifrostReq == nil || bifrostReq.Input == nil { + return nil + } + + messages := bifrostReq.Input + perplexityReq := &PerplexityChatRequest{ + Model: bifrostReq.Model, + Messages: messages, + } + + // Map parameters if they exist + if bifrostReq.Params != nil { + // Core parameters + perplexityReq.MaxTokens = bifrostReq.Params.MaxCompletionTokens + perplexityReq.Temperature = bifrostReq.Params.Temperature + perplexityReq.TopP = bifrostReq.Params.TopP + perplexityReq.PresencePenalty = bifrostReq.Params.PresencePenalty + perplexityReq.FrequencyPenalty = bifrostReq.Params.FrequencyPenalty + perplexityReq.ResponseFormat = bifrostReq.Params.ResponseFormat + + // Handle reasoning effort mapping + if bifrostReq.Params.ReasoningEffort != nil { + if *bifrostReq.Params.ReasoningEffort == "minimal" { + perplexityReq.ReasoningEffort = schemas.Ptr("low") + } else { + perplexityReq.ReasoningEffort = bifrostReq.Params.ReasoningEffort + } + } + + // Handle extra parameters for Perplexity-specific fields + if bifrostReq.Params.ExtraParams != nil { + // Search-related parameters + if searchMode, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_mode"]); ok { + perplexityReq.SearchMode = searchMode + } + + if languagePreference, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["language_preference"]); ok { + perplexityReq.LanguagePreference = languagePreference + } + + if searchDomainFilters, ok := schemas.SafeExtractStringSlice(bifrostReq.Params.ExtraParams["search_domain_filters"]); ok { + perplexityReq.SearchDomainFilters = searchDomainFilters + } + + if returnImages, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["return_images"]); ok { + perplexityReq.ReturnImages = returnImages + } + + if returnRelatedQuestions, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["return_related_questions"]); ok { + perplexityReq.ReturnRelatedQuestions = returnRelatedQuestions + } + + if searchRecencyFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_recency_filter"]); ok { + perplexityReq.SearchRecencyFilter = searchRecencyFilter + } + + if searchAfterDateFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_after_date_filter"]); ok { + perplexityReq.SearchAfterDateFilter = searchAfterDateFilter + } + + if searchBeforeDateFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_before_date_filter"]); ok { + perplexityReq.SearchBeforeDateFilter = searchBeforeDateFilter + } + + if lastUpdatedAfterFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["last_updated_after_filter"]); ok { + perplexityReq.LastUpdatedAfterFilter = lastUpdatedAfterFilter + } + + if lastUpdatedBeforeFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["last_updated_before_filter"]); ok { + perplexityReq.LastUpdatedBeforeFilter = lastUpdatedBeforeFilter + } + + if topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]); ok { + perplexityReq.TopK = topK + } + + if stream, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["stream"]); ok { + perplexityReq.Stream = stream + } + + if disableSearch, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["disable_search"]); ok { + perplexityReq.DisableSearch = disableSearch + } + + if enableSearchClassifier, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["enable_search_classifier"]); ok { + perplexityReq.EnableSearchClassifier = enableSearchClassifier + } + + // Handle web_search_options + if webSearchOptionsParam, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "web_search_options"); ok { + if webSearchOptionsSlice, ok := webSearchOptionsParam.([]interface{}); ok { + var webSearchOptions []WebSearchOption + for _, optionInterface := range webSearchOptionsSlice { + if optionMap, ok := optionInterface.(map[string]interface{}); ok { + option := WebSearchOption{} + + if searchContextSize, ok := schemas.SafeExtractStringPointer(optionMap["search_context_size"]); ok { + option.SearchContextSize = searchContextSize + } + + if imageSearchRelevanceEnhanced, ok := schemas.SafeExtractBoolPointer(optionMap["image_search_relevance_enhanced"]); ok { + option.ImageSearchRelevanceEnhanced = imageSearchRelevanceEnhanced + } + + // Handle user_location + if userLocationParam, ok := schemas.SafeExtractFromMap(optionMap, "user_location"); ok { + if userLocationMap, ok := userLocationParam.(map[string]interface{}); ok { + userLocation := &WebSearchOptionUserLocation{} + + if latitude, ok := schemas.SafeExtractFloat64Pointer(userLocationMap["latitude"]); ok { + userLocation.Latitude = latitude + } + if longitude, ok := schemas.SafeExtractFloat64Pointer(userLocationMap["longitude"]); ok { + userLocation.Longitude = longitude + } + if city, ok := schemas.SafeExtractStringPointer(userLocationMap["city"]); ok { + userLocation.City = city + } + if country, ok := schemas.SafeExtractStringPointer(userLocationMap["country"]); ok { + userLocation.Country = country + } + if region, ok := schemas.SafeExtractStringPointer(userLocationMap["region"]); ok { + userLocation.Region = region + } + + option.UserLocation = userLocation + } + } + + webSearchOptions = append(webSearchOptions, option) + } + } + perplexityReq.WebSearchOptions = webSearchOptions + } + } + + // Handle media_response + if mediaResponseParam, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "media_response"); ok { + if mediaResponseMap, ok := mediaResponseParam.(map[string]interface{}); ok { + mediaResponse := &MediaResponse{} + + if overridesParam, ok := schemas.SafeExtractFromMap(mediaResponseMap, "overrides"); ok { + if overridesMap, ok := overridesParam.(map[string]interface{}); ok { + overrides := MediaResponseOverrides{} + + if returnVideos, ok := schemas.SafeExtractBoolPointer(overridesMap["return_videos"]); ok { + overrides.ReturnVideos = returnVideos + } + if returnImages, ok := schemas.SafeExtractBoolPointer(overridesMap["return_images"]); ok { + overrides.ReturnImages = returnImages + } + + mediaResponse.Overrides = overrides + } + } + + perplexityReq.MediaResponse = mediaResponse + } + } + } + } + + return perplexityReq +} + +// ToBifrostChatResponse converts a Perplexity chat completion response to Bifrost format +func (response *PerplexityChatResponse) ToBifrostChatResponse(model string) *schemas.BifrostChatResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostChatResponse{ + ID: response.ID, + Model: model, + Object: response.Object, + Created: response.Created, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: schemas.Perplexity, + }, + SearchResults: response.SearchResults, + Videos: response.Videos, + } + + // Map all response fields + if len(response.Choices) > 0 { + bifrostResponse.Choices = response.Choices + } + + // Convert usage information with all available fields + if response.Usage != nil { + usage := &schemas.BifrostLLMUsage{ + PromptTokens: response.Usage.PromptTokens, + CompletionTokens: response.Usage.CompletionTokens, + TotalTokens: response.Usage.TotalTokens, + } + + // Map Perplexity-specific usage details to CompletionTokensDetails + completionDetails := &schemas.ChatCompletionTokensDetails{} + hasCompletionDetails := false + + if response.Usage.CitationTokens != nil { + completionDetails.CitationTokens = response.Usage.CitationTokens + hasCompletionDetails = true + } + + if response.Usage.NumSearchQueries != nil { + completionDetails.NumSearchQueries = response.Usage.NumSearchQueries + hasCompletionDetails = true + } + + if response.Usage.ReasoningTokens != nil { + completionDetails.ReasoningTokens = *response.Usage.ReasoningTokens + hasCompletionDetails = true + } + + if hasCompletionDetails { + usage.CompletionTokensDetails = completionDetails + } + + if response.Usage.Cost != nil { + usage.Cost = response.Usage.Cost + } + + bifrostResponse.Usage = usage + } + + return bifrostResponse +} diff --git a/core/providers/perplexity/perplexity.go b/core/providers/perplexity/perplexity.go new file mode 100644 index 000000000..1b26bca40 --- /dev/null +++ b/core/providers/perplexity/perplexity.go @@ -0,0 +1,245 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the Perplexity provider implementation. +package perplexity + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// PerplexityProvider implements the Provider interface for Perplexity's API. +type PerplexityProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewPerplexityProvider creates a new Perplexity provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewPerplexityProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*PerplexityProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + // Set default BaseURL if not provided + if config.NetworkConfig.BaseURL == "" { + config.NetworkConfig.BaseURL = "https://api.perplexity.ai" + } + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + return &PerplexityProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Perplexity. +func (provider *PerplexityProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Perplexity +} + +// completeRequest sends a request to Perplexity's API and handles the response. +// It constructs the API URL, sets up authentication, and processes the response. +// Returns the response body or an error if the request fails. +func (provider *PerplexityProvider) completeRequest(ctx context.Context, jsonData []byte, url string, key string, model string) ([]byte, time.Duration, *schemas.BifrostError) { + // Create the request with the JSON body + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + req.SetRequestURI(url) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + + req.SetBody(jsonData) + + // Send the request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + return nil, latency, bifrostErr + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + provider.logger.Debug(fmt.Sprintf("error from %s provider: %s", provider.GetProviderKey(), string(resp.Body()))) + return nil, latency, openai.ParseOpenAIError(resp, schemas.ChatCompletionRequest, provider.GetProviderKey(), model) + } + + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, latency, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err, provider.GetProviderKey()) + } + + // Read the response body and copy it before releasing the response + // to avoid use-after-free since resp.Body() references fasthttp's internal buffer + bodyCopy := append([]byte(nil), body...) + + return bodyCopy, latency, nil +} + +// ListModels performs a list models request to Perplexity's API. +func (provider *PerplexityProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ListModelsRequest, provider.GetProviderKey()) +} + +// TextCompletion is not supported by the Perplexity provider. +func (provider *PerplexityProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream performs a streaming text completion request to Perplexity's API. +// It formats the request, sends it to Perplexity, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *PerplexityProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to the Perplexity API. +func (provider *PerplexityProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + // Convert to Perplexity chat completion request + jsonBody, err := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToPerplexityChatCompletionRequest(request), nil }, + provider.GetProviderKey()) + if err != nil { + return nil, err + } + + responseBody, latency, err := provider.completeRequest(ctx, jsonBody, provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/chat/completions"), key.Value, request.Model) + if err != nil { + return nil, err + } + + var response PerplexityChatResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(responseBody, &response, providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse)) + if bifrostErr != nil { + return nil, bifrostErr + } + + bifrostResponse := response.ToBifrostChatResponse(request.Model) + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = provider.GetProviderKey() + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.ChatCompletionRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + bifrostResponse.ExtraFields.RawResponse = rawResponse + } + + return bifrostResponse, nil +} + +// ChatCompletionStream performs a streaming chat completion request to the Perplexity API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses Perplexity's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *PerplexityProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var authHeader map[string]string + if key.Value != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value} + } + customRequestConverter := func(request *schemas.BifrostChatRequest) (any, error) { + reqBody := ToPerplexityChatCompletionRequest(request) + reqBody.Stream = schemas.Ptr(true) + return reqBody, nil + } + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/chat/completions", + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Perplexity, + postHookRunner, + customRequestConverter, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the Perplexity API. +func (provider *PerplexityProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Perplexity API. +func (provider *PerplexityProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding is not supported by the Perplexity provider. +func (provider *PerplexityProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.EmbeddingRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the Perplexity provider. +func (provider *PerplexityProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Perplexity provider. +func (provider *PerplexityProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Perplexity provider. +func (provider *PerplexityProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Perplexity provider. +func (provider *PerplexityProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/perplexity/responses.go b/core/providers/perplexity/responses.go new file mode 100644 index 000000000..01dccbccc --- /dev/null +++ b/core/providers/perplexity/responses.go @@ -0,0 +1,184 @@ +package perplexity + +import ( + "github.com/maximhq/bifrost/core/schemas" +) + +// ToPerplexityResponsesRequest converts a BifrostResponsesRequest to PerplexityChatRequest +func ToPerplexityResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *PerplexityChatRequest { + if bifrostReq == nil { + return nil + } + + perplexityReq := &PerplexityChatRequest{ + Model: bifrostReq.Model, + } + + // Map basic parameters + if bifrostReq.Params != nil { + // Core parameters + perplexityReq.MaxTokens = bifrostReq.Params.MaxOutputTokens + perplexityReq.Temperature = bifrostReq.Params.Temperature + perplexityReq.TopP = bifrostReq.Params.TopP + + // Handle reasoning effort mapping + if bifrostReq.Params.Reasoning != nil && bifrostReq.Params.Reasoning.Effort != nil { + if *bifrostReq.Params.Reasoning.Effort == "minimal" { + perplexityReq.ReasoningEffort = schemas.Ptr("low") + } else { + perplexityReq.ReasoningEffort = schemas.Ptr(*bifrostReq.Params.Reasoning.Effort) + } + } + + // Handle extra parameters for Perplexity-specific fields + if bifrostReq.Params.ExtraParams != nil { + // Search-related parameters + if searchMode, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_mode"]); ok { + perplexityReq.SearchMode = searchMode + } + + if languagePreference, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["language_preference"]); ok { + perplexityReq.LanguagePreference = languagePreference + } + + if searchDomainFilters, ok := schemas.SafeExtractStringSlice(bifrostReq.Params.ExtraParams["search_domain_filters"]); ok { + perplexityReq.SearchDomainFilters = searchDomainFilters + } + + if returnImages, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["return_images"]); ok { + perplexityReq.ReturnImages = returnImages + } + + if returnRelatedQuestions, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["return_related_questions"]); ok { + perplexityReq.ReturnRelatedQuestions = returnRelatedQuestions + } + + if searchRecencyFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_recency_filter"]); ok { + perplexityReq.SearchRecencyFilter = searchRecencyFilter + } + + if searchAfterDateFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_after_date_filter"]); ok { + perplexityReq.SearchAfterDateFilter = searchAfterDateFilter + } + + if searchBeforeDateFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["search_before_date_filter"]); ok { + perplexityReq.SearchBeforeDateFilter = searchBeforeDateFilter + } + + if lastUpdatedAfterFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["last_updated_after_filter"]); ok { + perplexityReq.LastUpdatedAfterFilter = lastUpdatedAfterFilter + } + + if lastUpdatedBeforeFilter, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["last_updated_before_filter"]); ok { + perplexityReq.LastUpdatedBeforeFilter = lastUpdatedBeforeFilter + } + + if topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]); ok { + perplexityReq.TopK = topK + } + + if stream, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["stream"]); ok { + perplexityReq.Stream = stream + } + + if disableSearch, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["disable_search"]); ok { + perplexityReq.DisableSearch = disableSearch + } + + if enableSearchClassifier, ok := schemas.SafeExtractBoolPointer(bifrostReq.Params.ExtraParams["enable_search_classifier"]); ok { + perplexityReq.EnableSearchClassifier = enableSearchClassifier + } + + if presencePenalty, ok := schemas.SafeExtractFloat64Pointer(bifrostReq.Params.ExtraParams["presence_penalty"]); ok { + perplexityReq.PresencePenalty = presencePenalty + } + + if frequencyPenalty, ok := schemas.SafeExtractFloat64Pointer(bifrostReq.Params.ExtraParams["frequency_penalty"]); ok { + perplexityReq.FrequencyPenalty = frequencyPenalty + } + + if responseFormat, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "response_format"); ok { + perplexityReq.ResponseFormat = &responseFormat + } + + // Handle web_search_options + if webSearchOptionsParam, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "web_search_options"); ok { + if webSearchOptionsSlice, ok := webSearchOptionsParam.([]interface{}); ok { + var webSearchOptions []WebSearchOption + for _, optionInterface := range webSearchOptionsSlice { + if optionMap, ok := optionInterface.(map[string]interface{}); ok { + option := WebSearchOption{} + + if searchContextSize, ok := schemas.SafeExtractStringPointer(optionMap["search_context_size"]); ok { + option.SearchContextSize = searchContextSize + } + + if imageSearchRelevanceEnhanced, ok := schemas.SafeExtractBoolPointer(optionMap["image_search_relevance_enhanced"]); ok { + option.ImageSearchRelevanceEnhanced = imageSearchRelevanceEnhanced + } + + // Handle user_location + if userLocationParam, ok := schemas.SafeExtractFromMap(optionMap, "user_location"); ok { + if userLocationMap, ok := userLocationParam.(map[string]interface{}); ok { + userLocation := &WebSearchOptionUserLocation{} + + if latitude, ok := schemas.SafeExtractFloat64Pointer(userLocationMap["latitude"]); ok { + userLocation.Latitude = latitude + } + if longitude, ok := schemas.SafeExtractFloat64Pointer(userLocationMap["longitude"]); ok { + userLocation.Longitude = longitude + } + if city, ok := schemas.SafeExtractStringPointer(userLocationMap["city"]); ok { + userLocation.City = city + } + if country, ok := schemas.SafeExtractStringPointer(userLocationMap["country"]); ok { + userLocation.Country = country + } + if region, ok := schemas.SafeExtractStringPointer(userLocationMap["region"]); ok { + userLocation.Region = region + } + + option.UserLocation = userLocation + } + } + + webSearchOptions = append(webSearchOptions, option) + } + } + perplexityReq.WebSearchOptions = webSearchOptions + } + } + + // Handle media_response + if mediaResponseParam, ok := schemas.SafeExtractFromMap(bifrostReq.Params.ExtraParams, "media_response"); ok { + if mediaResponseMap, ok := mediaResponseParam.(map[string]interface{}); ok { + mediaResponse := &MediaResponse{} + + if overridesParam, ok := schemas.SafeExtractFromMap(mediaResponseMap, "overrides"); ok { + if overridesMap, ok := overridesParam.(map[string]interface{}); ok { + overrides := MediaResponseOverrides{} + + if returnVideos, ok := schemas.SafeExtractBoolPointer(overridesMap["return_videos"]); ok { + overrides.ReturnVideos = returnVideos + } + if returnImages, ok := schemas.SafeExtractBoolPointer(overridesMap["return_images"]); ok { + overrides.ReturnImages = returnImages + } + + mediaResponse.Overrides = overrides + } + } + + perplexityReq.MediaResponse = mediaResponse + } + } + } + } + + // Process ResponsesInput (which contains the Responses messages) + if bifrostReq.Input != nil { + perplexityReq.Messages = schemas.ToChatMessages(bifrostReq.Input) + } + + return perplexityReq +} diff --git a/core/providers/perplexity/types.go b/core/providers/perplexity/types.go new file mode 100644 index 000000000..7e5601958 --- /dev/null +++ b/core/providers/perplexity/types.go @@ -0,0 +1,78 @@ +package perplexity + +import "github.com/maximhq/bifrost/core/schemas" + +// PerplexityChatRequest represents a Perplexity chat completion request +type PerplexityChatRequest struct { + Model string `json:"model"` // Required: Model to use for chat completion + Messages []schemas.ChatMessage `json:"messages"` // Required: Array of message objects + SearchMode *string `json:"search_mode"` // Required: Search mode + ReasoningEffort *string `json:"reasoning_effort"` // Required: Reasoning effort (low, medium, high) + MaxTokens *int `json:"max_tokens,omitempty"` // Optional: Maximum tokens to generate + Temperature *float64 `json:"temperature,omitempty"` // Optional: Sampling temperature + TopP *float64 `json:"top_p,omitempty"` // Optional: Top-p sampling + LanguagePreference *string `json:"language_preference,omitempty"` // Optional: Language preference + SearchDomainFilters []string `json:"search_domain_filters,omitempty"` // Optional: Search domain filters + ReturnImages *bool `json:"return_images,omitempty"` // Optional: Return images + ReturnRelatedQuestions *bool `json:"return_related_questions,omitempty"` // Optional: Return related questions + SearchRecencyFilter *string `json:"search_recency_filter,omitempty"` // Optional: Search recency filter + SearchAfterDateFilter *string `json:"search_after_date_filter,omitempty"` // Optional: Search after date filter + SearchBeforeDateFilter *string `json:"search_before_date_filter,omitempty"` // Optional: Search before date filter + LastUpdatedAfterFilter *string `json:"last_updated_after_filter,omitempty"` // Optional: Last updated after filter + LastUpdatedBeforeFilter *string `json:"last_updated_before_filter,omitempty"` // Optional: Last updated before filter + TopK *int `json:"top_k,omitempty"` // Optional: Top-k sampling + Stream *bool `json:"stream,omitempty"` // Optional: Enable streaming + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // Optional: Presence penalty + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // Optional: Frequency penalty + ResponseFormat *interface{} `json:"response_format,omitempty"` // Format for the response + DisableSearch *bool `json:"disable_search,omitempty"` // Optional: Disable search + EnableSearchClassifier *bool `json:"enable_search_classifier,omitempty"` // Optional: Enable search classifier + WebSearchOptions []WebSearchOption `json:"web_search_options,omitempty"` // Optional: Web search options + MediaResponse *MediaResponse `json:"media_response,omitempty"` // Optional: Media response +} + +type WebSearchOption struct { + SearchContextSize *string `json:"search_context_size,omitempty"` // "low" | "medium" | "high" + UserLocation *WebSearchOptionUserLocation `json:"user_location,omitempty"` // The approximate location of the user + ImageSearchRelevanceEnhanced *bool `json:"image_search_relevance_enhanced,omitempty"` // Optional: Image search relevance enhanced +} + +type WebSearchOptionUserLocation struct { + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + City *string `json:"city,omitempty"` // Free text input for the city + Country *string `json:"country,omitempty"` // Two-letter ISO country code + Region *string `json:"region,omitempty"` // Free text input for the region +} + +type MediaResponse struct { + Overrides MediaResponseOverrides `json:"overrides,omitempty"` // Optional: Overrides for the media response +} + +type MediaResponseOverrides struct { + ReturnVideos *bool `json:"return_videos,omitempty"` // Optional: Return videos + ReturnImages *bool `json:"return_images,omitempty"` // Optional: Return images +} + +type PerplexityChatResponse struct { + ID string `json:"id"` + Choices []schemas.BifrostResponseChoice `json:"choices"` + Created int `json:"created"` // The Unix timestamp (in seconds). + Model string `json:"model"` + Object string `json:"object"` // "chat.completion" or "chat.completion.chunk" + Citations []string `json:"citations,omitempty"` + SearchResults []schemas.SearchResult `json:"search_results,omitempty"` + Videos []schemas.VideoResult `json:"videos,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + SearchContextSize *string `json:"search_context_size,omitempty"` + CitationTokens *int `json:"citation_tokens,omitempty"` + NumSearchQueries *int `json:"num_search_queries,omitempty"` + ReasoningTokens *int `json:"reasoning_tokens,omitempty"` + Cost *schemas.BifrostCost `json:"cost,omitempty"` +} diff --git a/core/providers/sgl.go b/core/providers/sgl.go new file mode 100644 index 000000000..1c3597c4c --- /dev/null +++ b/core/providers/sgl.go @@ -0,0 +1,214 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains the SGL provider implementation. +package providers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// SGLProvider implements the Provider interface for SGL's API. +type SGLProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewSGLProvider creates a new SGL provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewSGLProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*SGLProvider, error) { + config.CheckAndSetDefaults() + + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + + // Pre-warm response pools + // for range config.ConcurrencyAndBufferSize.Concurrency { + // sglResponsePool.Put(&schemas.BifrostResponse{}) + // } + + // Configure proxy if provided + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + + config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") + + // BaseURL is required for SGLang + if config.NetworkConfig.BaseURL == "" { + return nil, fmt.Errorf("base_url is required for sgl provider") + } + + return &SGLProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for SGL. +func (provider *SGLProvider) GetProviderKey() schemas.ModelProvider { + return schemas.SGL +} + +// ListModels performs a list models request to SGL's API. +func (provider *SGLProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return openai.HandleOpenAIListModelsRequest( + ctx, + provider.client, + request, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/models"), + keys, + provider.networkConfig.ExtraHeaders, + schemas.SGL, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletion is not supported by the SGL provider. +func (provider *SGLProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return openai.HandleOpenAITextCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// TextCompletionStream performs a streaming text completion request to SGL's API. +// It formats the request, sends it to SGL, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *SGLProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return openai.HandleOpenAITextCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/completions", + request, + nil, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// ChatCompletion performs a chat completion request to the SGL API. +func (provider *SGLProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to the SGL API. +// It supports real-time streaming of responses using Server-Sent Events (SSE). +// Uses SGL's OpenAI-compatible streaming format. +// Returns a channel containing BifrostResponse objects representing the stream or an error if the request fails. +func (provider *SGLProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + // Use shared OpenAI-compatible streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + provider.networkConfig.BaseURL+"/v1/chat/completions", + request, + nil, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.SGL, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) +} + +// Responses performs a responses request to the SGL API. +func (provider *SGLProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the SGL API. +func (provider *SGLProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding is not supported by the SGL provider. +func (provider *SGLProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return openai.HandleOpenAIEmbeddingRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/embeddings"), + request, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.logger, + ) +} + +// Speech is not supported by the SGL provider. +func (provider *SGLProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the SGL provider. +func (provider *SGLProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the SGL provider. +func (provider *SGLProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the SGL provider. +func (provider *SGLProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/utils.go b/core/providers/utils.go deleted file mode 100644 index 2988fac35..000000000 --- a/core/providers/utils.go +++ /dev/null @@ -1,260 +0,0 @@ -// Package providers implements various LLM providers and their utility functions. -// This file contains common utility functions used across different provider implementations. -package providers - -import ( - "fmt" - "net/url" - "reflect" - "strings" - "sync" - - "github.com/goccy/go-json" - schemas "github.com/maximhq/bifrost/core/schemas" - "github.com/valyala/fasthttp" - "github.com/valyala/fasthttp/fasthttpproxy" - - "maps" -) - -// bifrostResponsePool provides a pool for Bifrost response objects. -var bifrostResponsePool = sync.Pool{ - New: func() interface{} { - return &schemas.BifrostResponse{} - }, -} - -// acquireBifrostResponse gets a Bifrost response from the pool and resets it. -func acquireBifrostResponse() *schemas.BifrostResponse { - resp := bifrostResponsePool.Get().(*schemas.BifrostResponse) - *resp = schemas.BifrostResponse{} // Reset the struct - return resp -} - -// releaseBifrostResponse returns a Bifrost response to the pool. -func releaseBifrostResponse(resp *schemas.BifrostResponse) { - if resp != nil { - bifrostResponsePool.Put(resp) - } -} - -// mergeConfig merges a default configuration map with custom parameters. -// It creates a new map containing all default values, then overrides them with any custom values. -// Returns a new map containing the merged configuration. -func mergeConfig(defaultConfig map[string]interface{}, customParams map[string]interface{}) map[string]interface{} { - merged := make(map[string]interface{}) - - // Copy default config - for k, v := range defaultConfig { - merged[k] = v - } - - // Override with custom parameters - for k, v := range customParams { - merged[k] = v - } - - return merged -} - -// prepareParams converts ModelParameters into a flat map of parameters. -// It handles both standard fields and extra parameters, using reflection to process -// the struct fields and their JSON tags. -// Returns a map containing all parameters ready for use in API requests. -func prepareParams(params *schemas.ModelParameters) map[string]interface{} { - flatParams := make(map[string]interface{}) - - // Return empty map if params is nil - if params == nil { - return flatParams - } - - // Use reflection to get the type and value of params - val := reflect.ValueOf(params).Elem() - typ := val.Type() - - // Iterate through all fields - for i := range val.NumField() { - field := val.Field(i) - fieldType := typ.Field(i) - - // Skip the ExtraParams field as it's handled separately - if fieldType.Name == "ExtraParams" { - continue - } - - // Get the JSON tag name - jsonTag := fieldType.Tag.Get("json") - if jsonTag == "" || jsonTag == "-" { - continue - } - - // Strip out ,omitempty and others from the tag - jsonTag = strings.Split(jsonTag, ",")[0] - - // Handle pointer fields - if field.Kind() == reflect.Ptr && !field.IsNil() { - flatParams[jsonTag] = field.Elem().Interface() - } - } - - // Handle ExtraParams - maps.Copy(flatParams, params.ExtraParams) - - return flatParams -} - -// configureProxy sets up a proxy for the fasthttp client based on the provided configuration. -// It supports HTTP, SOCKS5, and environment-based proxy configurations. -// Returns the configured client or the original client if proxy configuration is invalid. -func configureProxy(client *fasthttp.Client, proxyConfig *schemas.ProxyConfig, logger schemas.Logger) *fasthttp.Client { - if proxyConfig == nil { - return client - } - - var dialFunc fasthttp.DialFunc - - // Create the appropriate proxy based on type - switch proxyConfig.Type { - case schemas.NoProxy: - return client - case schemas.HttpProxy: - if proxyConfig.URL == "" { - logger.Warn("Warning: HTTP proxy URL is required for setting up proxy") - return client - } - dialFunc = fasthttpproxy.FasthttpHTTPDialer(proxyConfig.URL) - case schemas.Socks5Proxy: - if proxyConfig.URL == "" { - logger.Warn("Warning: SOCKS5 proxy URL is required for setting up proxy") - return client - } - proxyUrl := proxyConfig.URL - // Add authentication if provided - if proxyConfig.Username != "" && proxyConfig.Password != "" { - parsedURL, err := url.Parse(proxyConfig.URL) - if err != nil { - logger.Warn("Invalid proxy configuration: invalid SOCKS5 proxy URL") - return client - } - // Set user and password in the parsed URL - parsedURL.User = url.UserPassword(proxyConfig.Username, proxyConfig.Password) - proxyUrl = parsedURL.String() - } - dialFunc = fasthttpproxy.FasthttpSocksDialer(proxyUrl) - case schemas.EnvProxy: - // Use environment variables for proxy configuration - dialFunc = fasthttpproxy.FasthttpProxyHTTPDialer() - default: - logger.Warn(fmt.Sprintf("Invalid proxy configuration: unsupported proxy type: %s", proxyConfig.Type)) - return client - } - - if dialFunc != nil { - client.Dial = dialFunc - } - - return client -} - -// handleProviderAPIError processes error responses from provider APIs. -// It attempts to unmarshal the error response and returns a BifrostError -// with the appropriate status code and error information. -func handleProviderAPIError(resp *fasthttp.Response, errorResp any) *schemas.BifrostError { - if err := json.Unmarshal(resp.Body(), &errorResp); err != nil { - return &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderResponseUnmarshal, - Error: err, - }, - } - } - - statusCode := resp.StatusCode() - - return &schemas.BifrostError{ - IsBifrostError: false, - StatusCode: &statusCode, - Error: schemas.ErrorField{}, - } -} - -// handleProviderResponse handles common response parsing logic for provider responses. -// It attempts to parse the response body into the provided response type -// and returns either the parsed response or a BifrostError if parsing fails. -func handleProviderResponse[T any](responseBody []byte, response *T) (interface{}, *schemas.BifrostError) { - var rawResponse interface{} - - var wg sync.WaitGroup - var structuredErr, rawErr error - - wg.Add(2) - go func() { - defer wg.Done() - structuredErr = json.Unmarshal(responseBody, response) - }() - go func() { - defer wg.Done() - rawErr = json.Unmarshal(responseBody, &rawResponse) - }() - wg.Wait() - - if structuredErr != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderDecodeStructured, - Error: structuredErr, - }, - } - } - - if rawErr != nil { - return nil, &schemas.BifrostError{ - IsBifrostError: true, - Error: schemas.ErrorField{ - Message: schemas.ErrProviderDecodeRaw, - Error: rawErr, - }, - } - } - - return rawResponse, nil -} - -// float64Ptr creates a pointer to a float64 value. -// This is a helper function for creating pointers to float64 values. -func float64Ptr(f float64) *float64 { - return &f -} - -func setConfigDefaults(config *schemas.ProviderConfig) { - if config.ConcurrencyAndBufferSize.Concurrency == 0 { - config.ConcurrencyAndBufferSize.Concurrency = schemas.DefaultConcurrency - } - - if config.ConcurrencyAndBufferSize.BufferSize == 0 { - config.ConcurrencyAndBufferSize.BufferSize = schemas.DefaultBufferSize - } - - if config.NetworkConfig.DefaultRequestTimeoutInSeconds == 0 { - config.NetworkConfig.DefaultRequestTimeoutInSeconds = schemas.DefaultRequestTimeoutInSeconds - } - - if config.NetworkConfig.MaxRetries == 0 { - config.NetworkConfig.MaxRetries = schemas.DefaultMaxRetries - } - - if config.NetworkConfig.RetryBackoffInitial == 0 { - config.NetworkConfig.RetryBackoffInitial = schemas.DefaultRetryBackoffInitial - } - - if config.NetworkConfig.RetryBackoffMax == 0 { - config.NetworkConfig.RetryBackoffMax = schemas.DefaultRetryBackoffMax - } -} - -func StrPtr(s string) *string { - return &s -} diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go new file mode 100644 index 000000000..0ad8476c0 --- /dev/null +++ b/core/providers/utils/utils.go @@ -0,0 +1,950 @@ +// Package providers implements various LLM providers and their utility functions. +// This file contains common utility functions used across different provider implementations. +package utils + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/textproto" + "net/url" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/bytedance/sonic" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttpproxy" +) + +var logger schemas.Logger + +func SetLogger(l schemas.Logger) { + logger = l +} + +// MakeRequestWithContext makes a request with a context and returns the latency and error. +// IMPORTANT: This function does NOT truly cancel the underlying fasthttp network request if the +// context is done. The fasthttp client call will continue in its goroutine until it completes +// or times out based on its own settings. This function merely stops *waiting* for the +// fasthttp call and returns an error related to the context. +// Returns the request latency and any error that occurred. +func MakeRequestWithContext(ctx context.Context, client *fasthttp.Client, req *fasthttp.Request, resp *fasthttp.Response) (time.Duration, *schemas.BifrostError) { + startTime := time.Now() + errChan := make(chan error, 1) + + go func() { + // client.Do is a blocking call. + // It will send an error (or nil for success) to errChan when it completes. + errChan <- client.Do(req, resp) + }() + + select { + case <-ctx.Done(): + // Context was cancelled (e.g., deadline exceeded or manual cancellation). + // Calculate latency even for cancelled requests + latency := time.Since(startTime) + return latency, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: fmt.Sprintf("Request cancelled or timed out by context: %v", ctx.Err()), + Error: ctx.Err(), + }, + } + case err := <-errChan: + // The fasthttp.Do call completed. + // Calculate latency for both successful and failed requests + latency := time.Since(startTime) + if err != nil { + if errors.Is(err, context.Canceled) { + return latency, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return latency, NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, "") + } + // The HTTP request itself failed (e.g., connection error, fasthttp timeout). + return latency, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderDoRequest, + Error: err, + }, + } + } + // HTTP request was successful from fasthttp's perspective (err is nil). + // The caller should check resp.StatusCode() for HTTP-level errors (4xx, 5xx). + return latency, nil + } +} + +// ConfigureProxy sets up a proxy for the fasthttp client based on the provided configuration. +// It supports HTTP, SOCKS5, and environment-based proxy configurations. +// Returns the configured client or the original client if proxy configuration is invalid. +func ConfigureProxy(client *fasthttp.Client, proxyConfig *schemas.ProxyConfig, logger schemas.Logger) *fasthttp.Client { + if proxyConfig == nil { + return client + } + + var dialFunc fasthttp.DialFunc + + // Create the appropriate proxy based on type + switch proxyConfig.Type { + case schemas.NoProxy: + return client + case schemas.HTTPProxy: + if proxyConfig.URL == "" { + logger.Warn("Warning: HTTP proxy URL is required for setting up proxy") + return client + } + dialFunc = fasthttpproxy.FasthttpHTTPDialer(proxyConfig.URL) + case schemas.Socks5Proxy: + if proxyConfig.URL == "" { + logger.Warn("Warning: SOCKS5 proxy URL is required for setting up proxy") + return client + } + proxyURL := proxyConfig.URL + // Add authentication if provided + if proxyConfig.Username != "" && proxyConfig.Password != "" { + parsedURL, err := url.Parse(proxyConfig.URL) + if err != nil { + logger.Warn("Invalid proxy configuration: invalid SOCKS5 proxy URL") + return client + } + // Set user and password in the parsed URL + parsedURL.User = url.UserPassword(proxyConfig.Username, proxyConfig.Password) + proxyURL = parsedURL.String() + } + dialFunc = fasthttpproxy.FasthttpSocksDialer(proxyURL) + case schemas.EnvProxy: + // Use environment variables for proxy configuration + dialFunc = fasthttpproxy.FasthttpProxyHTTPDialer() + default: + logger.Warn(fmt.Sprintf("Invalid proxy configuration: unsupported proxy type: %s", proxyConfig.Type)) + return client + } + + if dialFunc != nil { + client.Dial = dialFunc + } + + return client +} + +// hopByHopHeaders are HTTP/1.1 headers that must not be forwarded by proxies. +var hopByHopHeaders = map[string]bool{ + "connection": true, + "proxy-connection": true, + "keep-alive": true, + "proxy-authenticate": true, + "proxy-authorization": true, + "te": true, + "trailer": true, + "transfer-encoding": true, + "upgrade": true, +} + +// filterHeaders filters out hop-by-hop headers and returns only the allowed headers. +func filterHeaders(headers map[string][]string) map[string][]string { + filtered := make(map[string][]string, len(headers)) + for k, v := range headers { + if !hopByHopHeaders[strings.ToLower(k)] { + filtered[k] = v + } + } + return filtered +} + +// SetExtraHeaders sets additional headers from NetworkConfig to the fasthttp request. +// This allows users to configure custom headers for their provider requests. +// Header keys are canonicalized using textproto.CanonicalMIMEHeaderKey to avoid duplicates. +// The Authorization header is excluded for security reasons. +// It accepts a list of headers (all canonicalized) to skip for security reasons. +// Headers are only set if they don't already exist on the request to avoid overwriting important headers. +func SetExtraHeaders(ctx context.Context, req *fasthttp.Request, extraHeaders map[string]string, skipHeaders []string) { + for key, value := range extraHeaders { + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + // Skip Authorization header for security reasons + if key == "Authorization" { + continue + } + if skipHeaders != nil { + if slices.Contains(skipHeaders, key) { + continue + } + } + // Only set the header if it doesn't already exist to avoid overwriting important headers + if len(req.Header.Peek(canonicalKey)) == 0 { + req.Header.Set(canonicalKey, value) + } + } + + // Give priority to extra headers in the context + if extraHeaders, ok := (ctx).Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string); ok { + for k, values := range filterHeaders(extraHeaders) { + for i, v := range values { + if i == 0 { + req.Header.Set(k, v) + } else { + req.Header.Add(k, v) + } + } + } + } +} + +// GetPathFromContext gets the path from the context, if it exists, otherwise returns the default path. +func GetPathFromContext(ctx context.Context, defaultPath string) string { + if pathInContext, ok := ctx.Value(schemas.BifrostContextKeyURLPath).(string); ok { + return pathInContext + } + return defaultPath +} + +// GetRequestPath gets the request path from the context, if it exists, checking for path overrides in the custom provider config. +func GetRequestPath(ctx context.Context, defaultPath string, customProviderConfig *schemas.CustomProviderConfig, requestType schemas.RequestType) string { + // If path set in context, return it + if pathInContext, ok := ctx.Value(schemas.BifrostContextKeyURLPath).(string); ok { + return pathInContext + } + // If path override set in custom provider config, return it + if customProviderConfig != nil && customProviderConfig.RequestPathOverrides != nil { + if raw, ok := customProviderConfig.RequestPathOverrides[requestType]; ok { + pathOverride := strings.TrimSpace(raw) + if pathOverride == "" { + return defaultPath + } + if !strings.HasPrefix(pathOverride, "/") { + pathOverride = "/" + pathOverride + } + return pathOverride + } + } + // Return default path + return defaultPath +} + +type RequestBodyGetter interface { + GetRawRequestBody() []byte +} + +// CheckAndGetRawRequestBody checks if the raw request body should be used, and returns it if it exists. +func CheckAndGetRawRequestBody(ctx context.Context, request RequestBodyGetter) ([]byte, bool) { + if rawBody, ok := ctx.Value(schemas.BifrostContextKeyUseRawRequestBody).(bool); ok && rawBody { + return request.GetRawRequestBody(), true + } + return nil, false +} + +type RequestBodyConverter func() (any, error) + +// CheckContextAndGetRequestBody checks if the raw request body should be used, and returns it if it exists. +func CheckContextAndGetRequestBody(ctx context.Context, request RequestBodyGetter, requestConverter RequestBodyConverter, providerType schemas.ModelProvider) ([]byte, *schemas.BifrostError) { + rawBody, ok := CheckAndGetRawRequestBody(ctx, request) + if !ok { + convertedBody, err := requestConverter() + if err != nil { + return nil, NewBifrostOperationError(schemas.ErrRequestBodyConversion, err, providerType) + } + if convertedBody == nil { + return nil, NewBifrostOperationError("request body is not provided", nil, providerType) + } + jsonBody, err := sonic.Marshal(convertedBody) + if err != nil { + return nil, NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerType) + } + return jsonBody, nil + } else { + return rawBody, nil + } +} + +// SetExtraHeadersHTTP sets additional headers from NetworkConfig to the standard HTTP request. +// This allows users to configure custom headers for their provider requests. +// Header keys are canonicalized using textproto.CanonicalMIMEHeaderKey to avoid duplicates. +// It accepts a list of headers (all canonicalized) to skip for security reasons. +// Headers are only set if they don't already exist on the request to avoid overwriting important headers. +func SetExtraHeadersHTTP(ctx context.Context, req *http.Request, extraHeaders map[string]string, skipHeaders []string) { + for key, value := range extraHeaders { + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + // Skip Authorization header for security reasons + if key == "Authorization" { + continue + } + if skipHeaders != nil { + if slices.Contains(skipHeaders, key) { + continue + } + } + // Only set the header if it doesn't already exist to avoid overwriting important headers + if req.Header.Get(canonicalKey) == "" { + req.Header.Set(canonicalKey, value) + } + } + + // Give priority to extra headers in the context + if extraHeaders, ok := (ctx).Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string); ok { + for k, values := range filterHeaders(extraHeaders) { + for i, v := range values { + if i == 0 { + req.Header.Set(k, v) + } else { + req.Header.Add(k, v) + } + } + } + } +} + +// HandleProviderAPIError processes error responses from provider APIs. +// It attempts to unmarshal the error response and returns a BifrostError +// with the appropriate status code and error information. +// errorResp must be a pointer to the target struct for unmarshaling. +func HandleProviderAPIError(resp *fasthttp.Response, errorResp any) *schemas.BifrostError { + statusCode := resp.StatusCode() + + if err := sonic.Unmarshal(resp.Body(), errorResp); err != nil { + rawResponse := resp.Body() + message := fmt.Sprintf("provider API error: %s", string(rawResponse)) + return &schemas.BifrostError{ + IsBifrostError: false, + StatusCode: &statusCode, + Error: &schemas.ErrorField{ + Message: message, + }, + } + } + + return &schemas.BifrostError{ + IsBifrostError: false, + StatusCode: &statusCode, + Error: &schemas.ErrorField{}, + } +} + +// HandleProviderResponse handles common response parsing logic for provider responses. +// It attempts to parse the response body into the provided response type +// and returns either the parsed response or a BifrostError if parsing fails. +// If sendBackRawResponse is true, it returns the raw response interface, otherwise nil. +func HandleProviderResponse[T any](responseBody []byte, response *T, sendBackRawResponse bool) (interface{}, *schemas.BifrostError) { + var rawResponse interface{} + + var wg sync.WaitGroup + var structuredErr, rawErr error + + wg.Add(2) + go func() { + defer wg.Done() + structuredErr = sonic.Unmarshal(responseBody, response) + }() + go func() { + defer wg.Done() + if sendBackRawResponse { + rawErr = sonic.Unmarshal(responseBody, &rawResponse) + } + }() + wg.Wait() + + if structuredErr != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderResponseUnmarshal, + Error: structuredErr, + }, + } + } + + if sendBackRawResponse { + if rawErr != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: schemas.ErrProviderRawResponseUnmarshal, + Error: rawErr, + }, + } + } + + return rawResponse, nil + } + + return nil, nil +} + +// NewUnsupportedOperationError creates a standardized error for unsupported operations. +// This helper reduces code duplication across providers that don't support certain operations. +func NewUnsupportedOperationError(requestType schemas.RequestType, providerName schemas.ModelProvider) *schemas.BifrostError { + return &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("%s is not supported by %s provider", requestType, providerName), + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + Provider: providerName, + RequestType: requestType, + }, + } +} + +// CheckOperationAllowed enforces per-op gating using schemas.Operation. +// Behavior: +// - If no gating is configured (config == nil or AllowedRequests == nil), the operation is allowed. +// - If gating is configured, returns an error when the operation is not explicitly allowed. +func CheckOperationAllowed(defaultProvider schemas.ModelProvider, config *schemas.CustomProviderConfig, operation schemas.RequestType) *schemas.BifrostError { + // No gating configured => allowed + if config == nil || config.AllowedRequests == nil { + return nil + } + // Explicitly allowed? + if config.IsOperationAllowed(operation) { + return nil + } + // Gated and not allowed + resolved := GetProviderName(defaultProvider, config) + return NewUnsupportedOperationError(operation, resolved) +} + +// CheckAndDecodeBody checks the content encoding and decodes the body accordingly. +func CheckAndDecodeBody(resp *fasthttp.Response) ([]byte, error) { + contentEncoding := strings.ToLower(strings.TrimSpace(string(resp.Header.Peek("Content-Encoding")))) + switch contentEncoding { + case "gzip": + return resp.BodyGunzip() + default: + return resp.Body(), nil + } +} + +// NewConfigurationError creates a standardized error for configuration errors. +// This helper reduces code duplication across providers that have configuration errors. +func NewConfigurationError(message string, providerType schemas.ModelProvider) *schemas.BifrostError { + return &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: message, + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + Provider: providerType, + }, + } +} + +// NewBifrostOperationError creates a standardized error for bifrost operation errors. +// This helper reduces code duplication across providers that have bifrost operation errors. +func NewBifrostOperationError(message string, err error, providerType schemas.ModelProvider) *schemas.BifrostError { + return &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: message, + Error: err, + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + Provider: providerType, + }, + } +} + +// NewProviderAPIError creates a standardized error for provider API errors. +// This helper reduces code duplication across providers that have provider API errors. +func NewProviderAPIError(message string, err error, statusCode int, providerType schemas.ModelProvider, errorType *string, eventID *string) *schemas.BifrostError { + return &schemas.BifrostError{ + IsBifrostError: false, + StatusCode: &statusCode, + Type: errorType, + EventID: eventID, + Error: &schemas.ErrorField{ + Message: message, + Error: err, + Type: errorType, + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + Provider: providerType, + }, + } +} + +// ShouldSendBackRawResponse checks if the raw response should be sent back, and returns it if it exists. +func ShouldSendBackRawResponse(ctx context.Context, defaultSendBackRawResponse bool) bool { + if sendBackRawResponse, ok := ctx.Value(schemas.BifrostContextKeySendBackRawResponse).(bool); ok && sendBackRawResponse { + return sendBackRawResponse + } + return defaultSendBackRawResponse +} + +// SendCreatedEventResponsesChunk sends a ResponsesStreamResponseTypeCreated event. +func SendCreatedEventResponsesChunk(ctx context.Context, postHookRunner schemas.PostHookRunner, provider schemas.ModelProvider, model string, startTime time.Time, responseChan chan *schemas.BifrostStream) { + firstChunk := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeCreated, + SequenceNumber: 0, + Response: &schemas.BifrostResponsesResponse{}, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: provider, + ModelRequested: model, + ChunkIndex: 0, + Latency: time.Since(startTime).Milliseconds(), + }, + } + //TODO add bifrost response pooling here + bifrostResponse := &schemas.BifrostResponse{ + ResponsesStreamResponse: firstChunk, + } + ProcessAndSendResponse(ctx, postHookRunner, bifrostResponse, responseChan) +} + +// SendInProgressEventResponsesChunk sends a ResponsesStreamResponseTypeInProgress event +func SendInProgressEventResponsesChunk(ctx context.Context, postHookRunner schemas.PostHookRunner, provider schemas.ModelProvider, model string, startTime time.Time, responseChan chan *schemas.BifrostStream) { + chunk := &schemas.BifrostResponsesStreamResponse{ + Type: schemas.ResponsesStreamResponseTypeInProgress, + SequenceNumber: 1, + Response: &schemas.BifrostResponsesResponse{}, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesStreamRequest, + Provider: provider, + ModelRequested: model, + ChunkIndex: 1, + Latency: time.Since(startTime).Milliseconds(), + }, + } + //TODO add bifrost response pooling here + bifrostResponse := &schemas.BifrostResponse{ + ResponsesStreamResponse: chunk, + } + ProcessAndSendResponse(ctx, postHookRunner, bifrostResponse, responseChan) +} + +// ProcessAndSendResponse handles post-hook processing and sends the response to the channel. +// This utility reduces code duplication across streaming implementations by encapsulating +// the common pattern of running post hooks, handling errors, and sending responses with +// proper context cancellation handling. +func ProcessAndSendResponse( + ctx context.Context, + postHookRunner schemas.PostHookRunner, + response *schemas.BifrostResponse, + responseChan chan *schemas.BifrostStream, +) { + // Run post hooks on the response + processedResponse, processedError := postHookRunner(&ctx, response, nil) + + if HandleStreamControlSkip(processedError) { + return + } + + streamResponse := &schemas.BifrostStream{} + if processedResponse != nil { + streamResponse.BifrostTextCompletionResponse = processedResponse.TextCompletionResponse + streamResponse.BifrostChatResponse = processedResponse.ChatResponse + streamResponse.BifrostResponsesStreamResponse = processedResponse.ResponsesStreamResponse + streamResponse.BifrostSpeechStreamResponse = processedResponse.SpeechStreamResponse + streamResponse.BifrostTranscriptionStreamResponse = processedResponse.TranscriptionStreamResponse + } + if processedError != nil { + streamResponse.BifrostError = processedError + } + + select { + case responseChan <- streamResponse: + case <-ctx.Done(): + return + } +} + +// ProcessAndSendBifrostError handles post-hook processing and sends the bifrost error to the channel. +// This utility reduces code duplication across streaming implementations by encapsulating +// the common pattern of running post hooks, handling errors, and sending responses with +// proper context cancellation handling. +func ProcessAndSendBifrostError( + ctx context.Context, + postHookRunner schemas.PostHookRunner, + bifrostErr *schemas.BifrostError, + responseChan chan *schemas.BifrostStream, + logger schemas.Logger, +) { + // Send scanner error through channel + processedResponse, processedError := postHookRunner(&ctx, nil, bifrostErr) + + if HandleStreamControlSkip(processedError) { + return + } + + streamResponse := &schemas.BifrostStream{} + if processedResponse != nil { + streamResponse.BifrostTextCompletionResponse = processedResponse.TextCompletionResponse + streamResponse.BifrostChatResponse = processedResponse.ChatResponse + streamResponse.BifrostResponsesStreamResponse = processedResponse.ResponsesStreamResponse + streamResponse.BifrostSpeechStreamResponse = processedResponse.SpeechStreamResponse + streamResponse.BifrostTranscriptionStreamResponse = processedResponse.TranscriptionStreamResponse + } + if processedError != nil { + streamResponse.BifrostError = processedError + } + + select { + case responseChan <- streamResponse: + case <-ctx.Done(): + } +} + +// ProcessAndSendError handles post-hook processing and sends the error to the channel. +// This utility reduces code duplication across streaming implementations by encapsulating +// the common pattern of running post hooks, handling errors, and sending responses with +// proper context cancellation handling. +func ProcessAndSendError( + ctx context.Context, + postHookRunner schemas.PostHookRunner, + err error, + responseChan chan *schemas.BifrostStream, + requestType schemas.RequestType, + providerName schemas.ModelProvider, + model string, + logger schemas.Logger, +) { + // Send scanner error through channel + bifrostError := + &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("Error reading stream: %v", err), + Error: err, + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + RequestType: requestType, + Provider: providerName, + ModelRequested: model, + }, + } + processedResponse, processedError := postHookRunner(&ctx, nil, bifrostError) + + if HandleStreamControlSkip(processedError) { + return + } + + streamResponse := &schemas.BifrostStream{} + if processedResponse != nil { + streamResponse.BifrostTextCompletionResponse = processedResponse.TextCompletionResponse + streamResponse.BifrostChatResponse = processedResponse.ChatResponse + streamResponse.BifrostResponsesStreamResponse = processedResponse.ResponsesStreamResponse + streamResponse.BifrostSpeechStreamResponse = processedResponse.SpeechStreamResponse + streamResponse.BifrostTranscriptionStreamResponse = processedResponse.TranscriptionStreamResponse + } + if processedError != nil { + streamResponse.BifrostError = processedError + } + + select { + case responseChan <- streamResponse: + case <-ctx.Done(): + } +} + +// CreateBifrostTextCompletionChunkResponse creates a bifrost text completion chunk response. +func CreateBifrostTextCompletionChunkResponse( + id string, + usage *schemas.BifrostLLMUsage, + finishReason *string, + currentChunkIndex int, + requestType schemas.RequestType, + providerName schemas.ModelProvider, + model string, +) *schemas.BifrostTextCompletionResponse { + response := &schemas.BifrostTextCompletionResponse{ + ID: id, + Object: "text_completion", + Usage: usage, + Choices: []schemas.BifrostResponseChoice{ + { + FinishReason: finishReason, + TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{}, // empty delta + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: requestType, + Provider: providerName, + ModelRequested: model, + ChunkIndex: currentChunkIndex + 1, + }, + } + return response +} + +// CreateBifrostChatCompletionChunkResponse creates a bifrost chat completion chunk response. +func CreateBifrostChatCompletionChunkResponse( + id string, + usage *schemas.BifrostLLMUsage, + finishReason *string, + currentChunkIndex int, + requestType schemas.RequestType, + providerName schemas.ModelProvider, + model string, +) *schemas.BifrostChatResponse { + response := &schemas.BifrostChatResponse{ + ID: id, + Object: "chat.completion.chunk", + Usage: usage, + Choices: []schemas.BifrostResponseChoice{ + { + FinishReason: finishReason, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{}, // empty delta + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: requestType, + Provider: providerName, + ModelRequested: model, + ChunkIndex: currentChunkIndex + 1, + }, + } + return response +} + +// HandleStreamControlSkip checks if the stream control should be skipped. +func HandleStreamControlSkip(bifrostErr *schemas.BifrostError) bool { + if bifrostErr == nil || bifrostErr.StreamControl == nil { + return false + } + if bifrostErr.StreamControl.SkipStream != nil && *bifrostErr.StreamControl.SkipStream { + if bifrostErr.StreamControl.LogError != nil && *bifrostErr.StreamControl.LogError { + logger.Warn("Error in stream: " + bifrostErr.Error.Message) + } + return true + } + return false +} + +// GetProviderName extracts the provider name from custom provider configuration. +// If a custom provider key is specified, it returns that; otherwise, it returns the default provider. +// Note: CustomProviderKey is internally set by Bifrost and should always match the provider name. +func GetProviderName(defaultProvider schemas.ModelProvider, customConfig *schemas.CustomProviderConfig) schemas.ModelProvider { + if customConfig != nil { + if key := strings.TrimSpace(customConfig.CustomProviderKey); key != "" { + return schemas.ModelProvider(key) + } + } + return defaultProvider +} + +// IsVertexMistralModel checks if the model is a Mistral or Codestral model in Vertex. +func IsVertexMistralModel(model string) bool { + return strings.Contains(model, "mistral") || strings.Contains(model, "codestral") +} + +// ProviderSendsDoneMarker returns true if the provider sends the [DONE] marker in streaming responses. +// Some OpenAI-compatible providers (like Cerebras) don't send [DONE] and instead end the stream +// after sending the finish_reason. This function helps determine the correct stream termination logic. +func ProviderSendsDoneMarker(providerName schemas.ModelProvider) bool { + switch providerName { + case schemas.Cerebras, schemas.Perplexity: + // Cerebras and Perplexity don't send [DONE] marker, ends stream after finish_reason + return false + default: + // Default to expecting [DONE] marker for safety + return true + } +} + +func ProviderIsResponsesAPINative(providerName schemas.ModelProvider) bool { + switch providerName { + case schemas.OpenAI, schemas.OpenRouter, schemas.Azure: + return true + default: + return false + } +} + +// ReleaseStreamingResponse releases a streaming response by draining the body stream and releasing the response. +func ReleaseStreamingResponse(resp *fasthttp.Response) { + // Drain any remaining data from the body stream before releasing + // This prevents "whitespace in header" errors when the response is reused + if resp.BodyStream() != nil { + io.Copy(io.Discard, resp.BodyStream()) + } + fasthttp.ReleaseResponse(resp) +} + +// GetBifrostResponseForStreamResponse converts the provided responses to a bifrost response. +func GetBifrostResponseForStreamResponse( + textCompletionResponse *schemas.BifrostTextCompletionResponse, + chatResponse *schemas.BifrostChatResponse, + responsesStreamResponse *schemas.BifrostResponsesStreamResponse, + speechStreamResponse *schemas.BifrostSpeechStreamResponse, + transcriptionStreamResponse *schemas.BifrostTranscriptionStreamResponse, +) *schemas.BifrostResponse { + //TODO add bifrost response pooling here + bifrostResponse := &schemas.BifrostResponse{} + + switch { + case textCompletionResponse != nil: + bifrostResponse.TextCompletionResponse = textCompletionResponse + return bifrostResponse + case chatResponse != nil: + bifrostResponse.ChatResponse = chatResponse + return bifrostResponse + case responsesStreamResponse != nil: + bifrostResponse.ResponsesStreamResponse = responsesStreamResponse + return bifrostResponse + case speechStreamResponse != nil: + bifrostResponse.SpeechStreamResponse = speechStreamResponse + return bifrostResponse + case transcriptionStreamResponse != nil: + bifrostResponse.TranscriptionStreamResponse = transcriptionStreamResponse + return bifrostResponse + } + return nil +} + +// aggregateListModelsResponses merges multiple BifrostListModelsResponse objects into a single response. +// It concatenates all model arrays, deduplicates based on model ID, sums up latencies across all responses, +// and concatenates raw responses into an array. +// When duplicate IDs are found, the first occurrence is kept to maintain the original ordering. +func aggregateListModelsResponses(responses []*schemas.BifrostListModelsResponse) *schemas.BifrostListModelsResponse { + if len(responses) == 0 { + return &schemas.BifrostListModelsResponse{ + Data: []schemas.Model{}, + } + } + + if len(responses) == 1 { + return responses[0] + } + + // Use a map to track unique model IDs for efficient deduplication + seenIDs := make(map[string]struct{}) + aggregated := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0), + } + + // Aggregate all models with deduplication, and collect raw responses + var rawResponses []interface{} + + for _, response := range responses { + if response == nil { + continue + } + + // Add models, skipping duplicates based on ID + for _, model := range response.Data { + if _, exists := seenIDs[model.ID]; !exists { + seenIDs[model.ID] = struct{}{} + aggregated.Data = append(aggregated.Data, model) + } + } + + // Collect raw response if present + if response.ExtraFields.RawResponse != nil { + rawResponses = append(rawResponses, response.ExtraFields.RawResponse) + } + } + + // Sort models alphabetically by ID + sort.Slice(aggregated.Data, func(i, j int) bool { + return aggregated.Data[i].ID < aggregated.Data[j].ID + }) + + if len(rawResponses) > 0 { + aggregated.ExtraFields.RawResponse = rawResponses + } + + return aggregated +} + +// extractSuccessfulListModelsResponses extracts successful responses from a results channel +// and tracks the last error encountered. This utility reduces code duplication across providers +// for handling multi-key ListModels requests. +func extractSuccessfulListModelsResponses( + results chan schemas.ListModelsByKeyResult, + providerName schemas.ModelProvider, + logger schemas.Logger, +) ([]*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + var successfulResponses []*schemas.BifrostListModelsResponse + var lastError *schemas.BifrostError + + for result := range results { + if result.Err != nil { + logger.Debug(fmt.Sprintf("failed to list models with key %s: %s", result.KeyID, result.Err.Error.Message)) + lastError = result.Err + continue + } + + successfulResponses = append(successfulResponses, result.Response) + } + + if len(successfulResponses) == 0 { + if lastError != nil { + return nil, lastError + } + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: "all keys failed to list models", + }, + ExtraFields: schemas.BifrostErrorExtraFields{ + Provider: providerName, + RequestType: schemas.ListModelsRequest, + }, + } + } + + return successfulResponses, nil +} + +// HandleMultipleListModelsRequests handles multiple list models requests concurrently for different keys. +// It launches concurrent requests for all keys and waits for all goroutines to complete. +// It returns the aggregated response or an error if the request fails. +func HandleMultipleListModelsRequests( + ctx context.Context, + keys []schemas.Key, + request *schemas.BifrostListModelsRequest, + listModelsByKey func(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError), + logger schemas.Logger, +) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + startTime := time.Now() + + results := make(chan schemas.ListModelsByKeyResult, len(keys)) + var wg sync.WaitGroup + + // Launch concurrent requests for all keys + for _, key := range keys { + wg.Add(1) + go func(k schemas.Key) { + defer wg.Done() + resp, bifrostErr := listModelsByKey(ctx, k, request) + results <- schemas.ListModelsByKeyResult{Response: resp, Err: bifrostErr, KeyID: k.ID} + }(key) + } + + // Wait for all goroutines to complete + wg.Wait() + close(results) + + successfulResponses, err := extractSuccessfulListModelsResponses(results, request.Provider, logger) + if err != nil { + return nil, err + } + + // Aggregate all successful responses + response := aggregateListModelsResponses(successfulResponses) + response = response.ApplyPagination(request.PageSize, request.PageToken) + + // Set ExtraFields + latency := time.Since(startTime) + response.ExtraFields.Provider = request.Provider + response.ExtraFields.RequestType = schemas.ListModelsRequest + response.ExtraFields.Latency = latency.Milliseconds() + + return response, nil +} diff --git a/core/providers/vertex/embedding.go b/core/providers/vertex/embedding.go new file mode 100644 index 000000000..c7033d016 --- /dev/null +++ b/core/providers/vertex/embedding.go @@ -0,0 +1,120 @@ +package vertex + +import ( + "github.com/maximhq/bifrost/core/schemas" +) + +// ToVertexEmbeddingRequest converts a Bifrost embedding request to Vertex AI format +func ToVertexEmbeddingRequest(bifrostReq *schemas.BifrostEmbeddingRequest) *VertexEmbeddingRequest { + if bifrostReq == nil || bifrostReq.Input == nil || (bifrostReq.Input.Text == nil && bifrostReq.Input.Texts == nil) { + return nil + } + + var texts []string + if bifrostReq.Input.Text != nil { + texts = []string{*bifrostReq.Input.Text} + } else { + texts = bifrostReq.Input.Texts + } + + // Create instances for each text + instances := make([]VertexEmbeddingInstance, 0, len(texts)) + for _, text := range texts { + instance := VertexEmbeddingInstance{ + Content: text, + } + + // Add optional task_type and title from params + if bifrostReq.Params != nil { + if taskTypeStr, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["task_type"]); ok { + instance.TaskType = taskTypeStr + } + if title, ok := schemas.SafeExtractStringPointer(bifrostReq.Params.ExtraParams["title"]); ok { + instance.Title = title + } + } + + instances = append(instances, instance) + } + + // Create the request + vertexReq := &VertexEmbeddingRequest{ + Instances: instances, + } + + // Add parameters if present + if bifrostReq.Params != nil { + parameters := &VertexEmbeddingParameters{} + + // Set autoTruncate (defaults to true) + autoTruncate := true + if bifrostReq.Params.ExtraParams != nil { + if autoTruncateVal, ok := schemas.SafeExtractBool(bifrostReq.Params.ExtraParams["autoTruncate"]); ok { + autoTruncate = autoTruncateVal + } + } + parameters.AutoTruncate = &autoTruncate + + // Add outputDimensionality if specified + if bifrostReq.Params.Dimensions != nil { + parameters.OutputDimensionality = bifrostReq.Params.Dimensions + } + + vertexReq.Parameters = parameters + } + + return vertexReq +} + +// ToBifrostEmbeddingResponse converts a Vertex AI embedding response to Bifrost format +func (response *VertexEmbeddingResponse) ToBifrostEmbeddingResponse() *schemas.BifrostEmbeddingResponse { + if response == nil || len(response.Predictions) == 0 { + return nil + } + + // Convert predictions to Bifrost embeddings + embeddings := make([]schemas.EmbeddingData, 0, len(response.Predictions)) + var usage *schemas.BifrostLLMUsage + + for i, prediction := range response.Predictions { + if prediction.Embeddings == nil || len(prediction.Embeddings.Values) == 0 { + continue + } + + // Convert float64 values to float32 for Bifrost format + embeddingFloat32 := make([]float32, 0, len(prediction.Embeddings.Values)) + for _, v := range prediction.Embeddings.Values { + embeddingFloat32 = append(embeddingFloat32, float32(v)) + } + + // Create embedding object + embedding := schemas.EmbeddingData{ + Object: "embedding", + Embedding: schemas.EmbeddingStruct{ + EmbeddingArray: embeddingFloat32, + }, + Index: i, + } + + // Extract statistics if available + if prediction.Embeddings.Statistics != nil { + if usage == nil { + usage = &schemas.BifrostLLMUsage{} + } + usage.TotalTokens += prediction.Embeddings.Statistics.TokenCount + usage.PromptTokens += prediction.Embeddings.Statistics.TokenCount + } + + embeddings = append(embeddings, embedding) + } + + return &schemas.BifrostEmbeddingResponse{ + Object: "list", + Data: embeddings, + Usage: usage, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.EmbeddingRequest, + Provider: schemas.Vertex, + }, + } +} diff --git a/core/providers/vertex/models.go b/core/providers/vertex/models.go new file mode 100644 index 000000000..8c6f166e7 --- /dev/null +++ b/core/providers/vertex/models.go @@ -0,0 +1,24 @@ +package vertex + +import "github.com/maximhq/bifrost/core/schemas" + +func (response *VertexListModelsResponse) ToBifrostListModelsResponse() *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Models)), + } + + for _, model := range response.Models { + bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ + ID: string(schemas.Vertex) + "/" + model.Name, + Name: schemas.Ptr(model.DisplayName), + Description: schemas.Ptr(model.Description), + Created: schemas.Ptr(model.VersionCreateTime.Unix()), + }) + } + + return bifrostResponse +} diff --git a/core/providers/vertex/types.go b/core/providers/vertex/types.go new file mode 100644 index 000000000..35946b7d7 --- /dev/null +++ b/core/providers/vertex/types.go @@ -0,0 +1,80 @@ +package vertex + +import "time" + +// Vertex AI Embedding API types + +const ( + DefaultVertexAnthropicVersion = "vertex-2023-10-16" +) + +// VertexEmbeddingInstance represents a single embedding instance in the request +type VertexEmbeddingInstance struct { + Content string `json:"content"` // The text to generate embeddings for + TaskType *string `json:"task_type,omitempty"` // Intended downstream application (optional) + Title *string `json:"title,omitempty"` // Used to help the model produce better embeddings (optional) +} + +// VertexEmbeddingParameters represents the parameters for the embedding request +type VertexEmbeddingParameters struct { + AutoTruncate *bool `json:"autoTruncate,omitempty"` // When true, input text will be truncated (defaults to true) + OutputDimensionality *int `json:"outputDimensionality,omitempty"` // Output embedding size (optional) +} + +// VertexEmbeddingRequest represents the complete embedding request to Vertex AI +type VertexEmbeddingRequest struct { + Instances []VertexEmbeddingInstance `json:"instances"` // List of embedding instances + Parameters *VertexEmbeddingParameters `json:"parameters,omitempty"` // Optional parameters +} + +// VertexEmbeddingStatistics represents statistics computed from the input text +type VertexEmbeddingStatistics struct { + Truncated bool `json:"truncated"` // Whether the input text was truncated + TokenCount int `json:"token_count"` // Number of tokens in the input text +} + +// VertexEmbeddingValues represents the embedding result +type VertexEmbeddingValues struct { + Values []float64 `json:"values"` // The embedding vector (list of floats) + Statistics *VertexEmbeddingStatistics `json:"statistics"` // Statistics about the input text +} + +// VertexEmbeddingPrediction represents a single prediction in the response +type VertexEmbeddingPrediction struct { + Embeddings *VertexEmbeddingValues `json:"embeddings"` // The embedding result +} + +// VertexEmbeddingResponse represents the complete embedding response from Vertex AI +type VertexEmbeddingResponse struct { + Predictions []VertexEmbeddingPrediction `json:"predictions"` // List of embedding predictions +} + +// ================================ Model Types ================================ + +const MaxPageSize = 100 + +type VertexModel struct { + Name string `json:"name"` + VersionId string `json:"versionId"` + VersionAliases []string `json:"versionAliases"` + VersionCreateTime time.Time `json:"versionCreateTime"` + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + +type VertexListModelsResponse struct { + Models []VertexModel `json:"models"` + NextPageToken string `json:"nextPageToken"` +} + +// ==================== ERROR TYPES ==================== +// VertexValidationError represents validation errors +// returned by the Vertex Mistral endpoint +type VertexValidationError struct { + Detail []struct { + Input any `json:"input"` // can be number, object, or array + Loc []any `json:"loc"` // location of the error (can contain strings and numeric indices) + Msg string `json:"msg"` // error message + Type string `json:"type"` // error type (e.g., "extra_forbidden", "missing") + } `json:"detail"` +} diff --git a/core/providers/vertex/vertex.go b/core/providers/vertex/vertex.go new file mode 100644 index 000000000..f69a0ac9d --- /dev/null +++ b/core/providers/vertex/vertex.go @@ -0,0 +1,829 @@ +package vertex + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/valyala/fasthttp" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +type VertexError struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` +} + +// vertexClientPool provides a pool/cache for authenticated Vertex HTTP clients. +// This avoids creating and authenticating clients for every request. +// Uses sync.Map for atomic operations without explicit locking. +var vertexClientPool sync.Map + +// getClientKey generates a unique key for caching authenticated clients. +// It uses a hash of the auth credentials for security. +func getClientKey(authCredentials string) string { + hash := sha256.Sum256([]byte(authCredentials)) + return hex.EncodeToString(hash[:]) +} + +// removeVertexClient removes a specific client from the pool. +// This should be called when: +// - API returns authentication/authorization errors (401, 403) +// - Auth client creation fails +// - Network errors that might indicate credential issues +// This ensures we don't keep using potentially invalid clients. +func removeVertexClient(authCredentials string) { + clientKey := getClientKey(authCredentials) + vertexClientPool.Delete(clientKey) +} + +// VertexProvider implements the Provider interface for Google's Vertex AI API. +type VertexProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for API requests + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewVertexProvider creates a new Vertex provider instance. +// It initializes the HTTP client with the provided configuration and sets up response pools. +// The client is configured with timeouts, concurrency limits, and optional proxy settings. +func NewVertexProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*VertexProvider, error) { + config.CheckAndSetDefaults() + client := &fasthttp.Client{ + ReadTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + WriteTimeout: time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds), + MaxConnsPerHost: 5000, + MaxIdleConnDuration: 60 * time.Second, + MaxConnWaitTimeout: 10 * time.Second, + } + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + return &VertexProvider{ + logger: logger, + client: client, + networkConfig: config.NetworkConfig, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +const cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform" + +// getAuthTokenSource returns an authenticated token source for Vertex AI API requests. +// It uses the default credentials if no auth credentials are provided. +// It uses the JWT config if auth credentials are provided. +// It returns an error if the token source creation fails. +func getAuthTokenSource(key schemas.Key) (oauth2.TokenSource, error) { + if key.VertexKeyConfig == nil { + return nil, fmt.Errorf("vertex key config is not set") + } + authCredentials := key.VertexKeyConfig.AuthCredentials + var tokenSource oauth2.TokenSource + if authCredentials == "" { + creds, err := google.FindDefaultCredentials(context.Background(), cloudPlatformScope) + if err != nil { + return nil, fmt.Errorf("failed to find default credentials: %w", err) + } + tokenSource = creds.TokenSource + } else { + conf, err := google.JWTConfigFromJSON([]byte(authCredentials), cloudPlatformScope) + if err != nil { + return nil, fmt.Errorf("failed to create JWT config: %w", err) + } + tokenSource = conf.TokenSource(context.Background()) + } + return tokenSource, nil +} + +// GetProviderKey returns the provider identifier for Vertex. +func (provider *VertexProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Vertex +} + +// listModelsByKey performs a list models request for a single key. +// Returns the response and latency, or an error if the request fails. +// Handles pagination automatically by following nextPageToken until all models are retrieved. +func (provider *VertexProvider) listModelsByKey(ctx context.Context, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + if key.VertexKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("vertex key config is not set", providerName) + } + + projectID := key.VertexKeyConfig.ProjectID + if projectID == "" { + return nil, providerUtils.NewConfigurationError("project ID is not set", providerName) + } + + region := key.VertexKeyConfig.Region + if region == "" { + return nil, providerUtils.NewConfigurationError("region is not set in key config", providerName) + } + + var host string + if region == "global" { + host = "aiplatform.googleapis.com" + } else { + host = fmt.Sprintf("%s-aiplatform.googleapis.com", region) + } + + // Accumulate all models from paginated requests + var allModels []VertexModel + var totalLatency time.Duration + var rawResponses []interface{} + pageToken := "" + + // Getting oauth2 token + tokenSource, err := getAuthTokenSource(key) + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error creating auth token source", err, schemas.Vertex) + } + token, err := tokenSource.Token() + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error getting token", err, schemas.Vertex) + } + + // Loop through all pages until no nextPageToken is returned + for { + // Build URL with pagination parameters + requestURL := fmt.Sprintf("https://%s/v1/projects/%s/locations/%s/models?pageSize=%d", host, projectID, region, MaxPageSize) + if pageToken != "" { + requestURL = fmt.Sprintf("%s&pageToken=%s", requestURL, url.QueryEscape(pageToken)) + } + + // Create HTTP request for listing models + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + req.Header.SetMethod(http.MethodGet) + req.SetRequestURI(requestURL) + req.Header.SetContentType("application/json") + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + + _, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, provider.GetProviderKey()) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, provider.GetProviderKey()) + } + + // Handle error response + if resp.StatusCode() != fasthttp.StatusOK { + if resp.StatusCode() == fasthttp.StatusUnauthorized || resp.StatusCode() == fasthttp.StatusForbidden { + removeVertexClient(key.VertexKeyConfig.AuthCredentials) + } + + var errorResp VertexError + if err := sonic.Unmarshal(resp.Body(), &errorResp); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, schemas.Vertex) + } + return nil, providerUtils.NewProviderAPIError(errorResp.Error.Message, nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + + // Parse Vertex's response + var vertexResponse VertexListModelsResponse + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(resp.Body(), &vertexResponse, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + rawResponses = append(rawResponses, rawResponse) + } + + // Accumulate models from this page + allModels = append(allModels, vertexResponse.Models...) + + // Check if there are more pages + if vertexResponse.NextPageToken == "" { + break + } + pageToken = vertexResponse.NextPageToken + } + + // Create aggregated response from all pages + aggregatedResponse := &VertexListModelsResponse{ + Models: allModels, + } + response := aggregatedResponse.ToBifrostListModelsResponse() + response.ExtraFields.Latency = totalLatency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponses + } + + return response, nil +} + +// ListModels performs a list models request to Vertex's API. +// Requests are made concurrently for improved performance. +func (provider *VertexProvider) ListModels(ctx context.Context, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return providerUtils.HandleMultipleListModelsRequests( + ctx, + keys, + request, + provider.listModelsByKey, + provider.logger, + ) +} + +// TextCompletion is not supported by the Vertex provider. +// Returns an error indicating that text completion is not available. +func (provider *VertexProvider) TextCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream performs a streaming text completion request to Vertex's API. +// It formats the request, sends it to Vertex, and processes the response. +// Returns a channel of BifrostStream objects or an error if the request fails. +func (provider *VertexProvider) TextCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// ChatCompletion performs a chat completion request to the Vertex API. +// It supports both text and image content in messages. +// Returns a BifrostResponse containing the completion results or an error if the request fails. +func (provider *VertexProvider) ChatCompletion(ctx context.Context, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + if key.VertexKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("vertex key config is not set", providerName) + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + //TODO: optimize this double Marshal + // Format messages for Vertex API + var requestBody map[string]interface{} + + if strings.Contains(request.Model, "claude") { + // Use centralized Anthropic converter + reqBody := anthropic.ToAnthropicChatCompletionRequest(request) + if reqBody == nil { + return nil, fmt.Errorf("chat completion input is not provided") + } + + // Convert struct to map for Vertex API + reqBytes, err := sonic.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + if err := sonic.Unmarshal(reqBytes, &requestBody); err != nil { + return nil, fmt.Errorf("failed to unmarshal request body: %w", err) + } + } else { + // Use centralized OpenAI converter for non-Claude models + reqBody := openai.ToOpenAIChatRequest(request) + if reqBody == nil { + return nil, fmt.Errorf("chat completion input is not provided") + } + + // Convert struct to map for Vertex API + reqBytes, err := sonic.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + if err := sonic.Unmarshal(reqBytes, &requestBody); err != nil { + return nil, fmt.Errorf("failed to unmarshal request body: %w", err) + } + } + + if strings.Contains(request.Model, "claude") { + if _, exists := requestBody["anthropic_version"]; !exists { + requestBody["anthropic_version"] = DefaultVertexAnthropicVersion + } + + delete(requestBody, "model") + } + + delete(requestBody, "region") + + return requestBody, nil + + }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + projectID := key.VertexKeyConfig.ProjectID + if projectID == "" { + return nil, providerUtils.NewConfigurationError("project ID is not set", providerName) + } + + region := key.VertexKeyConfig.Region + if region == "" { + return nil, providerUtils.NewConfigurationError("region is not set in key config", providerName) + } + + // Determine the URL based on model type + var url string + if strings.Contains(request.Model, "claude") { + // Claude models use Anthropic publisher + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:rawPredict", projectID, request.Model) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", region, projectID, region, request.Model) + } + } else if providerUtils.IsVertexMistralModel(request.Model) { + // Mistral models use mistralai publisher with rawPredict + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/mistralai/models/%s:rawPredict", projectID, request.Model) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/mistralai/models/%s:rawPredict", region, projectID, region, request.Model) + } + } else { + // Other models use OpenAPI endpoint + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/global/endpoints/openapi/chat/completions", projectID) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", region, projectID, region) + } + } + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(url) + req.Header.SetContentType("application/json") + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Getting oauth2 token + tokenSource, err := getAuthTokenSource(key) + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error creating auth token source", err, schemas.Vertex) + } + token, err := tokenSource.Token() + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error getting token", err, schemas.Vertex) + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.SetBody(jsonBody) + + // Make the request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, provider.GetProviderKey()) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, provider.GetProviderKey()) + } + + if resp.StatusCode() != fasthttp.StatusOK { + // Remove client from pool for authentication/authorization errors + if resp.StatusCode() == fasthttp.StatusUnauthorized || resp.StatusCode() == fasthttp.StatusForbidden { + removeVertexClient(key.VertexKeyConfig.AuthCredentials) + } + + var openAIErr schemas.BifrostError + + var vertexErr []VertexError + if err := sonic.Unmarshal(resp.Body(), &openAIErr); err != nil || openAIErr.Error == nil { + // Try Vertex error format if OpenAI format fails or is incomplete + if err := sonic.Unmarshal(resp.Body(), &vertexErr); err != nil { + + //try with single Vertex error format + var vertexErr VertexError + if err := sonic.Unmarshal(resp.Body(), &vertexErr); err != nil { + // Try VertexValidationError format (validation errors from Mistral endpoint) + var validationErr VertexValidationError + if err := sonic.Unmarshal(resp.Body(), &validationErr); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, schemas.Vertex) + } + if len(validationErr.Detail) > 0 { + return nil, providerUtils.NewProviderAPIError(validationErr.Detail[0].Msg, nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + return nil, providerUtils.NewProviderAPIError("Unknown error", nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + + return nil, providerUtils.NewProviderAPIError(vertexErr.Error.Message, nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + + if len(vertexErr) > 0 { + return nil, providerUtils.NewProviderAPIError(vertexErr[0].Error.Message, nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + + return nil, providerUtils.NewProviderAPIError("Unknown error", nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } else { + // OpenAI error format succeeded with valid Error field + return nil, providerUtils.NewProviderAPIError(openAIErr.Error.Message, nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + } + + if strings.Contains(request.Model, "claude") { + // Create response object from pool + anthropicChatResponse := anthropic.AcquireAnthropicChatResponse() + defer anthropic.ReleaseAnthropicChatResponse(anthropicChatResponse) + + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(resp.Body(), anthropicChatResponse, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Create final response + response := anthropicChatResponse.ToBifrostChatResponse() + + response.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: providerName, + ModelRequested: request.Model, + Latency: latency.Milliseconds(), + } + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil + } else { + response := &schemas.BifrostChatResponse{} + + // Use enhanced response handler with pre-allocated response + rawResponse, bifrostErr := providerUtils.HandleProviderResponse(resp.Body(), response, provider.sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + response.ExtraFields.RequestType = schemas.ChatCompletionRequest + response.ExtraFields.Provider = providerName + response.ExtraFields.ModelRequested = request.Model + response.ExtraFields.Latency = latency.Milliseconds() + + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + response.ExtraFields.RawResponse = rawResponse + } + + return response, nil + } +} + +// ChatCompletionStream performs a streaming chat completion request to the Vertex API. +// It supports both OpenAI-style streaming (for non-Claude models) and Anthropic-style streaming (for Claude models). +// Returns a channel of BifrostResponse objects for streaming results or an error if the request fails. +func (provider *VertexProvider) ChatCompletionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + if key.VertexKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("vertex key config is not set", providerName) + } + + projectID := key.VertexKeyConfig.ProjectID + if projectID == "" { + return nil, providerUtils.NewConfigurationError("project ID is not set", providerName) + } + + region := key.VertexKeyConfig.Region + if region == "" { + return nil, providerUtils.NewConfigurationError("region is not set in key config", providerName) + } + + if strings.Contains(request.Model, "claude") { + // Use Anthropic-style streaming for Claude models + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { + reqBody := anthropic.ToAnthropicChatCompletionRequest(request) + if reqBody == nil { + return nil, fmt.Errorf("chat completion input is not provided") + } + + reqBody.Stream = schemas.Ptr(true) + + // Convert struct to map for Vertex API + reqBytes, err := sonic.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + var requestBody map[string]interface{} + if err := sonic.Unmarshal(reqBytes, &requestBody); err != nil { + return nil, fmt.Errorf("failed to unmarshal request body: %w", err) + } + + if _, exists := requestBody["anthropic_version"]; !exists { + requestBody["anthropic_version"] = DefaultVertexAnthropicVersion + } + + delete(requestBody, "model") + delete(requestBody, "region") + return requestBody, nil + }, + provider.GetProviderKey()) + if bifrostErr != nil { + return nil, bifrostErr + } + + var url string + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:streamRawPredict", projectID, request.Model) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:streamRawPredict", region, projectID, region, request.Model) + } + + // Prepare headers for Vertex Anthropic + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + // Adding authorization header + tokenSource, err := getAuthTokenSource(key) + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error creating auth token source", err, schemas.Vertex) + } + token, err := tokenSource.Token() + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error getting token", err, schemas.Vertex) + } + headers["Authorization"] = "Bearer " + token.AccessToken + + // Use shared Anthropic streaming logic + return anthropic.HandleAnthropicChatCompletionStreaming( + ctx, + provider.client, + url, + jsonData, + headers, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + providerName, + postHookRunner, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) + } else { + // Use OpenAI-compatible streaming for Mistral and other models + var url string + if providerUtils.IsVertexMistralModel(request.Model) { + // Mistral models use mistralai publisher with streamRawPredict + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/mistralai/models/%s:streamRawPredict", projectID, request.Model) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/mistralai/models/%s:streamRawPredict", region, projectID, region, request.Model) + } + } else { + // Other models use OpenAPI endpoint + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/global/endpoints/openapi/chat/completions", projectID) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", region, projectID, region) + } + } + + // Getting oauth2 token + tokenSource, err := getAuthTokenSource(key) + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error creating auth token source", err, schemas.Vertex) + } + token, err := tokenSource.Token() + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error getting token", err, schemas.Vertex) + } + + authHeader := map[string]string{ + "Authorization": "Bearer " + token.AccessToken, + } + + // Use shared OpenAI streaming logic + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.client, + url, + request, + authHeader, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + providerName, + postHookRunner, + nil, + nil, + provider.logger, + provider.networkConfig.StreamInactivityTimeoutInSeconds, + ) + } +} + +// Responses performs a responses request to the Vertex API. +func (provider *VertexProvider) Responses(ctx context.Context, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + + response := chatResponse.ToBifrostResponsesResponse() + response.ExtraFields.RequestType = schemas.ResponsesRequest + response.ExtraFields.Provider = provider.GetProviderKey() + response.ExtraFields.ModelRequested = request.Model + + return response, nil +} + +// ResponsesStream performs a streaming responses request to the Vertex API. +func (provider *VertexProvider) ResponsesStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + ctx = context.WithValue(ctx, schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + key, + request.ToChatRequest(), + ) +} + +// Embedding generates embeddings for the given input text(s) using Vertex AI. +// All Vertex AI embedding models use the same response format regardless of the model type. +// Returns a BifrostResponse containing the embedding(s) and any error that occurred. +func (provider *VertexProvider) Embedding(ctx context.Context, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + + if key.VertexKeyConfig == nil { + return nil, providerUtils.NewConfigurationError("vertex key config is not set", providerName) + } + + projectID := key.VertexKeyConfig.ProjectID + if projectID == "" { + return nil, providerUtils.NewConfigurationError("project ID is not set", providerName) + } + + region := key.VertexKeyConfig.Region + if region == "" { + return nil, providerUtils.NewConfigurationError("region is not set in key config", providerName) + } + + jsonBody, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + request, + func() (any, error) { return ToVertexEmbeddingRequest(request), nil }, + providerName) + if bifrostErr != nil { + return nil, bifrostErr + } + + // Build the native Vertex embedding API endpoint + url := fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict", + key.VertexKeyConfig.Region, key.VertexKeyConfig.ProjectID, key.VertexKeyConfig.Region, request.Model) + + // Create HTTP request for streaming + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(url) + req.Header.SetContentType("application/json") + + // Set any extra headers from network config + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + + // Getting oauth2 token + tokenSource, err := getAuthTokenSource(key) + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error creating auth token source", err, schemas.Vertex) + } + token, err := tokenSource.Token() + if err != nil { + return nil, providerUtils.NewBifrostOperationError("error getting token", err, schemas.Vertex) + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + + req.SetBody(jsonBody) + + // Set any extra headers from network config + + // Make the request + latency, bifrostErr := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + if bifrostErr != nil { + if errors.Is(err, context.Canceled) { + return nil, &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Type: schemas.Ptr(schemas.RequestCancelled), + Message: schemas.ErrRequestCancelled, + Error: err, + }, + } + } + if errors.Is(err, fasthttp.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestTimedOut, err, provider.GetProviderKey()) + } + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err, provider.GetProviderKey()) + } + + if resp.StatusCode() != fasthttp.StatusOK { + // Remove client from pool for authentication/authorization errors + if resp.StatusCode() == fasthttp.StatusUnauthorized || resp.StatusCode() == fasthttp.StatusForbidden { + removeVertexClient(key.VertexKeyConfig.AuthCredentials) + } + + // Try to parse Vertex's error format + var vertexError map[string]interface{} + if err := sonic.Unmarshal(resp.Body(), &vertexError); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, schemas.Vertex) + } + + // Extract error message from Vertex's error format + errorMessage := "Unknown error" + if errorObj, exists := vertexError["error"]; exists { + if errorMap, ok := errorObj.(map[string]interface{}); ok { + if message, exists := errorMap["message"]; exists { + if msgStr, ok := message.(string); ok { + errorMessage = msgStr + } + } + } + } + + return nil, providerUtils.NewProviderAPIError(errorMessage, nil, resp.StatusCode(), schemas.Vertex, nil, nil) + } + + // Parse Vertex's native embedding response using typed response + var vertexResponse VertexEmbeddingResponse + if err := sonic.Unmarshal(resp.Body(), &vertexResponse); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseUnmarshal, err, schemas.Vertex) + } + + // Use centralized Vertex converter + bifrostResponse := vertexResponse.ToBifrostEmbeddingResponse() + + // Set ExtraFields + bifrostResponse.ExtraFields.Provider = providerName + bifrostResponse.ExtraFields.ModelRequested = request.Model + bifrostResponse.ExtraFields.RequestType = schemas.EmbeddingRequest + bifrostResponse.ExtraFields.Latency = latency.Milliseconds() + + // Set raw response if enabled + if providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) { + var rawResponseMap map[string]interface{} + if err := sonic.Unmarshal(resp.Body(), &rawResponseMap); err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRawResponseUnmarshal, err, providerName) + } + bifrostResponse.ExtraFields.RawResponse = rawResponseMap + } + + return bifrostResponse, nil +} + +// Speech is not supported by the Vertex provider. +func (provider *VertexProvider) Speech(ctx context.Context, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Vertex provider. +func (provider *VertexProvider) SpeechStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Vertex provider. +func (provider *VertexProvider) Transcription(ctx context.Context, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Vertex provider. +func (provider *VertexProvider) TranscriptionStream(ctx context.Context, postHookRunner schemas.PostHookRunner, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStream, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} diff --git a/core/schemas/account.go b/core/schemas/account.go index 7800c2dd3..dee76c12c 100644 --- a/core/schemas/account.go +++ b/core/schemas/account.go @@ -1,14 +1,53 @@ // Package schemas defines the core schemas and types used by the Bifrost system. package schemas +import "context" + // Key represents an API key and its associated configuration for a provider. // It contains the key value, supported models, and a weight for load balancing. type Key struct { - Value string `json:"value"` // The actual API key value - Models []string `json:"models"` // List of models this key can access - Weight float64 `json:"weight"` // Weight for load balancing between multiple keys + ID string `json:"id"` // The unique identifier for the key (used by bifrost to identify the key) + Name string `json:"name"` // The name of the key (used by users to identify the key, not used by bifrost) + Value string `json:"value"` // The actual API key value + Models []string `json:"models"` // List of models this key can access + Weight float64 `json:"weight"` // Weight for load balancing between multiple keys + AzureKeyConfig *AzureKeyConfig `json:"azure_key_config,omitempty"` // Azure-specific key configuration + VertexKeyConfig *VertexKeyConfig `json:"vertex_key_config,omitempty"` // Vertex-specific key configuration + BedrockKeyConfig *BedrockKeyConfig `json:"bedrock_key_config,omitempty"` // AWS Bedrock-specific key configuration +} + +// AzureKeyConfig represents the Azure-specific configuration. +// It contains Azure-specific settings required for service access and deployment management. +type AzureKeyConfig struct { + Endpoint string `json:"endpoint"` // Azure service endpoint URL + Deployments map[string]string `json:"deployments,omitempty"` // Mapping of model names to deployment names + APIVersion *string `json:"api_version,omitempty"` // Azure API version to use; defaults to "2024-10-21" +} + +// VertexKeyConfig represents the Vertex-specific configuration. +// It contains Vertex-specific settings required for authentication and service access. +type VertexKeyConfig struct { + ProjectID string `json:"project_id,omitempty"` + Region string `json:"region,omitempty"` + AuthCredentials string `json:"auth_credentials,omitempty"` } +// NOTE: To use Vertex IAM role authentication, set AuthCredentials to empty string. + +// BedrockKeyConfig represents the AWS Bedrock-specific configuration. +// It contains AWS-specific settings required for authentication and service access. +type BedrockKeyConfig struct { + AccessKey string `json:"access_key,omitempty"` // AWS access key for authentication + SecretKey string `json:"secret_key,omitempty"` // AWS secret access key for authentication + SessionToken *string `json:"session_token,omitempty"` // AWS session token for temporary credentials + Region *string `json:"region,omitempty"` // AWS region for service access + ARN *string `json:"arn,omitempty"` // Amazon Resource Name for resource identification + Deployments map[string]string `json:"deployments,omitempty"` // Mapping of model identifiers to inference profiles +} + +// NOTE: To use Bedrock IAM role authentication, set both AccessKey and SecretKey to empty strings. +// To use Bedrock API Key authentication, set Value in Key struct instead. + // Account defines the interface for managing provider accounts and their configurations. // It provides methods to access provider-specific settings, API keys, and configurations. type Account interface { @@ -18,7 +57,10 @@ type Account interface { // GetKeysForProvider returns the API keys configured for a specific provider. // The keys include their values, supported models, and weights for load balancing. - GetKeysForProvider(providerKey ModelProvider) ([]Key, error) + // The context can carry data from any source that sets values before the Bifrost request, + // including but not limited to plugin pre-hooks, application logic, or any in app middleware sharing the context. + // This enables dynamic key selection based on any context values present during the request. + GetKeysForProvider(ctx *context.Context, providerKey ModelProvider) ([]Key, error) // GetConfigForProvider returns the configuration for a specific provider. // This includes network settings, authentication details, and other provider-specific diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 4e3f06041..8ef7633b6 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -1,10 +1,20 @@ // Package schemas defines the core schemas and types used by the Bifrost system. package schemas +import ( + "context" + "encoding/json" + "errors" + + "github.com/bytedance/sonic" +) + const ( - DefaultInitialPoolSize = 100 + DefaultInitialPoolSize = 5000 ) +type KeySelector func(ctx *context.Context, keys []Key, providerKey ModelProvider, model string) (Key, error) + // BifrostConfig represents the configuration for initializing a Bifrost instance. // It contains the necessary components for setting up the system including account details, // plugins, logging, and initial pool size. @@ -12,289 +22,347 @@ type BifrostConfig struct { Account Account Plugins []Plugin Logger Logger - InitialPoolSize int // Initial pool size for sync pools in Bifrost. Higher values will reduce memory allocations but will increase memory usage. - DropExcessRequests bool // If true, in cases where the queue is full, requests will not wait for the queue to be empty and will be dropped instead. + InitialPoolSize int // Initial pool size for sync pools in Bifrost. Higher values will reduce memory allocations but will increase memory usage. + DropExcessRequests bool // If true, in cases where the queue is full, requests will not wait for the queue to be empty and will be dropped instead. + MCPConfig *MCPConfig // MCP (Model Context Protocol) configuration for tool integration + KeySelector KeySelector // Custom key selector function } -// ModelChatMessageRole represents the role of a chat message -type ModelChatMessageRole string - -const ( - RoleAssistant ModelChatMessageRole = "assistant" - RoleUser ModelChatMessageRole = "user" - RoleSystem ModelChatMessageRole = "system" - RoleChatbot ModelChatMessageRole = "chatbot" - RoleTool ModelChatMessageRole = "tool" -) - // ModelProvider represents the different AI model providers supported by Bifrost. type ModelProvider string const ( - OpenAI ModelProvider = "openai" - Azure ModelProvider = "azure" - Anthropic ModelProvider = "anthropic" - Bedrock ModelProvider = "bedrock" - Cohere ModelProvider = "cohere" + OpenAI ModelProvider = "openai" + Azure ModelProvider = "azure" + Anthropic ModelProvider = "anthropic" + Bedrock ModelProvider = "bedrock" + Cohere ModelProvider = "cohere" + Vertex ModelProvider = "vertex" + Mistral ModelProvider = "mistral" + Ollama ModelProvider = "ollama" + Groq ModelProvider = "groq" + SGL ModelProvider = "sgl" + Parasail ModelProvider = "parasail" + Perplexity ModelProvider = "perplexity" + Cerebras ModelProvider = "cerebras" + Gemini ModelProvider = "gemini" + OpenRouter ModelProvider = "openrouter" ) -//* Request Structs - -// RequestInput represents the input for a model request, which can be either -// a text completion or a chat completion, but either one must be provided. -type RequestInput struct { - TextCompletionInput *string - ChatCompletionInput *[]Message +// SupportedBaseProviders is the list of base providers allowed for custom providers. +var SupportedBaseProviders = []ModelProvider{ + Anthropic, + Bedrock, + Cohere, + Gemini, + OpenAI, } -// BifrostRequest represents a request to be processed by Bifrost. -// It must be provided when calling the Bifrost for text completion or chat completion. -// It contains the model identifier, input data, and parameters for the request. -type BifrostRequest struct { - Model string - Input RequestInput - Params *ModelParameters - - // Fallbacks are tried in order, the first one to succeed is returned - // Provider config must be available for each fallback's provider in account's GetConfigForProvider, - // else it will be skipped. - Fallbacks []Fallback +// StandardProviders is the list of all built-in (non-custom) providers. +var StandardProviders = []ModelProvider{ + Anthropic, + Azure, + Bedrock, + Cerebras, + Cohere, + Gemini, + Groq, + Mistral, + Ollama, + OpenAI, + Parasail, + Perplexity, + SGL, + Vertex, + OpenRouter, } -// Fallback represents a fallback model to be used if the primary model is not available. -type Fallback struct { - Provider ModelProvider - Model string -} +// RequestType represents the type of request being made to a provider. +type RequestType string -// ModelParameters represents the parameters that can be used to configure -// your request to the model. Bifrost follows a standard set of parameters which -// mapped to the provider's parameters. -type ModelParameters struct { - ToolChoice *ToolChoice `json:"tool_choice,omitempty"` - Tools *[]Tool `json:"tools,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` // Controls randomness in the output - TopP *float64 `json:"top_p,omitempty"` // Controls diversity via nucleus sampling - TopK *int `json:"top_k,omitempty"` // Controls diversity via top-k sampling - MaxTokens *int `json:"max_tokens,omitempty"` // Maximum number of tokens to generate - StopSequences *[]string `json:"stop_sequences,omitempty"` // Sequences that stop generation - PresencePenalty *float64 `json:"presence_penalty,omitempty"` // Penalizes repeated tokens - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // Penalizes frequent tokens - ParallelToolCalls *bool `json:"parallel_tool_calls"` // Enables parallel tool calls - - // Dynamic parameters that can be provider-specific, they are directly - // added to the request as is. - ExtraParams map[string]interface{} `json:"-"` -} - -// FunctionParameters represents the parameters for a function definition. -type FunctionParameters struct { - Type string `json:"type,"` // Type of the parameters - Description *string `json:"description,omitempty"` // Description of the parameters - Required []string `json:"required"` // Required parameter names - Properties map[string]interface{} `json:"properties"` // Parameter properties -} - -// Function represents a function that can be called by the model. -type Function struct { - Name string `json:"name"` // Name of the function - Description string `json:"description"` // Description of the function - Parameters FunctionParameters `json:"parameters"` // Parameters of the function -} - -// Tool represents a tool that can be used with the model. -type Tool struct { - ID *string `json:"id,omitempty"` // Optional tool identifier - Type string `json:"type"` // Type of the tool - Function Function `json:"function"` // Function definition -} +const ( + ListModelsRequest RequestType = "list_models" + TextCompletionRequest RequestType = "text_completion" + TextCompletionStreamRequest RequestType = "text_completion_stream" + ChatCompletionRequest RequestType = "chat_completion" + ChatCompletionStreamRequest RequestType = "chat_completion_stream" + ResponsesRequest RequestType = "responses" + ResponsesStreamRequest RequestType = "responses_stream" + EmbeddingRequest RequestType = "embedding" + SpeechRequest RequestType = "speech" + SpeechStreamRequest RequestType = "speech_stream" + TranscriptionRequest RequestType = "transcription" + TranscriptionStreamRequest RequestType = "transcription_stream" +) -// Combined tool choices for all providers, make sure to check the provider's -// documentation to see which tool choices are supported. -type ToolChoiceType string +// BifrostContextKey is a type for context keys used in Bifrost. +type BifrostContextKey string +// BifrostContextKeyRequestType is a context key for the request type. const ( - // ToolChoiceNone means no tool will be called - ToolChoiceNone ToolChoiceType = "none" - // ToolChoiceAuto means the model can choose whether to call a tool - ToolChoiceAuto ToolChoiceType = "auto" - // ToolChoiceAny means any tool can be called - ToolChoiceAny ToolChoiceType = "any" - // ToolChoiceTool means a specific tool must be called - ToolChoiceTool ToolChoiceType = "tool" - // ToolChoiceRequired means a tool must be called - ToolChoiceRequired ToolChoiceType = "required" + BifrostContextKeyVirtualKey BifrostContextKey = "x-bf-vk" // string + BifrostContextKeyRequestID BifrostContextKey = "request-id" // string + BifrostContextKeyFallbackRequestID BifrostContextKey = "fallback-request-id" // string + BifrostContextKeyDirectKey BifrostContextKey = "bifrost-direct-key" // Key struct + BifrostContextKeySelectedKeyID BifrostContextKey = "bifrost-selected-key-id" // string (to store the selected key ID (set by bifrost)) + BifrostContextKeySelectedKeyName BifrostContextKey = "bifrost-selected-key-name" // string (to store the selected key name (set by bifrost)) + BifrostContextKeyNumberOfRetries BifrostContextKey = "bifrost-number-of-retries" // int (to store the number of retries (set by bifrost)) + BifrostContextKeyFallbackIndex BifrostContextKey = "bifrost-fallback-index" // int (to store the fallback index (set by bifrost)) 0 for primary, 1 for first fallback, etc. + BifrostContextKeyStreamEndIndicator BifrostContextKey = "bifrost-stream-end-indicator" // bool (set by bifrost) + BifrostContextKeySkipKeySelection BifrostContextKey = "bifrost-skip-key-selection" // bool (will pass an empty key to the provider) + BifrostContextKeyExtraHeaders BifrostContextKey = "bifrost-extra-headers" // map[string]string + BifrostContextKeyURLPath BifrostContextKey = "bifrost-extra-url-path" // string + BifrostContextKeyUseRawRequestBody BifrostContextKey = "bifrost-use-raw-request-body" // bool + BifrostContextKeySendBackRawResponse BifrostContextKey = "bifrost-send-back-raw-response" // bool + BifrostContextKeyIsResponsesToChatCompletionFallback BifrostContextKey = "bifrost-is-responses-to-chat-completion-fallback" // bool (set by bifrost) ) -// ToolChoiceFunction represents a specific function to be called. -type ToolChoiceFunction struct { - Name string `json:"name"` // Name of the function to call -} +// NOTE: for custom plugin implementation dealing with streaming short circuit, +// make sure to mark BifrostContextKeyStreamEndIndicator as true at the end of the stream. -// ToolChoice represents how a tool should be chosen for a request. -type ToolChoice struct { - Type ToolChoiceType `json:"type"` // Type of tool choice - Function ToolChoiceFunction `json:"function"` // Function to call if type is ToolChoiceTool -} +//* Request Structs -// Message represents a single message in a chat conversation. -type Message struct { - Role ModelChatMessageRole `json:"role"` - Content *string `json:"content,omitempty"` - ImageContent *ImageContent `json:"image_content,omitempty"` - ToolCalls *[]Tool `json:"tool_calls,omitempty"` +// Fallback represents a fallback model to be used if the primary model is not available. +type Fallback struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` } -// ImageContent represents image data in a message. -type ImageContent struct { - Type *string `json:"type"` - URL string `json:"url"` - MediaType *string `json:"media_type"` - Detail *string `json:"detail"` +// BifrostRequest is the request struct for all bifrost requests. +// only ONE of the following fields should be set: +// - ListModelsRequest +// - TextCompletionRequest +// - ChatRequest +// - ResponsesRequest +// - EmbeddingRequest +// - SpeechRequest +// - TranscriptionRequest +// NOTE: Bifrost Request is submitted back to pool after every use so DO NOT keep references to this struct after use, especially in go routines. +type BifrostRequest struct { + RequestType RequestType + + ListModelsRequest *BifrostListModelsRequest + TextCompletionRequest *BifrostTextCompletionRequest + ChatRequest *BifrostChatRequest + ResponsesRequest *BifrostResponsesRequest + EmbeddingRequest *BifrostEmbeddingRequest + SpeechRequest *BifrostSpeechRequest + TranscriptionRequest *BifrostTranscriptionRequest } -//* Response Structs - -// BifrostResponse represents the complete result from any bifrost request. -type BifrostResponse struct { - ID string `json:"id,omitempty"` - Object string `json:"object,omitempty"` // text.completion or chat.completion - Choices []BifrostResponseChoice `json:"choices,omitempty"` - Model string `json:"model,omitempty"` - Created int `json:"created,omitempty"` // The Unix timestamp (in seconds). - ServiceTier *string `json:"service_tier,omitempty"` - SystemFingerprint *string `json:"system_fingerprint,omitempty"` - Usage LLMUsage `json:"usage,omitempty"` - ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +// GetRequestFields returns the provider, model, and fallbacks from the request. +func (br *BifrostRequest) GetRequestFields() (provider ModelProvider, model string, fallbacks []Fallback) { + switch { + case br.TextCompletionRequest != nil: + return br.TextCompletionRequest.Provider, br.TextCompletionRequest.Model, br.TextCompletionRequest.Fallbacks + case br.ChatRequest != nil: + return br.ChatRequest.Provider, br.ChatRequest.Model, br.ChatRequest.Fallbacks + case br.ResponsesRequest != nil: + return br.ResponsesRequest.Provider, br.ResponsesRequest.Model, br.ResponsesRequest.Fallbacks + case br.EmbeddingRequest != nil: + return br.EmbeddingRequest.Provider, br.EmbeddingRequest.Model, br.EmbeddingRequest.Fallbacks + case br.SpeechRequest != nil: + return br.SpeechRequest.Provider, br.SpeechRequest.Model, br.SpeechRequest.Fallbacks + case br.TranscriptionRequest != nil: + return br.TranscriptionRequest.Provider, br.TranscriptionRequest.Model, br.TranscriptionRequest.Fallbacks + } + + return "", "", nil } -// LLMUsage represents token usage information -type LLMUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - TokenDetails *TokenDetails `json:"prompt_tokens_details,omitempty"` - CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` +func (br *BifrostRequest) SetProvider(provider ModelProvider) { + switch { + case br.TextCompletionRequest != nil: + br.TextCompletionRequest.Provider = provider + case br.ChatRequest != nil: + br.ChatRequest.Provider = provider + case br.ResponsesRequest != nil: + br.ResponsesRequest.Provider = provider + case br.EmbeddingRequest != nil: + br.EmbeddingRequest.Provider = provider + case br.SpeechRequest != nil: + br.SpeechRequest.Provider = provider + case br.TranscriptionRequest != nil: + br.TranscriptionRequest.Provider = provider + } } -// TokenDetails provides detailed information about token usage. -// It is not provided by all model providers. -type TokenDetails struct { - CachedTokens int `json:"cached_tokens,omitempty"` - AudioTokens int `json:"audio_tokens,omitempty"` +func (br *BifrostRequest) SetModel(model string) { + switch { + case br.TextCompletionRequest != nil: + br.TextCompletionRequest.Model = model + case br.ChatRequest != nil: + br.ChatRequest.Model = model + case br.ResponsesRequest != nil: + br.ResponsesRequest.Model = model + case br.EmbeddingRequest != nil: + br.EmbeddingRequest.Model = model + case br.SpeechRequest != nil: + br.SpeechRequest.Model = model + case br.TranscriptionRequest != nil: + br.TranscriptionRequest.Model = model + } } -// CompletionTokensDetails provides detailed information about completion token usage. -// It is not provided by all model providers. -type CompletionTokensDetails struct { - ReasoningTokens int `json:"reasoning_tokens,omitempty"` - AudioTokens int `json:"audio_tokens,omitempty"` - AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` - RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` +func (br *BifrostRequest) SetFallbacks(fallbacks []Fallback) { + switch { + case br.TextCompletionRequest != nil: + br.TextCompletionRequest.Fallbacks = fallbacks + case br.ChatRequest != nil: + br.ChatRequest.Fallbacks = fallbacks + case br.ResponsesRequest != nil: + br.ResponsesRequest.Fallbacks = fallbacks + case br.EmbeddingRequest != nil: + br.EmbeddingRequest.Fallbacks = fallbacks + case br.SpeechRequest != nil: + br.SpeechRequest.Fallbacks = fallbacks + case br.TranscriptionRequest != nil: + br.TranscriptionRequest.Fallbacks = fallbacks + } } -// BilledLLMUsage represents the billing information for token usage. -type BilledLLMUsage struct { - PromptTokens *float64 `json:"prompt_tokens,omitempty"` - CompletionTokens *float64 `json:"completion_tokens,omitempty"` - SearchUnits *float64 `json:"search_units,omitempty"` - Classifications *float64 `json:"classifications,omitempty"` +func (br *BifrostRequest) SetRawRequestBody(rawRequestBody []byte) { + switch { + case br.TextCompletionRequest != nil: + br.TextCompletionRequest.RawRequestBody = rawRequestBody + case br.ChatRequest != nil: + br.ChatRequest.RawRequestBody = rawRequestBody + case br.ResponsesRequest != nil: + br.ResponsesRequest.RawRequestBody = rawRequestBody + case br.EmbeddingRequest != nil: + br.EmbeddingRequest.RawRequestBody = rawRequestBody + case br.SpeechRequest != nil: + br.SpeechRequest.RawRequestBody = rawRequestBody + case br.TranscriptionRequest != nil: + br.TranscriptionRequest.RawRequestBody = rawRequestBody + } } -// LogProb represents the log probability of a token. -type LogProb struct { - Bytes []int `json:"bytes,omitempty"` - LogProb float64 `json:"logprob"` - Token string `json:"token"` -} +//* Response Structs -// ContentLogProb represents log probability information for content. -type ContentLogProb struct { - Bytes []int `json:"bytes"` - LogProb float64 `json:"logprob"` - Token string `json:"token"` - TopLogProbs []LogProb `json:"top_logprobs"` +// BifrostResponse represents the complete result from any bifrost request. +type BifrostResponse struct { + TextCompletionResponse *BifrostTextCompletionResponse + ChatResponse *BifrostChatResponse + ResponsesResponse *BifrostResponsesResponse + ResponsesStreamResponse *BifrostResponsesStreamResponse + EmbeddingResponse *BifrostEmbeddingResponse + SpeechResponse *BifrostSpeechResponse + SpeechStreamResponse *BifrostSpeechStreamResponse + TranscriptionResponse *BifrostTranscriptionResponse + TranscriptionStreamResponse *BifrostTranscriptionStreamResponse } -// TextCompletionLogProb represents log probability information for text completion. -type TextCompletionLogProb struct { - TextOffset []int `json:"text_offset"` - TokenLogProbs []float64 `json:"token_logprobs"` - Tokens []string `json:"tokens"` - TopLogProbs []map[string]float64 `json:"top_logprobs"` +func (r *BifrostResponse) GetExtraFields() *BifrostResponseExtraFields { + switch { + case r.TextCompletionResponse != nil: + return &r.TextCompletionResponse.ExtraFields + case r.ChatResponse != nil: + return &r.ChatResponse.ExtraFields + case r.ResponsesResponse != nil: + return &r.ResponsesResponse.ExtraFields + case r.ResponsesStreamResponse != nil: + return &r.ResponsesStreamResponse.ExtraFields + case r.EmbeddingResponse != nil: + return &r.EmbeddingResponse.ExtraFields + case r.SpeechResponse != nil: + return &r.SpeechResponse.ExtraFields + case r.SpeechStreamResponse != nil: + return &r.SpeechStreamResponse.ExtraFields + case r.TranscriptionResponse != nil: + return &r.TranscriptionResponse.ExtraFields + case r.TranscriptionStreamResponse != nil: + return &r.TranscriptionStreamResponse.ExtraFields + } + + return &BifrostResponseExtraFields{} } -// LogProbs represents the log probabilities for different aspects of a response. -type LogProbs struct { - Content []ContentLogProb `json:"content,omitempty"` - Refusal []LogProb `json:"refusal,omitempty"` - Text TextCompletionLogProb `json:"text,omitempty"` +// BifrostResponseExtraFields contains additional fields in a response. +type BifrostResponseExtraFields struct { + RequestType RequestType `json:"request_type"` + Provider ModelProvider `json:"provider,omitempty"` + ModelRequested string `json:"model_requested,omitempty"` + ModelDeployment string `json:"model_deployment,omitempty"` // only present for providers which use model deployments (e.g. Azure, Bedrock) + Latency int64 `json:"latency"` // in milliseconds (for streaming responses this will be each chunk latency, and the last chunk latency will be the total latency) + ChunkIndex int `json:"chunk_index"` // used for streaming responses to identify the chunk index, will be 0 for non-streaming responses + RawResponse interface{} `json:"raw_response,omitempty"` + CacheDebug *BifrostCacheDebug `json:"cache_debug,omitempty"` } -// FunctionCall represents a call to a function. -type FunctionCall struct { - Name *string `json:"name"` - Arguments string `json:"arguments"` // stringified json as retured by OpenAI, might not be a valid JSON always -} +// BifrostCacheDebug represents debug information about the cache. +type BifrostCacheDebug struct { + CacheHit bool `json:"cache_hit"` -// ToolCall represents a tool call in a message -type ToolCall struct { - Type *string `json:"type,omitempty"` - ID *string `json:"id,omitempty"` - Function FunctionCall `json:"function"` -} + CacheID *string `json:"cache_id,omitempty"` + HitType *string `json:"hit_type,omitempty"` -// Citation represents a citation in a response. -type Citation struct { - StartIndex int `json:"start_index"` - EndIndex int `json:"end_index"` - Title string `json:"title"` - URL *string `json:"url,omitempty"` - Sources *interface{} `json:"sources,omitempty"` - Type *string `json:"type,omitempty"` -} + // Semantic cache only (provider, model, and input tokens will be present for semantic cache, even if cache is not hit) + ProviderUsed *string `json:"provider_used,omitempty"` + ModelUsed *string `json:"model_used,omitempty"` + InputTokens *int `json:"input_tokens,omitempty"` -// Annotation represents an annotation in a response. -type Annotation struct { - Type string `json:"type"` - Citation Citation `json:"url_citation"` + // Semantic cache only (only when cache is hit) + Threshold *float64 `json:"threshold,omitempty"` + Similarity *float64 `json:"similarity,omitempty"` } -// BifrostResponseChoiceMessage represents a choice in the completion response -type BifrostResponseChoiceMessage struct { - Role ModelChatMessageRole `json:"role"` - Content *string `json:"content,omitempty"` - Refusal *string `json:"refusal,omitempty"` - Annotations []Annotation `json:"annotations,omitempty"` - ToolCalls *[]ToolCall `json:"tool_calls,omitempty"` -} +const ( + RequestCancelled = "request_cancelled" +) -// BifrostResponseChoice represents a choice in the completion result -type BifrostResponseChoice struct { - Index int `json:"index"` - Message BifrostResponseChoiceMessage `json:"message"` - FinishReason *string `json:"finish_reason,omitempty"` - StopString *string `json:"stop,omitempty"` - LogProbs *LogProbs `json:"log_probs,omitempty"` +// BifrostStream represents a stream of responses from the Bifrost system. +// Either BifrostResponse or BifrostError will be non-nil. +type BifrostStream struct { + *BifrostTextCompletionResponse + *BifrostChatResponse + *BifrostResponsesStreamResponse + *BifrostSpeechStreamResponse + *BifrostTranscriptionStreamResponse + *BifrostError } -// BifrostResponseExtraFields contains additional fields in a response. -type BifrostResponseExtraFields struct { - Provider ModelProvider `json:"provider"` - Params ModelParameters `json:"model_params"` - Latency *float64 `json:"latency,omitempty"` - ChatHistory *[]BifrostResponseChoiceMessage `json:"chat_history,omitempty"` - BilledUsage *BilledLLMUsage `json:"billed_usage,omitempty"` - RawResponse interface{} `json:"raw_response"` +// MarshalJSON implements custom JSON marshaling for BifrostStream. +// This ensures that only the non-nil embedded struct is marshaled, +func (bs BifrostStream) MarshalJSON() ([]byte, error) { + if bs.BifrostTextCompletionResponse != nil { + return sonic.Marshal(bs.BifrostTextCompletionResponse) + } else if bs.BifrostChatResponse != nil { + return sonic.Marshal(bs.BifrostChatResponse) + } else if bs.BifrostResponsesStreamResponse != nil { + return sonic.Marshal(bs.BifrostResponsesStreamResponse) + } else if bs.BifrostSpeechStreamResponse != nil { + return sonic.Marshal(bs.BifrostSpeechStreamResponse) + } else if bs.BifrostTranscriptionStreamResponse != nil { + return sonic.Marshal(bs.BifrostTranscriptionStreamResponse) + } else if bs.BifrostError != nil { + return sonic.Marshal(bs.BifrostError) + } + // Return empty object if both are nil (shouldn't happen in practice) + return []byte("{}"), nil } // BifrostError represents an error from the Bifrost system. +// +// PLUGIN DEVELOPERS: When creating BifrostError in PreHook or PostHook, you can set AllowFallbacks: +// - AllowFallbacks = &true: Bifrost will try fallback providers if available +// - AllowFallbacks = &false: Bifrost will return this error immediately, no fallbacks +// - AllowFallbacks = nil: Treated as true by default (fallbacks allowed for resilience) type BifrostError struct { - EventID *string `json:"event_id,omitempty"` - Type *string `json:"type,omitempty"` - IsBifrostError bool `json:"is_bifrost_error"` - StatusCode *int `json:"status_code,omitempty"` - Error ErrorField `json:"error"` + EventID *string `json:"event_id,omitempty"` + Type *string `json:"type,omitempty"` + IsBifrostError bool `json:"is_bifrost_error"` + StatusCode *int `json:"status_code,omitempty"` + Error *ErrorField `json:"error"` + AllowFallbacks *bool `json:"-"` // Optional: Controls fallback behavior (nil = true by default) + StreamControl *StreamControl `json:"-"` // Optional: Controls stream behavior + ExtraFields BifrostErrorExtraFields `json:"extra_fields,omitempty"` +} + +// StreamControl represents stream control options. +type StreamControl struct { + LogError *bool `json:"log_error,omitempty"` // Optional: Controls logging of error + SkipStream *bool `json:"skip_stream,omitempty"` // Optional: Controls skipping of stream chunk } // ErrorField represents detailed error information. @@ -302,7 +370,53 @@ type ErrorField struct { Type *string `json:"type,omitempty"` Code *string `json:"code,omitempty"` Message string `json:"message"` - Error error `json:"error,omitempty"` + Error error `json:"-"` Param interface{} `json:"param,omitempty"` EventID *string `json:"event_id,omitempty"` } + +// MarshalJSON implements custom JSON marshaling for ErrorField. +// It converts the Error field (error interface) to a string. +func (e *ErrorField) MarshalJSON() ([]byte, error) { + type Alias ErrorField + aux := &struct { + Error *string `json:"error,omitempty"` + *Alias + }{ + Alias: (*Alias)(e), + } + + if e.Error != nil { + errStr := e.Error.Error() + aux.Error = &errStr + } + + return json.Marshal(aux) +} + +func (e *ErrorField) UnmarshalJSON(data []byte) error { + type Alias ErrorField + aux := &struct { + Error *string `json:"error,omitempty"` + *Alias + }{ + Alias: (*Alias)(e), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + if aux.Error != nil { + e.Error = errors.New(*aux.Error) + } + + return nil +} + +// BifrostErrorExtraFields contains additional fields in an error response. +type BifrostErrorExtraFields struct { + Provider ModelProvider `json:"provider"` + ModelRequested string `json:"model_requested"` + RequestType RequestType `json:"request_type"` +} diff --git a/core/schemas/chatcompletions.go b/core/schemas/chatcompletions.go new file mode 100644 index 000000000..078780258 --- /dev/null +++ b/core/schemas/chatcompletions.go @@ -0,0 +1,624 @@ +package schemas + +import ( + "bytes" + "fmt" + + "github.com/bytedance/sonic" +) + +// BifrostChatRequest is the request struct for chat completion requests +type BifrostChatRequest struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` + Input []ChatMessage `json:"input,omitempty"` + Params *ChatParameters `json:"params,omitempty"` + Fallbacks []Fallback `json:"fallbacks,omitempty"` + RawRequestBody []byte `json:"-"` // set bifrost-use-raw-request-body to true in ctx to use the raw request body. Bifrost will directly send this to the downstream provider. +} + +func (r *BifrostChatRequest) GetRawRequestBody() []byte { + return r.RawRequestBody +} + +// BifrostChatResponse represents the complete result from a chat completion request. +type BifrostChatResponse struct { + ID string `json:"id"` + Choices []BifrostResponseChoice `json:"choices"` + Created int `json:"created"` // The Unix timestamp (in seconds). + Model string `json:"model"` + Object string `json:"object"` // "chat.completion" or "chat.completion.chunk" + ServiceTier string `json:"service_tier"` + SystemFingerprint string `json:"system_fingerprint"` + Usage *BifrostLLMUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` + + // Perplexity-specific fields + SearchResults []SearchResult `json:"search_results,omitempty"` + Videos []VideoResult `json:"videos,omitempty"` + Citations []string `json:"citations,omitempty"` +} + +// ToTextCompletionResponse converts a BifrostChatResponse to a BifrostTextCompletionResponse +func (cr *BifrostChatResponse) ToTextCompletionResponse() *BifrostTextCompletionResponse { + if cr == nil { + return nil + } + + if len(cr.Choices) == 0 { + return &BifrostTextCompletionResponse{ + ID: cr.ID, + Model: cr.Model, + Object: "text_completion", + SystemFingerprint: cr.SystemFingerprint, + Usage: cr.Usage, + ExtraFields: BifrostResponseExtraFields{ + RequestType: TextCompletionRequest, + ChunkIndex: cr.ExtraFields.ChunkIndex, + Provider: cr.ExtraFields.Provider, + ModelRequested: cr.ExtraFields.ModelRequested, + Latency: cr.ExtraFields.Latency, + RawResponse: cr.ExtraFields.RawResponse, + CacheDebug: cr.ExtraFields.CacheDebug, + }, + } + } + + choice := cr.Choices[0] + + // Handle streaming response choice + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil { + return &BifrostTextCompletionResponse{ + ID: cr.ID, + Model: cr.Model, + Object: "text_completion", + SystemFingerprint: cr.SystemFingerprint, + Choices: []BifrostResponseChoice{ + { + Index: 0, + TextCompletionResponseChoice: &TextCompletionResponseChoice{ + Text: choice.ChatStreamResponseChoice.Delta.Content, + }, + FinishReason: choice.FinishReason, + LogProbs: choice.LogProbs, + }, + }, + Usage: cr.Usage, + ExtraFields: BifrostResponseExtraFields{ + RequestType: TextCompletionRequest, + ChunkIndex: cr.ExtraFields.ChunkIndex, + Provider: cr.ExtraFields.Provider, + ModelRequested: cr.ExtraFields.ModelRequested, + Latency: cr.ExtraFields.Latency, + RawResponse: cr.ExtraFields.RawResponse, + CacheDebug: cr.ExtraFields.CacheDebug, + }, + } + } + + // Handle non-streaming response choice + if choice.ChatNonStreamResponseChoice != nil { + msg := choice.ChatNonStreamResponseChoice.Message + var textContent *string + if msg != nil && msg.Content != nil && msg.Content.ContentStr != nil { + textContent = msg.Content.ContentStr + } + return &BifrostTextCompletionResponse{ + ID: cr.ID, + Model: cr.Model, + Object: "text_completion", + SystemFingerprint: cr.SystemFingerprint, + Choices: []BifrostResponseChoice{ + { + Index: 0, + TextCompletionResponseChoice: &TextCompletionResponseChoice{ + Text: textContent, + }, + FinishReason: choice.FinishReason, + LogProbs: choice.LogProbs, + }, + }, + Usage: cr.Usage, + ExtraFields: BifrostResponseExtraFields{ + RequestType: TextCompletionRequest, + ChunkIndex: cr.ExtraFields.ChunkIndex, + Provider: cr.ExtraFields.Provider, + ModelRequested: cr.ExtraFields.ModelRequested, + Latency: cr.ExtraFields.Latency, + RawResponse: cr.ExtraFields.RawResponse, + CacheDebug: cr.ExtraFields.CacheDebug, + }, + } + } + + // Fallback case - return basic response structure + return &BifrostTextCompletionResponse{ + ID: cr.ID, + Model: cr.Model, + Object: "text_completion", + SystemFingerprint: cr.SystemFingerprint, + Usage: cr.Usage, + ExtraFields: BifrostResponseExtraFields{ + RequestType: TextCompletionRequest, + ChunkIndex: cr.ExtraFields.ChunkIndex, + Provider: cr.ExtraFields.Provider, + ModelRequested: cr.ExtraFields.ModelRequested, + Latency: cr.ExtraFields.Latency, + RawResponse: cr.ExtraFields.RawResponse, + CacheDebug: cr.ExtraFields.CacheDebug, + }, + } +} + +// ChatParameters represents the parameters for a chat completion. +type ChatParameters struct { + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // Penalizes frequent tokens + LogitBias *map[string]float64 `json:"logit_bias,omitempty"` // Bias for logit values + LogProbs *bool `json:"logprobs,omitempty"` // Number of logprobs to return + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` // Maximum number of tokens to generate + Metadata *map[string]any `json:"metadata,omitempty"` // Metadata to be returned with the response + Modalities []string `json:"modalities,omitempty"` // Modalities to be returned with the response + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // Penalizes repeated tokens + PromptCacheKey *string `json:"prompt_cache_key,omitempty"` // Prompt cache key + ReasoningEffort *string `json:"reasoning_effort,omitempty"` // "minimal" | "low" | "medium" | "high" + ResponseFormat *interface{} `json:"response_format,omitempty"` // Format for the response + SafetyIdentifier *string `json:"safety_identifier,omitempty"` // Safety identifier + Seed *int `json:"seed,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` + StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"` + Stop []string `json:"stop,omitempty"` + Store *bool `json:"store,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + TopP *float64 `json:"top_p,omitempty"` // Controls diversity via nucleus sampling + ToolChoice *ChatToolChoice `json:"tool_choice,omitempty"` // Whether to call a tool + Tools []ChatTool `json:"tools,omitempty"` // Tools to use + User *string `json:"user,omitempty"` // User identifier for tracking + Verbosity *string `json:"verbosity,omitempty"` // "low" | "medium" | "high" + + // Dynamic parameters that can be provider-specific, they are directly + // added to the request as is. + ExtraParams map[string]interface{} `json:"-"` +} + +// ChatStreamOptions represents the stream options for a chat completion. +type ChatStreamOptions struct { + IncludeObfuscation *bool `json:"include_obfuscation,omitempty"` + IncludeUsage *bool `json:"include_usage,omitempty"` // Bifrost marks this as true by default +} + +// ChatToolType represents the type of tool. +type ChatToolType string + +// ChatToolType values +const ( + ChatToolTypeFunction ChatToolType = "function" + ChatToolTypeCustom ChatToolType = "custom" +) + +// ChatTool represents a tool definition. +type ChatTool struct { + Type ChatToolType `json:"type"` + Function *ChatToolFunction `json:"function,omitempty"` // Function definition + Custom *ChatToolCustom `json:"custom,omitempty"` // Custom tool definition +} + +// ChatToolFunction represents a function definition. +type ChatToolFunction struct { + Name string `json:"name"` // Name of the function + Description *string `json:"description,omitempty"` // Description of the parameters + Parameters *ToolFunctionParameters `json:"parameters,omitempty"` // A JSON schema object describing the parameters + Strict *bool `json:"strict,omitempty"` // Whether to enforce strict parameter validation +} + +// ToolFunctionParameters represents the parameters for a function definition. +type ToolFunctionParameters struct { + Type string `json:"type"` // Type of the parameters + Description *string `json:"description,omitempty"` // Description of the parameters + Required []string `json:"required,omitempty"` // Required parameter names + Properties *map[string]interface{} `json:"properties,omitempty"` // Parameter properties + Enum []string `json:"enum,omitempty"` // Enum values for the parameters + AdditionalProperties *bool `json:"additionalProperties,omitempty"` // Whether to allow additional properties +} + +type ChatToolCustom struct { + Format *ChatToolCustomFormat `json:"format,omitempty"` // The input format +} + +type ChatToolCustomFormat struct { + Type string `json:"type"` // always "text" + Grammar *ChatToolCustomGrammarFormat `json:"grammar,omitempty"` +} + +// ChatToolCustomGrammarFormat - A grammar defined by the user +type ChatToolCustomGrammarFormat struct { + Definition string `json:"definition"` // The grammar definition + Syntax string `json:"syntax"` // "lark" | "regex" +} + +// ChatToolChoiceType for all providers, make sure to check the provider's +// documentation to see which tool choices are supported. +type ChatToolChoiceType string + +// ChatToolChoiceType values +const ( + ChatToolChoiceTypeNone ChatToolChoiceType = "none" + ChatToolChoiceTypeAny ChatToolChoiceType = "any" + ChatToolChoiceTypeRequired ChatToolChoiceType = "required" + // ChatToolChoiceTypeFunction means a specific tool must be called + ChatToolChoiceTypeFunction ChatToolChoiceType = "function" + // ChatToolChoiceTypeAllowedTools means a specific tool must be called + ChatToolChoiceTypeAllowedTools ChatToolChoiceType = "allowed_tools" + // ChatToolChoiceTypeCustom means a custom tool must be called + ChatToolChoiceTypeCustom ChatToolChoiceType = "custom" +) + +// ChatToolChoiceStruct represents a tool choice. +type ChatToolChoiceStruct struct { + Type ChatToolChoiceType `json:"type"` // Type of tool choice + Function ChatToolChoiceFunction `json:"function,omitempty"` // Function to call if type is ToolChoiceTypeFunction + Custom ChatToolChoiceCustom `json:"custom,omitempty"` // Custom tool to call if type is ToolChoiceTypeCustom + AllowedTools ChatToolChoiceAllowedTools `json:"allowed_tools,omitempty"` // Allowed tools to call if type is ToolChoiceTypeAllowedTools +} + +type ChatToolChoice struct { + ChatToolChoiceStr *string + ChatToolChoiceStruct *ChatToolChoiceStruct +} + +// MarshalJSON implements custom JSON marshalling for ChatMessageContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (ctc ChatToolChoice) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if ctc.ChatToolChoiceStr != nil && ctc.ChatToolChoiceStruct != nil { + return nil, fmt.Errorf("both ChatToolChoiceStr, ChatToolChoiceStruct are set; only one should be non-nil") + } + + if ctc.ChatToolChoiceStr != nil { + return sonic.Marshal(ctc.ChatToolChoiceStr) + } + if ctc.ChatToolChoiceStruct != nil { + return sonic.Marshal(ctc.ChatToolChoiceStruct) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ChatMessageContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (ctc *ChatToolChoice) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var toolChoiceStr string + if err := sonic.Unmarshal(data, &toolChoiceStr); err == nil { + ctc.ChatToolChoiceStr = &toolChoiceStr + ctc.ChatToolChoiceStruct = nil + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var chatToolChoice ChatToolChoiceStruct + if err := sonic.Unmarshal(data, &chatToolChoice); err == nil { + ctc.ChatToolChoiceStr = nil + ctc.ChatToolChoiceStruct = &chatToolChoice + return nil + } + + return fmt.Errorf("tool_choice field is neither a string nor a ChatToolChoiceStruct object") +} + +// ChatToolChoiceFunction represents a function choice. +type ChatToolChoiceFunction struct { + Name string `json:"name"` +} + +// ChatToolChoiceCustom represents a custom choice. +type ChatToolChoiceCustom struct { + Name string `json:"name"` +} + +// ChatToolChoiceAllowedTools represents a allowed tools choice. +type ChatToolChoiceAllowedTools struct { + Mode string `json:"mode"` // "auto" | "required" + Tools []ChatToolChoiceAllowedToolsTool `json:"tools"` +} + +// ChatToolChoiceAllowedToolsTool represents a allowed tools tool. +type ChatToolChoiceAllowedToolsTool struct { + Type string `json:"type"` // "function" + Function ChatToolChoiceFunction `json:"function,omitempty"` +} + +// ChatMessageRole represents the role of a chat message +type ChatMessageRole string + +// ChatMessageRole values +const ( + ChatMessageRoleAssistant ChatMessageRole = "assistant" + ChatMessageRoleUser ChatMessageRole = "user" + ChatMessageRoleSystem ChatMessageRole = "system" + ChatMessageRoleTool ChatMessageRole = "tool" + ChatMessageRoleDeveloper ChatMessageRole = "developer" +) + +// ChatMessage represents a message in a chat conversation. +type ChatMessage struct { + Name *string `json:"name,omitempty"` // for chat completions + Role ChatMessageRole `json:"role,omitempty"` + Content *ChatMessageContent `json:"content,omitempty"` + + // Embedded pointer structs - when non-nil, their exported fields are flattened into the top-level JSON object + // IMPORTANT: Only one of the following can be non-nil at a time, otherwise the JSON marshalling will override the common fields + *ChatToolMessage + *ChatAssistantMessage +} + +// ChatMessageContent represents a content in a message. +type ChatMessageContent struct { + ContentStr *string + ContentBlocks []ChatContentBlock +} + +// MarshalJSON implements custom JSON marshalling for ChatMessageContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (mc ChatMessageContent) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if mc.ContentStr != nil && mc.ContentBlocks != nil { + return nil, fmt.Errorf("both Content string and Content blocks are set; only one should be non-nil") + } + + if mc.ContentStr != nil { + return sonic.Marshal(*mc.ContentStr) + } + if mc.ContentBlocks != nil { + return sonic.Marshal(mc.ContentBlocks) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ChatMessageContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (mc *ChatMessageContent) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + mc.ContentStr = nil + mc.ContentBlocks = nil + return nil + } + + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + mc.ContentStr = &stringContent + mc.ContentBlocks = nil + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var arrayContent []ChatContentBlock + if err := sonic.Unmarshal(data, &arrayContent); err == nil { + mc.ContentBlocks = arrayContent + mc.ContentStr = nil + return nil + } + + return fmt.Errorf("content field is neither a string nor an array of Content blocks") +} + +// ChatContentBlockType represents the type of content block in a message. +type ChatContentBlockType string + +// ChatContentBlockType values +const ( + ChatContentBlockTypeText ChatContentBlockType = "text" + ChatContentBlockTypeImage ChatContentBlockType = "image_url" + ChatContentBlockTypeInputAudio ChatContentBlockType = "input_audio" + ChatContentBlockTypeFile ChatContentBlockType = "input_file" + ChatContentBlockTypeRefusal ChatContentBlockType = "refusal" +) + +// ChatContentBlock represents a content block in a message. +type ChatContentBlock struct { + Type ChatContentBlockType `json:"type"` + Text *string `json:"text,omitempty"` + Refusal *string `json:"refusal,omitempty"` + ImageURLStruct *ChatInputImage `json:"image_url,omitempty"` + InputAudio *ChatInputAudio `json:"input_audio,omitempty"` + File *ChatInputFile `json:"file,omitempty"` +} + +// ChatInputImage represents image data in a message. +type ChatInputImage struct { + URL string `json:"url"` + Detail *string `json:"detail,omitempty"` +} + +// ChatInputAudio represents audio data in a message. +// Data carries the audio payload as a string (e.g., data URL or provider-accepted encoded content). +// Format is optional (e.g., "wav", "mp3"); when nil, providers may attempt auto-detection. +type ChatInputAudio struct { + Data string `json:"data"` + Format *string `json:"format,omitempty"` +} + +// ChatInputFile represents a file in a message. +type ChatInputFile struct { + FileData *string `json:"file_data,omitempty"` // Base64 encoded file data + FileID *string `json:"file_id,omitempty"` // Reference to uploaded file + Filename *string `json:"filename,omitempty"` // Name of the file +} + +// ChatToolMessage represents a tool message in a chat conversation. +type ChatToolMessage struct { + ToolCallID *string `json:"tool_call_id,omitempty"` +} + +// ChatAssistantMessage represents a message in a chat conversation. +type ChatAssistantMessage struct { + Refusal *string `json:"refusal,omitempty"` + Annotations []ChatAssistantMessageAnnotation `json:"annotations,omitempty"` + ToolCalls []ChatAssistantMessageToolCall `json:"tool_calls,omitempty"` +} + +// ChatAssistantMessageAnnotation represents an annotation in a response. +type ChatAssistantMessageAnnotation struct { + Type string `json:"type"` + Citation ChatAssistantMessageAnnotationCitation `json:"url_citation"` +} + +// ChatAssistantMessageAnnotationCitation represents a citation in a response. +type ChatAssistantMessageAnnotationCitation struct { + StartIndex int `json:"start_index"` + EndIndex int `json:"end_index"` + Title string `json:"title"` + URL *string `json:"url,omitempty"` + Sources *interface{} `json:"sources,omitempty"` + Type *string `json:"type,omitempty"` +} + +// ChatAssistantMessageToolCall represents a tool call in a message +type ChatAssistantMessageToolCall struct { + Index uint16 `json:"index"` + Type *string `json:"type,omitempty"` + ID *string `json:"id,omitempty"` + Function ChatAssistantMessageToolCallFunction `json:"function"` +} + +// ChatAssistantMessageToolCallFunction represents a call to a function. +type ChatAssistantMessageToolCallFunction struct { + Name *string `json:"name"` + Arguments string `json:"arguments"` // stringified json as retured by OpenAI, might not be a valid JSON always +} + +// BifrostResponseChoice represents a choice in the completion result. +// This struct can represent either a streaming or non-streaming response choice. +// IMPORTANT: Only one of TextCompletionResponseChoice, NonStreamResponseChoice or StreamResponseChoice +// should be non-nil at a time. +type BifrostResponseChoice struct { + Index int `json:"index"` + FinishReason *string `json:"finish_reason,omitempty"` + LogProbs *BifrostLogProbs `json:"log_probs,omitempty"` + + *TextCompletionResponseChoice + *ChatNonStreamResponseChoice + *ChatStreamResponseChoice +} + +// BifrostLogProbs represents the log probabilities for different aspects of a response. +type BifrostLogProbs struct { + Content []ContentLogProb `json:"content,omitempty"` + Refusal []LogProb `json:"refusal,omitempty"` + + *TextCompletionLogProb +} + +type TextCompletionResponseChoice struct { + Text *string `json:"text,omitempty"` +} + +// ChatNonStreamResponseChoice represents a choice in the non-stream response +type ChatNonStreamResponseChoice struct { + Message *ChatMessage `json:"message"` + StopString *string `json:"stop,omitempty"` +} + +// ChatStreamResponseChoice represents a choice in the stream response +type ChatStreamResponseChoice struct { + Delta *ChatStreamResponseChoiceDelta `json:"delta,omitempty"` // Partial message info +} + +// ChatStreamResponseChoiceDelta represents a delta in the stream response +type ChatStreamResponseChoiceDelta struct { + Role *string `json:"role,omitempty"` // Only in the first chunk + Content *string `json:"content,omitempty"` // May be empty string or null + Thought *string `json:"thought,omitempty"` // May be empty string or null + Refusal *string `json:"refusal,omitempty"` // Refusal content if any + ToolCalls []ChatAssistantMessageToolCall `json:"tool_calls,omitempty"` // If tool calls used (supports incremental updates) +} + +// LogProb represents the log probability of a token. +type LogProb struct { + Bytes []int `json:"bytes,omitempty"` + LogProb float64 `json:"logprob"` + Token string `json:"token"` +} + +// ContentLogProb represents log probability information for content. +type ContentLogProb struct { + Bytes []int `json:"bytes"` + LogProb float64 `json:"logprob"` + Token string `json:"token"` + TopLogProbs []LogProb `json:"top_logprobs"` +} + +// BifrostLLMUsage represents token usage information +type BifrostLLMUsage struct { + PromptTokens int `json:"prompt_tokens,omitempty"` + PromptTokensDetails *ChatPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` + CompletionTokensDetails *ChatCompletionTokensDetails `json:"completion_tokens_details,omitempty"` + TotalTokens int `json:"total_tokens"` + Cost *BifrostCost `json:"cost,omitempty"` //Only for the providers which support cost calculation +} + +type ChatPromptTokensDetails struct { + AudioTokens int `json:"audio_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` +} + +type ChatCompletionTokensDetails struct { + AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + CitationTokens *int `json:"citation_tokens,omitempty"` + NumSearchQueries *int `json:"num_search_queries,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` +} + +type BifrostCost struct { + InputTokensCost float64 `json:"input_tokens_cost,omitempty"` + OutputTokensCost float64 `json:"output_tokens_cost,omitempty"` + RequestCost float64 `json:"request_cost,omitempty"` + TotalCost float64 `json:"total_cost,omitempty"` +} + +// UnmarshalJSON implements custom JSON unmarshalling for BifrostCost. +func (bc *BifrostCost) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct float + var costFloat float64 + if err := sonic.Unmarshal(data, &costFloat); err == nil { + bc.TotalCost = costFloat + return nil + } + + // Try to unmarshal as a full BifrostCost struct + // Use a type alias to avoid infinite recursion + type Alias BifrostCost + var costStruct Alias + if err := sonic.Unmarshal(data, &costStruct); err == nil { + *bc = BifrostCost(costStruct) + return nil + } + + return fmt.Errorf("cost field is neither a float nor an object") +} + +type SearchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Date *string `json:"date,omitempty"` + LastUpdated *string `json:"last_updated,omitempty"` + Snippet *string `json:"snippet,omitempty"` + Source *string `json:"source,omitempty"` +} + +type VideoResult struct { + URL string `json:"url"` + ThumbnailURL *string `json:"thumbnail_url,omitempty"` + ThumbnailWidth *int `json:"thumbnail_width,omitempty"` + ThumbnailHeight *int `json:"thumbnail_height,omitempty"` + Duration *float64 `json:"duration,omitempty"` +} diff --git a/core/schemas/embedding.go b/core/schemas/embedding.go new file mode 100644 index 000000000..73f0d2664 --- /dev/null +++ b/core/schemas/embedding.go @@ -0,0 +1,166 @@ +package schemas + +import ( + "fmt" + + "github.com/bytedance/sonic" +) + +type BifrostEmbeddingRequest struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` + Input *EmbeddingInput `json:"input,omitempty"` + Params *EmbeddingParameters `json:"params,omitempty"` + Fallbacks []Fallback `json:"fallbacks,omitempty"` + RawRequestBody []byte `json:"-"` // set bifrost-use-raw-request-body to true in ctx to use the raw request body. Bifrost will directly send this to the downstream provider. +} + +func (r *BifrostEmbeddingRequest) GetRawRequestBody() []byte { + return r.RawRequestBody +} + +type BifrostEmbeddingResponse struct { + Data []EmbeddingData `json:"data"` // Maps to "data" field in provider responses (e.g., OpenAI embedding format) + Model string `json:"model"` + Object string `json:"object"` // "list" + Usage *BifrostLLMUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +// EmbeddingInput represents the input for an embedding request. +type EmbeddingInput struct { + Text *string + Texts []string + Embedding []int + Embeddings [][]int +} + +func (e *EmbeddingInput) MarshalJSON() ([]byte, error) { + // enforce one-of + set := 0 + if e.Text != nil { + set++ + } + if e.Texts != nil { + set++ + } + if e.Embedding != nil { + set++ + } + if e.Embeddings != nil { + set++ + } + if set == 0 { + return nil, fmt.Errorf("embedding input is empty") + } + if set > 1 { + return nil, fmt.Errorf("embedding input must set exactly one of: text, texts, embedding, embeddings") + } + + if e.Text != nil { + return sonic.Marshal(*e.Text) + } + if e.Texts != nil { + return sonic.Marshal(e.Texts) + } + if e.Embedding != nil { + return sonic.Marshal(e.Embedding) + } + if e.Embeddings != nil { + return sonic.Marshal(e.Embeddings) + } + + return nil, fmt.Errorf("invalid embedding input") +} + +func (e *EmbeddingInput) UnmarshalJSON(data []byte) error { + e.Text = nil + e.Texts = nil + e.Embedding = nil + e.Embeddings = nil + // Try string + var s string + if err := sonic.Unmarshal(data, &s); err == nil { + e.Text = &s + return nil + } + // Try []string + var ss []string + if err := sonic.Unmarshal(data, &ss); err == nil { + e.Texts = ss + return nil + } + // Try []int + var i []int + if err := sonic.Unmarshal(data, &i); err == nil { + e.Embedding = i + return nil + } + // Try [][]int + var i2 [][]int + if err := sonic.Unmarshal(data, &i2); err == nil { + e.Embeddings = i2 + return nil + } + + return fmt.Errorf("unsupported embedding input shape") +} + +type EmbeddingParameters struct { + EncodingFormat *string `json:"encoding_format,omitempty"` // Format for embedding output (e.g., "float", "base64") + Dimensions *int `json:"dimensions,omitempty"` // Number of dimensions for embedding output + + // Dynamic parameters that can be provider-specific, they are directly + // added to the request as is. + ExtraParams map[string]interface{} `json:"-"` +} + +type EmbeddingData struct { + Index int `json:"index"` + Object string `json:"object"` // "embedding" + Embedding EmbeddingStruct `json:"embedding"` // can be string, []float32 or [][]float32 +} + +type EmbeddingStruct struct { + EmbeddingStr *string + EmbeddingArray []float32 + Embedding2DArray [][]float32 +} + +func (be EmbeddingStruct) MarshalJSON() ([]byte, error) { + if be.EmbeddingStr != nil { + return sonic.Marshal(be.EmbeddingStr) + } + if be.EmbeddingArray != nil { + return sonic.Marshal(be.EmbeddingArray) + } + if be.Embedding2DArray != nil { + return sonic.Marshal(be.Embedding2DArray) + } + return nil, fmt.Errorf("no embedding found") +} + +func (be *EmbeddingStruct) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + be.EmbeddingStr = &stringContent + return nil + } + + // Try to unmarshal as a direct array of float32 + var arrayContent []float32 + if err := sonic.Unmarshal(data, &arrayContent); err == nil { + be.EmbeddingArray = arrayContent + return nil + } + + // Try to unmarshal as a direct 2D array of float32 + var arrayContent2D [][]float32 + if err := sonic.Unmarshal(data, &arrayContent2D); err == nil { + be.Embedding2DArray = arrayContent2D + return nil + } + + return fmt.Errorf("embedding field is neither a string nor an array of float32 nor a 2D array of float32") +} diff --git a/core/schemas/logger.go b/core/schemas/logger.go index 9e636579f..268244d79 100644 --- a/core/schemas/logger.go +++ b/core/schemas/logger.go @@ -2,9 +2,10 @@ package schemas // LogLevel represents the severity level of a log message. -// It is used to categorize and filter log messages based on their importance. +// Internally it maps to zerolog.Level for interoperability. type LogLevel string +// LogLevel constants for different severity levels. const ( LogLevelDebug LogLevel = "debug" LogLevelInfo LogLevel = "info" @@ -12,6 +13,15 @@ const ( LogLevelError LogLevel = "error" ) +// LoggerOutputType represents the output type of a logger. +type LoggerOutputType string + +// LoggerOutputType constants for different output types. +const ( + LoggerOutputTypeJSON LoggerOutputType = "json" + LoggerOutputTypePretty LoggerOutputType = "pretty" +) + // Logger defines the interface for logging operations in the Bifrost system. // Implementations of this interface should provide methods for logging messages // at different severity levels. @@ -19,17 +29,27 @@ type Logger interface { // Debug logs a debug-level message. // This is used for detailed debugging information that is typically only needed // during development or troubleshooting. - Debug(msg string) + Debug(msg string, args ...any) // Info logs an info-level message. // This is used for general informational messages about normal operation. - Info(msg string) + Info(msg string, args ...any) // Warn logs a warning-level message. // This is used for potentially harmful situations that don't prevent normal operation. - Warn(msg string) + Warn(msg string, args ...any) // Error logs an error-level message. // This is used for serious problems that need attention and may prevent normal operation. - Error(err error) + Error(msg string, args ...any) + + // Fatal logs a fatal-level message. + // This is used for critical situations that require immediate attention and will terminate the program. + Fatal(msg string, args ...any) + + // SetLevel sets the log level for the logger. + SetLevel(level LogLevel) + + // SetOutputType sets the output type for the logger. + SetOutputType(outputType LoggerOutputType) } diff --git a/core/schemas/mcp.go b/core/schemas/mcp.go new file mode 100644 index 000000000..e26409e12 --- /dev/null +++ b/core/schemas/mcp.go @@ -0,0 +1,64 @@ +// Package schemas defines the core schemas and types used by the Bifrost system. +package schemas + +// MCPServerInstance represents an MCP server instance for InProcess connections. +// This should be a *github.com/mark3labs/mcp-go/server.MCPServer instance. +// We use interface{} to avoid creating a dependency on the mcp-go package in schemas. +type MCPServerInstance interface{} + +// MCPConfig represents the configuration for MCP integration in Bifrost. +// It enables tool auto-discovery and execution from local and external MCP servers. +type MCPConfig struct { + ClientConfigs []MCPClientConfig `json:"client_configs,omitempty"` // Per-client execution configurations +} + +// MCPClientConfig defines tool filtering for an MCP client. +type MCPClientConfig struct { + ID string `json:"id"` // Client ID + Name string `json:"name"` // Client name + ConnectionType MCPConnectionType `json:"connection_type"` // How to connect (HTTP, STDIO, SSE, or InProcess) + ConnectionString *string `json:"connection_string,omitempty"` // HTTP or SSE URL (required for HTTP or SSE connections) + StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty"` // STDIO configuration (required for STDIO connections) + Headers map[string]string `json:"headers,omitempty"` // Headers to send with the request + InProcessServer MCPServerInstance `json:"-"` // MCP server instance for in-process connections (Go package only) + ToolsToExecute []string `json:"tools_to_execute,omitempty"` // Include-only list. + // ToolsToExecute semantics: + // - ["*"] => all tools are included + // - [] => no tools are included (deny-by-default) + // - nil/omitted => treated as [] (no tools) + // - ["tool1", "tool2"] => include only the specified tools +} + +// MCPConnectionType defines the communication protocol for MCP connections +type MCPConnectionType string + +const ( + MCPConnectionTypeHTTP MCPConnectionType = "http" // HTTP-based connection + MCPConnectionTypeSTDIO MCPConnectionType = "stdio" // STDIO-based connection + MCPConnectionTypeSSE MCPConnectionType = "sse" // Server-Sent Events connection + MCPConnectionTypeInProcess MCPConnectionType = "inprocess" // In-process (in-memory) connection +) + +// MCPStdioConfig defines how to launch a STDIO-based MCP server. +type MCPStdioConfig struct { + Command string `json:"command"` // Executable command to run + Args []string `json:"args"` // Command line arguments + Envs []string `json:"envs"` // Environment variables required +} + +type MCPConnectionState string + +const ( + MCPConnectionStateConnected MCPConnectionState = "connected" // Client is connected and ready to use + MCPConnectionStateDisconnected MCPConnectionState = "disconnected" // Client is not connected + MCPConnectionStateError MCPConnectionState = "error" // Client is in an error state, and cannot be used +) + +// MCPClient represents a connected MCP client with its configuration and tools, +// and connection information, after it has been initialized. +// It is returned by GetMCPClients() method. +type MCPClient struct { + Config MCPClientConfig `json:"config"` // Tool filtering settings + Tools []ChatToolFunction `json:"tools"` // Available tools + State MCPConnectionState `json:"state"` // Connection state +} diff --git a/core/schemas/meta/azure.go b/core/schemas/meta/azure.go deleted file mode 100644 index df5fd163b..000000000 --- a/core/schemas/meta/azure.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package meta provides provider-specific configuration structures and schemas. -// This file contains the Azure-specific configuration implementation. - -package meta - -// AzureMetaConfig represents the Azure-specific configuration. -// It contains Azure-specific settings required for service access and deployment management. -type AzureMetaConfig struct { - Endpoint string `json:"endpoint"` // Azure service endpoint URL - Deployments map[string]string `json:"deployments,omitempty"` // Mapping of model names to deployment names - APIVersion *string `json:"api_version,omitempty"` // Azure API version to use; defaults to "2024-02-01" -} - -// This is not used for Azure. -func (c *AzureMetaConfig) GetSecretAccessKey() *string { - return nil -} - -// This is not used for Azure. -func (c *AzureMetaConfig) GetRegion() *string { - return nil -} - -// This is not used for Azure. -func (c *AzureMetaConfig) GetSessionToken() *string { - return nil -} - -// This is not used for Azure. -func (c *AzureMetaConfig) GetARN() *string { - return nil -} - -// This is not used for Azure. -func (c *AzureMetaConfig) GetInferenceProfiles() map[string]string { - return nil -} - -// GetEndpoint returns the Azure service endpoint. -// This specifies the base URL for Azure API requests. -func (c *AzureMetaConfig) GetEndpoint() *string { - return &c.Endpoint -} - -// GetDeployments returns the deployment configurations. -// This maps model names to their corresponding Azure deployment names. -// Eg. "gpt-4o": "your-deployment-name-for-gpt-4o" -func (c *AzureMetaConfig) GetDeployments() map[string]string { - return c.Deployments -} - -// GetAPIVersion returns the Azure API version. -// This specifies which version of the Azure API to use. -func (c *AzureMetaConfig) GetAPIVersion() *string { - return c.APIVersion -} diff --git a/core/schemas/meta/bedrock.go b/core/schemas/meta/bedrock.go deleted file mode 100644 index 1a875d3f6..000000000 --- a/core/schemas/meta/bedrock.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package meta provides provider-specific configuration structures and schemas. -// This file contains the AWS Bedrock-specific configuration implementation. - -package meta - -// BedrockMetaConfig represents the AWS Bedrock-specific configuration. -// It contains AWS-specific settings required for authentication and service access. -type BedrockMetaConfig struct { - SecretAccessKey string `json:"secret_access_key,omitempty"` // AWS secret access key for authentication - Region *string `json:"region,omitempty"` // AWS region for service access - SessionToken *string `json:"session_token,omitempty"` // AWS session token for temporary credentials - ARN *string `json:"arn,omitempty"` // Amazon Resource Name for resource identification - InferenceProfiles map[string]string `json:"inference_profiles,omitempty"` // Mapping of model identifiers to inference profiles -} - -// GetSecretAccessKey returns the AWS secret access key. -// This is used for AWS API authentication. -func (c *BedrockMetaConfig) GetSecretAccessKey() *string { - return &c.SecretAccessKey -} - -// GetRegion returns the AWS region. -// This specifies which AWS region the service should be accessed from. -func (c *BedrockMetaConfig) GetRegion() *string { - return c.Region -} - -// GetSessionToken returns the AWS session token. -// This is used for temporary credentials in AWS authentication. -func (c *BedrockMetaConfig) GetSessionToken() *string { - return c.SessionToken -} - -// GetARN returns the Amazon Resource Name. -// This uniquely identifies AWS resources. -func (c *BedrockMetaConfig) GetARN() *string { - return c.ARN -} - -// GetInferenceProfiles returns the inference profiles mapping. -// This maps model identifiers to their corresponding inference profiles. -func (c *BedrockMetaConfig) GetInferenceProfiles() map[string]string { - return c.InferenceProfiles -} - -// This is not used for Bedrock. -func (c *BedrockMetaConfig) GetEndpoint() *string { - return nil -} - -// This is not used for Bedrock. -func (c *BedrockMetaConfig) GetDeployments() map[string]string { - return nil -} - -// This is not used for Bedrock. -func (c *BedrockMetaConfig) GetAPIVersion() *string { - return nil -} diff --git a/core/schemas/models.go b/core/schemas/models.go new file mode 100644 index 000000000..3f251e0fb --- /dev/null +++ b/core/schemas/models.go @@ -0,0 +1,236 @@ +package schemas + +import ( + "encoding/base64" + "fmt" + + "github.com/bytedance/sonic" +) + +// DefaultPageSize is the default page size for listing models +const DefaultPageSize = 1000 + +// MaxPaginationRequests is the maximum number of pagination requests to make +const MaxPaginationRequests = 20 + +// Structure to collect results from goroutines +type ListModelsByKeyResult struct { + Response *BifrostListModelsResponse + Err *BifrostError + KeyID string +} + +type BifrostListModelsRequest struct { + Provider ModelProvider `json:"provider"` + + PageSize int `json:"page_size"` + + // PageToken: Token received from previous request to retrieve next page + PageToken string `json:"page_token"` + + // ExtraParams: Additional provider-specific query parameters + // This allows for flexibility to pass any custom parameters that specific providers might support + ExtraParams map[string]interface{} `json:"-"` +} + +type BifrostListModelsResponse struct { + Data []Model `json:"data"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` + NextPageToken string `json:"next_page_token,omitempty"` // Token to retrieve next page + + // Anthropic specific fields + FirstID *string `json:"-"` + LastID *string `json:"-"` + HasMore *bool `json:"-"` +} + +// ApplyPagination applies offset-based pagination to a BifrostListModelsResponse. +// Uses opaque tokens with LastID validation to ensure cursor integrity. +// Returns the paginated response with properly set NextPageToken. +func (response *BifrostListModelsResponse) ApplyPagination(pageSize int, pageToken string) *BifrostListModelsResponse { + if response == nil { + return nil + } + + totalItems := len(response.Data) + + if pageSize <= 0 { + return response + } + + cursor := decodePaginationCursor(pageToken) + offset := cursor.Offset + + // Validate cursor integrity if LastID is present + if cursor.LastID != "" && !validatePaginationCursor(cursor, response.Data) { + // Invalid cursor: reset to beginning + offset = 0 + } + + if offset >= totalItems { + // Return empty page, no next token + return &BifrostListModelsResponse{ + Data: []Model{}, + ExtraFields: response.ExtraFields, + NextPageToken: "", + } + } + + endIndex := offset + pageSize + if endIndex > totalItems { + endIndex = totalItems + } + + paginatedData := response.Data[offset:endIndex] + + paginatedResponse := &BifrostListModelsResponse{ + Data: paginatedData, + ExtraFields: response.ExtraFields, + } + + if endIndex < totalItems { + // Get the last item ID for cursor validation + var lastID string + if len(paginatedData) > 0 { + lastID = paginatedData[len(paginatedData)-1].ID + } + + nextToken, err := encodePaginationCursor(endIndex, lastID) + if err == nil { + paginatedResponse.NextPageToken = nextToken + } + } else { + paginatedResponse.NextPageToken = "" + } + + return paginatedResponse +} + +type Model struct { + ID string `json:"id"` + CanonicalSlug *string `json:"canonical_slug,omitempty"` + Name *string `json:"name,omitempty"` + Created *int64 `json:"created,omitempty"` + ContextLength *int `json:"context_length,omitempty"` + MaxInputTokens *int `json:"max_input_tokens,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + Architecture *Architecture `json:"architecture,omitempty"` + Pricing *Pricing `json:"pricing,omitempty"` + TopProvider *TopProvider `json:"top_provider,omitempty"` + PerRequestLimits *PerRequestLimits `json:"per_request_limits,omitempty"` + SupportedParameters []string `json:"supported_parameters,omitempty"` + DefaultParameters *DefaultParameters `json:"default_parameters,omitempty"` + HuggingFaceID *string `json:"hugging_face_id,omitempty"` + Description *string `json:"description,omitempty"` + + OwnedBy *string `json:"owned_by,omitempty"` + SupportedMethods []string `json:"supported_methods,omitempty"` +} + +type Architecture struct { + Modality *string `json:"modality,omitempty"` + Tokenizer *string `json:"tokenizer,omitempty"` + InstructType *string `json:"instruct_type,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + OutputModalities []string `json:"output_modalities,omitempty"` +} + +type Pricing struct { + Prompt *string `json:"prompt,omitempty"` + Completion *string `json:"completion,omitempty"` + Request *string `json:"request,omitempty"` + Image *string `json:"image,omitempty"` + WebSearch *string `json:"web_search,omitempty"` + InternalReasoning *string `json:"internal_reasoning,omitempty"` + InputCacheRead *string `json:"input_cache_read,omitempty"` + InputCacheWrite *string `json:"input_cache_write,omitempty"` +} + +type TopProvider struct { + IsModerated *bool `json:"is_moderated,omitempty"` + ContextLength *int `json:"context_length,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` +} + +type PerRequestLimits struct { + PromptTokens *int `json:"prompt_tokens,omitempty"` + CompletionTokens *int `json:"completion_tokens,omitempty"` +} + +type DefaultParameters struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` +} + +// paginationCursor represents the internal cursor structure for pagination. +type paginationCursor struct { + Offset int `json:"o"` + LastID string `json:"l,omitempty"` +} + +// encodePaginationCursor creates an opaque base64-encoded page token from cursor data. +// Returns empty string if offset is 0 or negative. +func encodePaginationCursor(offset int, lastID string) (string, error) { + if offset <= 0 { + return "", nil + } + + cursor := paginationCursor{ + Offset: offset, + LastID: lastID, + } + + jsonData, err := sonic.Marshal(cursor) + if err != nil { + return "", fmt.Errorf("failed to marshal pagination cursor: %w", err) + } + + // Use URL-safe base64 encoding without padding for opaque token + encoded := base64.RawURLEncoding.EncodeToString(jsonData) + return encoded, nil +} + +// decodePaginationCursor extracts cursor data from an opaque base64-encoded page token. +// Returns cursor with 0 offset for empty or invalid tokens. +func decodePaginationCursor(token string) paginationCursor { + if token == "" { + return paginationCursor{} + } + + // Decode base64 + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return paginationCursor{} + } + + var cursor paginationCursor + if err := sonic.Unmarshal(decoded, &cursor); err != nil { + return paginationCursor{} + } + + if cursor.Offset < 0 { + return paginationCursor{} + } + + return cursor +} + +// validatePaginationCursor validates that the cursor matches the expected position in the data. +// Returns true if the cursor is valid, false otherwise. +func validatePaginationCursor(cursor paginationCursor, data []Model) bool { + if cursor.LastID == "" { + return true + } + + if cursor.Offset <= 0 || cursor.Offset > len(data) { + return false + } + + prevIndex := cursor.Offset - 1 + if prevIndex >= 0 && prevIndex < len(data) { + return data[prevIndex].ID == cursor.LastID + } + + return true +} diff --git a/core/schemas/mux.go b/core/schemas/mux.go new file mode 100644 index 000000000..f522bdcd9 --- /dev/null +++ b/core/schemas/mux.go @@ -0,0 +1,1436 @@ +package schemas + +import ( + "fmt" + "sync" + "time" +) + +// ============================================================================= +// BIDIRECTIONAL CONVERSION METHODS +// ============================================================================= +// +// This section contains methods for converting between Chat Completions API +// and Responses API formats. These methods are attached to the structs themselves +// for easy conversion in both directions. +// +// Key Features: +// 1. Bidirectional: Convert to and from both formats +// 2. Data preservation: All relevant data is preserved during conversion +// 3. Aggregation/Spreading: Handle tool messages properly for each format +// 4. Validation: Ensure data integrity during conversion +// +// ============================================================================= + +// ============================================================================= +// TOOL CONVERSION METHODS +// ============================================================================= + +// ToResponsesTool converts a ChatTool to ResponsesTool format +func (ct *ChatTool) ToResponsesTool() *ResponsesTool { + if ct == nil { + return &ResponsesTool{} + } + + rt := &ResponsesTool{ + Type: ResponsesToolType(ct.Type), + } + + // Convert function tools + if ct.Type == ChatToolTypeFunction && ct.Function != nil { + rt.Name = &ct.Function.Name + rt.Description = ct.Function.Description + + // Create ResponsesToolFunction if needed + if ct.Function.Parameters != nil || ct.Function.Strict != nil { + rt.ResponsesToolFunction = &ResponsesToolFunction{ + Parameters: ct.Function.Parameters, + Strict: ct.Function.Strict, + } + } + } + + // Convert custom tools + if ct.Type == ChatToolTypeCustom && ct.Custom != nil { + if ct.Custom.Format != nil { + rt.ResponsesToolCustom = &ResponsesToolCustom{ + Format: &ResponsesToolCustomFormat{ + Type: ct.Custom.Format.Type, + }, + } + if ct.Custom.Format.Grammar != nil { + rt.ResponsesToolCustom.Format.Definition = &ct.Custom.Format.Grammar.Definition + rt.ResponsesToolCustom.Format.Syntax = &ct.Custom.Format.Grammar.Syntax + } + } + } + + return rt +} + +// ToChatTool converts a ResponsesTool to ChatTool format +func (rt *ResponsesTool) ToChatTool() *ChatTool { + if rt == nil { + return &ChatTool{} + } + + ct := &ChatTool{ + Type: ChatToolType(rt.Type), + } + + // Convert function tools + if rt.Type == "function" { + ct.Function = &ChatToolFunction{} + + if rt.Name != nil { + ct.Function.Name = *rt.Name + } + if rt.Description != nil { + ct.Function.Description = rt.Description + } + if rt.ResponsesToolFunction != nil { + ct.Function.Parameters = rt.ResponsesToolFunction.Parameters + ct.Function.Strict = rt.ResponsesToolFunction.Strict + } + } + + // Convert custom tools + if rt.Type == "custom" && rt.ResponsesToolCustom != nil { + ct.Custom = &ChatToolCustom{} + if rt.ResponsesToolCustom.Format != nil { + ct.Custom.Format = &ChatToolCustomFormat{ + Type: rt.ResponsesToolCustom.Format.Type, + } + if rt.ResponsesToolCustom.Format.Definition != nil && rt.ResponsesToolCustom.Format.Syntax != nil { + ct.Custom.Format.Grammar = &ChatToolCustomGrammarFormat{ + Definition: *rt.ResponsesToolCustom.Format.Definition, + Syntax: *rt.ResponsesToolCustom.Format.Syntax, + } + } + } + } + + return ct +} + +// ============================================================================= +// TOOL CHOICE CONVERSION METHODS +// ============================================================================= + +// ToResponsesToolChoice converts a ChatToolChoice to ResponsesToolChoice format +func (ctc *ChatToolChoice) ToResponsesToolChoice() *ResponsesToolChoice { + if ctc == nil { + return &ResponsesToolChoice{} + } + + rtc := &ResponsesToolChoice{} + + // Handle string choice (e.g., "none", "auto", "required") + if ctc.ChatToolChoiceStr != nil { + rtc.ResponsesToolChoiceStr = ctc.ChatToolChoiceStr + return rtc + } + + // Handle structured choice + if ctc.ChatToolChoiceStruct != nil { + rtc.ResponsesToolChoiceStruct = &ResponsesToolChoiceStruct{ + Type: ResponsesToolChoiceType(ctc.ChatToolChoiceStruct.Type), + } + + switch ctc.ChatToolChoiceStruct.Type { + case ChatToolChoiceTypeNone, ChatToolChoiceTypeAny, ChatToolChoiceTypeRequired: + // These map to mode field + modeStr := string(ctc.ChatToolChoiceStruct.Type) + rtc.ResponsesToolChoiceStruct.Mode = &modeStr + + case ChatToolChoiceTypeFunction: + // Map function choice + if ctc.ChatToolChoiceStruct.Function.Name != "" { + rtc.ResponsesToolChoiceStruct.Name = &ctc.ChatToolChoiceStruct.Function.Name + } + + case ChatToolChoiceTypeAllowedTools: + // Map allowed tools + if len(ctc.ChatToolChoiceStruct.AllowedTools.Tools) > 0 { + tools := make([]ResponsesToolChoiceAllowedToolDef, len(ctc.ChatToolChoiceStruct.AllowedTools.Tools)) + for i, tool := range ctc.ChatToolChoiceStruct.AllowedTools.Tools { + tools[i] = ResponsesToolChoiceAllowedToolDef{ + Type: tool.Type, + } + if tool.Function.Name != "" { + name := tool.Function.Name + tools[i].Name = &name + } + } + rtc.ResponsesToolChoiceStruct.Tools = tools + } + // Copy the mode (e.g., "auto", "required") + if ctc.ChatToolChoiceStruct.AllowedTools.Mode != "" { + mode := ctc.ChatToolChoiceStruct.AllowedTools.Mode + rtc.ResponsesToolChoiceStruct.Mode = &mode + } + + case ChatToolChoiceTypeCustom: + // Map custom choice + if ctc.ChatToolChoiceStruct.Custom.Name != "" { + rtc.ResponsesToolChoiceStruct.Name = &ctc.ChatToolChoiceStruct.Custom.Name + } + } + } + + return rtc +} + +// ToChatToolChoice converts a ResponsesToolChoice to ChatToolChoice format +func (tc *ResponsesToolChoice) ToChatToolChoice() *ChatToolChoice { + if tc == nil { + return &ChatToolChoice{} + } + + ctc := &ChatToolChoice{} + + // Handle string choice + if tc.ResponsesToolChoiceStr != nil { + ctc.ChatToolChoiceStr = tc.ResponsesToolChoiceStr + return ctc + } + + // Handle structured choice + if tc.ResponsesToolChoiceStruct != nil { + ctc.ChatToolChoiceStruct = &ChatToolChoiceStruct{ + Type: ChatToolChoiceType(tc.ResponsesToolChoiceStruct.Type), + } + + // Handle mode-based choices (none, auto, required) + if tc.ResponsesToolChoiceStruct.Mode != nil { + switch *tc.ResponsesToolChoiceStruct.Mode { + case "none": + ctc.ChatToolChoiceStruct.Type = ChatToolChoiceTypeNone + case "auto": + ctc.ChatToolChoiceStruct.Type = ChatToolChoiceTypeAny + case "required": + ctc.ChatToolChoiceStruct.Type = ChatToolChoiceTypeRequired + } + } + + // Handle function choice + if tc.ResponsesToolChoiceStruct.Type == ResponsesToolChoiceTypeFunction && tc.ResponsesToolChoiceStruct.Name != nil { + ctc.ChatToolChoiceStruct.Function = ChatToolChoiceFunction{ + Name: *tc.ResponsesToolChoiceStruct.Name, + } + } + + // Handle custom choice + if tc.ResponsesToolChoiceStruct.Type == ResponsesToolChoiceTypeCustom && tc.ResponsesToolChoiceStruct.Name != nil { + ctc.ChatToolChoiceStruct.Custom = ChatToolChoiceCustom{ + Name: *tc.ResponsesToolChoiceStruct.Name, + } + } + + // Handle allowed tools + if len(tc.ResponsesToolChoiceStruct.Tools) > 0 { + ctc.ChatToolChoiceStruct.Type = ChatToolChoiceTypeAllowedTools + tools := make([]ChatToolChoiceAllowedToolsTool, len(tc.ResponsesToolChoiceStruct.Tools)) + for i, tool := range tc.ResponsesToolChoiceStruct.Tools { + tools[i] = ChatToolChoiceAllowedToolsTool{ + Type: tool.Type, + } + if tool.Name != nil { + tools[i].Function = ChatToolChoiceFunction{Name: *tool.Name} + } + } + // Copy the mode if present, otherwise default to "auto" + mode := "auto" + if tc.ResponsesToolChoiceStruct.Mode != nil && *tc.ResponsesToolChoiceStruct.Mode != "" { + mode = *tc.ResponsesToolChoiceStruct.Mode + } + ctc.ChatToolChoiceStruct.AllowedTools = ChatToolChoiceAllowedTools{ + Mode: mode, + Tools: tools, + } + } + + return ctc + } + + return nil +} + +// ============================================================================= +// MESSAGE CONVERSION METHODS +// ============================================================================= + +// ToResponsesMessages converts a ChatMessage to one or more ResponsesMessages +// This handles the expansion of assistant messages with tool calls into separate function_call messages +func (cm *ChatMessage) ToResponsesMessages() []ResponsesMessage { + if cm == nil { + return []ResponsesMessage{} + } + + var messages []ResponsesMessage + + // Check if this is an assistant message with multiple tool calls that need expansion + if cm.ChatAssistantMessage != nil && cm.ChatAssistantMessage.ToolCalls != nil && len(cm.ChatAssistantMessage.ToolCalls) > 0 { + // Expand multiple tool calls into separate function_call items + for _, tc := range cm.ChatAssistantMessage.ToolCalls { + messageType := ResponsesMessageTypeFunctionCall + + var callID *string + if tc.ID != nil && *tc.ID != "" { + callID = tc.ID + } + + var namePtr *string + if tc.Function.Name != nil && *tc.Function.Name != "" { + namePtr = tc.Function.Name + } + + // Create a copy of the arguments string to avoid range loop variable capture + var argumentsPtr *string + if tc.Function.Arguments != "" { + argumentsPtr = Ptr(tc.Function.Arguments) + } + + rm := ResponsesMessage{ + Type: &messageType, + Role: Ptr(ResponsesInputMessageRoleAssistant), + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: callID, + Name: namePtr, + Arguments: argumentsPtr, + }, + } + + messages = append(messages, rm) + } + return messages + } + + // Regular message conversion + messageType := ResponsesMessageTypeMessage + role := ResponsesInputMessageRoleUser + + // Determine message type and role + switch cm.Role { + case ChatMessageRoleAssistant: + role = ResponsesInputMessageRoleAssistant + // Check for refusal + if cm.ChatAssistantMessage != nil && cm.ChatAssistantMessage.Refusal != nil { + messageType = ResponsesMessageTypeRefusal + } + case ChatMessageRoleUser: + role = ResponsesInputMessageRoleUser + case ChatMessageRoleSystem: + role = ResponsesInputMessageRoleSystem + case ChatMessageRoleTool: + messageType = ResponsesMessageTypeFunctionCallOutput + role = ResponsesInputMessageRoleUser // Tool messages are typically user role in responses + case ChatMessageRoleDeveloper: + role = ResponsesInputMessageRoleDeveloper + } + + rm := ResponsesMessage{ + Type: &messageType, + Role: &role, + } + + // Handle refusal content specifically - use content blocks with ResponsesOutputMessageContentRefusal + if messageType == ResponsesMessageTypeRefusal && cm.ChatAssistantMessage != nil && cm.ChatAssistantMessage.Refusal != nil { + refusalBlock := ResponsesMessageContentBlock{ + Type: ResponsesOutputMessageContentTypeRefusal, + ResponsesOutputMessageContentRefusal: &ResponsesOutputMessageContentRefusal{ + Refusal: *cm.ChatAssistantMessage.Refusal, + }, + } + rm.Content = &ResponsesMessageContent{ + ContentBlocks: []ResponsesMessageContentBlock{refusalBlock}, + } + } else if cm.Content != nil && cm.Content.ContentStr != nil { + // Convert regular string content (if input message then ContentStr, else ContentBlocks) + if cm.Role == ChatMessageRoleAssistant { + rm.Content = &ResponsesMessageContent{ + ContentBlocks: []ResponsesMessageContentBlock{ + {Type: ResponsesOutputMessageContentTypeText, Text: cm.Content.ContentStr}, + }, + } + } else { + rm.Content = &ResponsesMessageContent{ + ContentStr: cm.Content.ContentStr, + } + } + } else if cm.Content != nil && cm.Content.ContentBlocks != nil { + // Convert content blocks + responseBlocks := make([]ResponsesMessageContentBlock, len(cm.Content.ContentBlocks)) + for i, block := range cm.Content.ContentBlocks { + blockType := ResponsesMessageContentBlockType(block.Type) + + switch block.Type { + case ChatContentBlockTypeText: + if cm.Role == ChatMessageRoleAssistant { + blockType = ResponsesOutputMessageContentTypeText + } else { + blockType = ResponsesInputMessageContentBlockTypeText + } + case ChatContentBlockTypeImage: + blockType = ResponsesInputMessageContentBlockTypeImage + case ChatContentBlockTypeFile: + blockType = ResponsesInputMessageContentBlockTypeFile + case ChatContentBlockTypeInputAudio: + blockType = ResponsesInputMessageContentBlockTypeAudio + } + + responseBlocks[i] = ResponsesMessageContentBlock{ + Type: blockType, + Text: block.Text, + } + + // Convert specific block types + if block.ImageURLStruct != nil { + responseBlocks[i].ResponsesInputMessageContentBlockImage = &ResponsesInputMessageContentBlockImage{ + ImageURL: &block.ImageURLStruct.URL, + Detail: block.ImageURLStruct.Detail, + } + } + if block.File != nil { + responseBlocks[i].ResponsesInputMessageContentBlockFile = &ResponsesInputMessageContentBlockFile{ + FileData: block.File.FileData, + Filename: block.File.Filename, + } + responseBlocks[i].FileID = block.File.FileID + } + if block.InputAudio != nil { + format := "" + if block.InputAudio.Format != nil { + format = *block.InputAudio.Format + } + responseBlocks[i].Audio = &ResponsesInputMessageContentBlockAudio{ + Data: block.InputAudio.Data, + Format: format, + } + } + } + rm.Content = &ResponsesMessageContent{ + ContentBlocks: responseBlocks, + } + } + + // Handle tool messages + if cm.ChatToolMessage != nil { + rm.ResponsesToolMessage = &ResponsesToolMessage{} + if cm.ChatToolMessage.ToolCallID != nil { + rm.ResponsesToolMessage.CallID = cm.ChatToolMessage.ToolCallID + } + + // If tool output content exists, add it to function_call_output + if rm.Content != nil && rm.Content.ContentStr != nil && *rm.Content.ContentStr != "" { + rm.ResponsesToolMessage.Output = &ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: rm.Content.ContentStr, + } + } + } + + messages = append(messages, rm) + return messages +} + +// ToChatMessages converts a slice of ResponsesMessages back to ChatMessages +// This handles the aggregation of function_call messages back into assistant messages with tool calls +func ToChatMessages(rms []ResponsesMessage) []ChatMessage { + if len(rms) == 0 { + return []ChatMessage{} + } + + var chatMessages []ChatMessage + var currentToolCalls []ChatAssistantMessageToolCall + + for _, rm := range rms { + if rm.Type != nil && *rm.Type == ResponsesMessageTypeReasoning { + continue + } + + // Handle function_call messages - collect them for aggregation + if rm.Type != nil && *rm.Type == ResponsesMessageTypeFunctionCall { + if rm.ResponsesToolMessage != nil { + tc := ChatAssistantMessageToolCall{ + Type: Ptr("function"), + } + + if rm.ResponsesToolMessage.CallID != nil { + tc.ID = rm.ResponsesToolMessage.CallID + } + + tc.Function = ChatAssistantMessageToolCallFunction{} + if rm.ResponsesToolMessage.Name != nil { + tc.Function.Name = rm.ResponsesToolMessage.Name + } + if rm.ResponsesToolMessage.Arguments != nil { + tc.Function.Arguments = *rm.ResponsesToolMessage.Arguments + } + + currentToolCalls = append(currentToolCalls, tc) + } + continue + } + + // If we have collected tool calls, create an assistant message with them + if len(currentToolCalls) > 0 { + // Create a copy of the slice to avoid shared slice header issues + toolCallsCopy := append([]ChatAssistantMessageToolCall(nil), currentToolCalls...) + chatMessages = append(chatMessages, ChatMessage{ + Role: ChatMessageRoleAssistant, + ChatAssistantMessage: &ChatAssistantMessage{ + ToolCalls: toolCallsCopy, + }, + }) + currentToolCalls = nil // Reset for next batch + } + + // Convert regular message + cm := ChatMessage{} + + // Set role + if rm.Role != nil { + switch *rm.Role { + case ResponsesInputMessageRoleAssistant: + cm.Role = ChatMessageRoleAssistant + case ResponsesInputMessageRoleUser: + cm.Role = ChatMessageRoleUser + case ResponsesInputMessageRoleSystem: + cm.Role = ChatMessageRoleSystem + case ResponsesInputMessageRoleDeveloper: + cm.Role = ChatMessageRoleDeveloper + } + } + + // Handle special message types + if rm.Type != nil { + switch *rm.Type { + case ResponsesMessageTypeFunctionCallOutput: + cm.Role = ChatMessageRoleTool + if rm.ResponsesToolMessage != nil && rm.ResponsesToolMessage.CallID != nil { + cm.ChatToolMessage = &ChatToolMessage{ + ToolCallID: rm.ResponsesToolMessage.CallID, + } + + // Extract content from ResponsesFunctionToolCallOutput if present + // This is needed because OpenAI Responses API uses an "output" field + // which is stored in ResponsesFunctionToolCallOutput + if rm.ResponsesToolMessage.Output != nil { + if rm.Content == nil { + rm.Content = &ResponsesMessageContent{} + } + // If Content is not already set, extract from ResponsesFunctionToolCallOutput + if rm.Content.ContentStr == nil && rm.Content.ContentBlocks == nil { + if rm.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + rm.Content.ContentStr = rm.ResponsesToolMessage.Output.ResponsesToolCallOutputStr + } else if rm.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks != nil { + rm.Content.ContentBlocks = rm.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks + } + } + } + } + case ResponsesMessageTypeRefusal: + cm.ChatAssistantMessage = &ChatAssistantMessage{} + // Extract refusal from content blocks or ContentStr + if rm.Content != nil { + if rm.Content.ContentBlocks != nil { + // Look for refusal content block + for _, block := range rm.Content.ContentBlocks { + if block.Type == ResponsesOutputMessageContentTypeRefusal && block.ResponsesOutputMessageContentRefusal != nil { + refusalText := block.ResponsesOutputMessageContentRefusal.Refusal + cm.ChatAssistantMessage.Refusal = &refusalText + break + } + } + } else if rm.Content.ContentStr != nil { + // Fallback to ContentStr for backward compatibility + cm.ChatAssistantMessage.Refusal = rm.Content.ContentStr + } + } + } + } + + // Convert content (skip for refusal messages since refusal is already extracted) + if rm.Content != nil && (rm.Type == nil || *rm.Type != ResponsesMessageTypeRefusal) { + if rm.Content.ContentStr != nil || + (len(rm.Content.ContentBlocks) == 1 && + (rm.Content.ContentBlocks[0].Type == ResponsesInputMessageContentBlockTypeText || rm.Content.ContentBlocks[0].Type == ResponsesOutputMessageContentTypeText)) { + if rm.Content.ContentStr != nil { + cm.Content = &ChatMessageContent{ + ContentStr: rm.Content.ContentStr, + } + } else { + cm.Content = &ChatMessageContent{ + ContentStr: rm.Content.ContentBlocks[0].Text, + } + } + } else if rm.Content.ContentBlocks != nil { + chatBlocks := make([]ChatContentBlock, len(rm.Content.ContentBlocks)) + for i, block := range rm.Content.ContentBlocks { + // Map ResponsesMessageContentBlockType to ChatContentBlockType + var chatBlockType ChatContentBlockType + switch block.Type { + case ResponsesInputMessageContentBlockTypeText: + chatBlockType = ChatContentBlockTypeText // "input_text" -> "text" + case ResponsesInputMessageContentBlockTypeImage: + chatBlockType = ChatContentBlockTypeImage // "input_image" -> "image_url" + case ResponsesInputMessageContentBlockTypeFile: + chatBlockType = ChatContentBlockTypeFile // "input_file" -> "input_file" (same) + case ResponsesInputMessageContentBlockTypeAudio: + chatBlockType = ChatContentBlockTypeInputAudio // "input_audio" -> "input_audio" (same) + default: + // For unknown types, fall back to direct conversion + chatBlockType = ChatContentBlockType(block.Type) + } + + chatBlocks[i] = ChatContentBlock{ + Type: chatBlockType, + Text: block.Text, + } + + // Convert specific block types + if block.ResponsesInputMessageContentBlockImage != nil { + chatBlocks[i].ImageURLStruct = &ChatInputImage{ + Detail: block.ResponsesInputMessageContentBlockImage.Detail, + } + if block.ResponsesInputMessageContentBlockImage.ImageURL != nil { + chatBlocks[i].ImageURLStruct.URL = *block.ResponsesInputMessageContentBlockImage.ImageURL + } + } + if block.ResponsesInputMessageContentBlockFile != nil { + chatBlocks[i].File = &ChatInputFile{ + FileData: block.ResponsesInputMessageContentBlockFile.FileData, + Filename: block.ResponsesInputMessageContentBlockFile.Filename, + FileID: block.FileID, + } + } + if block.Audio != nil { + chatBlocks[i].InputAudio = &ChatInputAudio{ + Data: block.Audio.Data, + } + if block.Audio.Format != "" { + chatBlocks[i].InputAudio.Format = &block.Audio.Format + } + } + } + cm.Content = &ChatMessageContent{ + ContentBlocks: chatBlocks, + } + } + } + + chatMessages = append(chatMessages, cm) + } + + // Handle any remaining tool calls at the end + if len(currentToolCalls) > 0 { + // Create a copy of the slice to avoid shared slice header issues + toolCallsCopy := append([]ChatAssistantMessageToolCall(nil), currentToolCalls...) + chatMessages = append(chatMessages, ChatMessage{ + Role: ChatMessageRoleAssistant, + ChatAssistantMessage: &ChatAssistantMessage{ + ToolCalls: toolCallsCopy, + }, + }) + } + + return chatMessages +} + +func (cu *BifrostLLMUsage) ToResponsesResponseUsage() *ResponsesResponseUsage { + if cu == nil { + return nil + } + + usage := &ResponsesResponseUsage{ + InputTokens: cu.PromptTokens, + OutputTokens: cu.CompletionTokens, + TotalTokens: cu.TotalTokens, + Cost: cu.Cost, + } + + if cu.PromptTokensDetails != nil { + usage.InputTokensDetails = &ResponsesResponseInputTokens{ + AudioTokens: cu.PromptTokensDetails.AudioTokens, + CachedTokens: cu.PromptTokensDetails.CachedTokens, + } + } + if cu.CompletionTokensDetails != nil { + usage.OutputTokensDetails = &ResponsesResponseOutputTokens{ + AcceptedPredictionTokens: cu.CompletionTokensDetails.AcceptedPredictionTokens, + AudioTokens: cu.CompletionTokensDetails.AudioTokens, + ReasoningTokens: cu.CompletionTokensDetails.ReasoningTokens, + RejectedPredictionTokens: cu.CompletionTokensDetails.RejectedPredictionTokens, + CitationTokens: cu.CompletionTokensDetails.CitationTokens, + NumSearchQueries: cu.CompletionTokensDetails.NumSearchQueries, + } + } + + return usage +} + +func (ru *ResponsesResponseUsage) ToBifrostLLMUsage() *BifrostLLMUsage { + if ru == nil { + return nil + } + + usage := &BifrostLLMUsage{ + PromptTokens: ru.InputTokens, + CompletionTokens: ru.OutputTokens, + TotalTokens: ru.TotalTokens, + Cost: ru.Cost, + } + + if ru.InputTokensDetails != nil { + usage.PromptTokensDetails = &ChatPromptTokensDetails{ + AudioTokens: ru.InputTokensDetails.AudioTokens, + CachedTokens: ru.InputTokensDetails.CachedTokens, + } + } + if ru.OutputTokensDetails != nil { + usage.CompletionTokensDetails = &ChatCompletionTokensDetails{ + AcceptedPredictionTokens: ru.OutputTokensDetails.AcceptedPredictionTokens, + AudioTokens: ru.OutputTokensDetails.AudioTokens, + ReasoningTokens: ru.OutputTokensDetails.ReasoningTokens, + RejectedPredictionTokens: ru.OutputTokensDetails.RejectedPredictionTokens, + CitationTokens: ru.OutputTokensDetails.CitationTokens, + NumSearchQueries: ru.OutputTokensDetails.NumSearchQueries, + } + } + + return usage +} + +// ============================================================================= +// REQUEST CONVERSION METHODS +// ============================================================================= + +// ToResponsesRequest converts a BifrostChatRequest to BifrostResponsesRequest format +func (bcr *BifrostChatRequest) ToResponsesRequest() *BifrostResponsesRequest { + if bcr == nil { + return &BifrostResponsesRequest{} + } + + brr := &BifrostResponsesRequest{ + Provider: bcr.Provider, + Model: bcr.Model, + Fallbacks: bcr.Fallbacks, // Copy fallbacks as-is + } + + // Convert Input messages using existing ChatMessage.ToResponsesMessages() + var allResponsesMessages []ResponsesMessage + for _, chatMsg := range bcr.Input { + responsesMessages := chatMsg.ToResponsesMessages() + allResponsesMessages = append(allResponsesMessages, responsesMessages...) + } + brr.Input = allResponsesMessages + + // Convert Parameters + if bcr.Params != nil { + brr.Params = &ResponsesParameters{ + // Map common fields + ParallelToolCalls: bcr.Params.ParallelToolCalls, + PromptCacheKey: bcr.Params.PromptCacheKey, + SafetyIdentifier: bcr.Params.SafetyIdentifier, + ServiceTier: bcr.Params.ServiceTier, + Store: bcr.Params.Store, + Temperature: bcr.Params.Temperature, + TopLogProbs: bcr.Params.TopLogProbs, + TopP: bcr.Params.TopP, + ExtraParams: bcr.Params.ExtraParams, + + // Map specific fields + MaxOutputTokens: bcr.Params.MaxCompletionTokens, // max_completion_tokens -> max_output_tokens + Metadata: bcr.Params.Metadata, + } + + // Convert StreamOptions + if bcr.Params.StreamOptions != nil { + brr.Params.StreamOptions = &ResponsesStreamOptions{ + IncludeObfuscation: bcr.Params.StreamOptions.IncludeObfuscation, + } + } + + // Convert Tools using existing ChatTool.ToResponsesTool() + if len(bcr.Params.Tools) > 0 { + responsesTools := make([]ResponsesTool, 0, len(bcr.Params.Tools)) + for _, chatTool := range bcr.Params.Tools { + responsesTool := chatTool.ToResponsesTool() + responsesTools = append(responsesTools, *responsesTool) + } + brr.Params.Tools = responsesTools + } + + // Convert ToolChoice using existing ChatToolChoice.ToResponsesToolChoice() + if bcr.Params.ToolChoice != nil { + responsesToolChoice := bcr.Params.ToolChoice.ToResponsesToolChoice() + brr.Params.ToolChoice = responsesToolChoice + } + + // Handle Reasoning from reasoning_effort + if bcr.Params.ReasoningEffort != nil { + brr.Params.Reasoning = &ResponsesParametersReasoning{ + Effort: bcr.Params.ReasoningEffort, + } + } + + // Handle Verbosity + if bcr.Params.Verbosity != nil { + if brr.Params.Text == nil { + brr.Params.Text = &ResponsesTextConfig{} + } + brr.Params.Text.Verbosity = bcr.Params.Verbosity + } + } + + brr.RawRequestBody = bcr.RawRequestBody + + return brr +} + +// ToChatRequest converts a BifrostResponsesRequest to BifrostChatRequest format +func (brr *BifrostResponsesRequest) ToChatRequest() *BifrostChatRequest { + if brr == nil { + return &BifrostChatRequest{} + } + + bcr := &BifrostChatRequest{ + Provider: brr.Provider, + Model: brr.Model, + Fallbacks: brr.Fallbacks, // Copy fallbacks as-is + } + + // Convert Input messages using existing ToChatMessages() + bcr.Input = ToChatMessages(brr.Input) + + // Convert Parameters + if brr.Params != nil { + bcr.Params = &ChatParameters{ + // Map common fields + ParallelToolCalls: brr.Params.ParallelToolCalls, + PromptCacheKey: brr.Params.PromptCacheKey, + SafetyIdentifier: brr.Params.SafetyIdentifier, + ServiceTier: brr.Params.ServiceTier, + Store: brr.Params.Store, + Temperature: brr.Params.Temperature, + TopLogProbs: brr.Params.TopLogProbs, + TopP: brr.Params.TopP, + ExtraParams: brr.Params.ExtraParams, + + // Map specific fields + MaxCompletionTokens: brr.Params.MaxOutputTokens, // max_output_tokens -> max_completion_tokens + Metadata: brr.Params.Metadata, + } + + // Convert StreamOptions + if brr.Params.StreamOptions != nil { + bcr.Params.StreamOptions = &ChatStreamOptions{ + IncludeObfuscation: brr.Params.StreamOptions.IncludeObfuscation, + IncludeUsage: Ptr(true), // Default for Chat API + } + } + + // Convert Tools using existing ResponsesTool.ToChatTool() + if len(brr.Params.Tools) > 0 { + chatTools := make([]ChatTool, 0, len(brr.Params.Tools)) + for _, responsesTool := range brr.Params.Tools { + chatTool := responsesTool.ToChatTool() + chatTools = append(chatTools, *chatTool) + } + bcr.Params.Tools = chatTools + } + + // Convert ToolChoice using existing ResponsesToolChoice.ToChatToolChoice() + if brr.Params.ToolChoice != nil { + chatToolChoice := brr.Params.ToolChoice.ToChatToolChoice() + bcr.Params.ToolChoice = chatToolChoice + } + + // Handle ReasoningEffort from Reasoning + if brr.Params.Reasoning != nil && brr.Params.Reasoning.Effort != nil { + bcr.Params.ReasoningEffort = brr.Params.Reasoning.Effort + } + + // Handle Verbosity from Text config + if brr.Params.Text != nil && brr.Params.Text.Verbosity != nil { + bcr.Params.Verbosity = brr.Params.Text.Verbosity + } + } + + bcr.RawRequestBody = brr.RawRequestBody + + return bcr +} + +// ============================================================================= +// RESPONSE CONVERSION METHODS +// ============================================================================= + +// ToBifrostResponsesResponse converts the BifrostChatResponse to BifrostResponsesResponse format +// This converts Chat-style fields (Choices) to Responses API format +func (cr *BifrostChatResponse) ToBifrostResponsesResponse() *BifrostResponsesResponse { + if cr == nil { + return nil + } + + // Create new BifrostResponsesResponse from Chat fields + responsesResp := &BifrostResponsesResponse{ + CreatedAt: cr.Created, + Citations: cr.Citations, + SearchResults: cr.SearchResults, + Videos: cr.Videos, + } + + // Convert Choices to Output messages + var outputMessages []ResponsesMessage + for _, choice := range cr.Choices { + if choice.ChatNonStreamResponseChoice != nil && choice.ChatNonStreamResponseChoice.Message != nil { + // Convert ChatMessage to ResponsesMessages + responsesMessages := choice.ChatNonStreamResponseChoice.Message.ToResponsesMessages() + outputMessages = append(outputMessages, responsesMessages...) + } + // Note: Stream choices would need different handling if needed + } + + if len(outputMessages) > 0 { + responsesResp.Output = outputMessages + } + + // Convert Usage if needed + if cr.Usage != nil { + responsesResp.Usage = cr.Usage.ToResponsesResponseUsage() + } + + // Copy other relevant fields + responsesResp.ExtraFields = cr.ExtraFields + responsesResp.ExtraFields.RequestType = ResponsesRequest + + return responsesResp +} + +// ToBifrostChatResponse converts a BifrostResponsesResponse to BifrostChatResponse format +// This converts Responses API format to Chat-style fields (Choices) +func (responsesResp *BifrostResponsesResponse) ToBifrostChatResponse() *BifrostChatResponse { + if responsesResp == nil { + return nil + } + + // Create new BifrostChatResponse from Responses fields + chatResp := &BifrostChatResponse{ + Created: responsesResp.CreatedAt, + Object: "chat.completion", + Citations: responsesResp.Citations, + SearchResults: responsesResp.SearchResults, + Videos: responsesResp.Videos, + } + + // Create Choices from ResponsesResponse + if len(responsesResp.Output) > 0 { + // Convert ResponsesMessages back to ChatMessages + chatMessages := ToChatMessages(responsesResp.Output) + + // Create choices from chat messages + choices := make([]BifrostResponseChoice, 0, len(chatMessages)) + for i, chatMsg := range chatMessages { + choice := BifrostResponseChoice{ + Index: i, + ChatNonStreamResponseChoice: &ChatNonStreamResponseChoice{ + Message: &chatMsg, + }, + } + choices = append(choices, choice) + } + + chatResp.Choices = choices + } + + // Convert Usage if needed + if responsesResp.Usage != nil { + // Map Responses usage to Chat usage + chatResp.Usage = responsesResp.Usage.ToBifrostLLMUsage() + } + + // Copy other relevant fields + chatResp.ExtraFields = responsesResp.ExtraFields + chatResp.ExtraFields.RequestType = ChatCompletionRequest + chatResp.ExtraFields.Provider = responsesResp.ExtraFields.Provider + + return chatResp +} + +// ChatToResponsesStreamState tracks state during Chat-to-Responses streaming conversion +type ChatToResponsesStreamState struct { + ToolArgumentBuffers map[string]string // Maps tool call ID to accumulated argument JSON + ItemIDs map[string]string // Maps tool call ID to item ID + ToolCallNames map[string]string // Maps tool call ID to tool name + ToolCallIndexToID map[uint16]string // Maps tool call index to tool call ID (for lookups when ID is missing) + MessageID *string // Message ID from first chunk + Model *string // Model name + CreatedAt int // Timestamp for created_at consistency + HasEmittedCreated bool // Whether we've emitted response.created + HasEmittedInProgress bool // Whether we've emitted response.in_progress + TextItemAdded bool // Whether text item has been added + TextItemClosed bool // Whether text item has been closed + TextItemHasContent bool // Whether text item has received any content deltas + CurrentOutputIndex int // Current output index counter + ToolCallOutputIndices map[string]int // Maps tool call ID to output index + SequenceNumber int // Monotonic sequence number across all chunks +} + +// chatToResponsesStreamStatePool provides a pool for ChatToResponsesStreamState objects. +var chatToResponsesStreamStatePool = sync.Pool{ + New: func() interface{} { + return &ChatToResponsesStreamState{ + ToolArgumentBuffers: make(map[string]string), + ItemIDs: make(map[string]string), + ToolCallNames: make(map[string]string), + ToolCallIndexToID: make(map[uint16]string), + CreatedAt: int(time.Now().Unix()), + CurrentOutputIndex: 0, + ToolCallOutputIndices: make(map[string]int), + SequenceNumber: 0, + HasEmittedCreated: false, + HasEmittedInProgress: false, + TextItemAdded: false, + TextItemClosed: false, + TextItemHasContent: false, + } + }, +} + +// AcquireChatToResponsesStreamState gets a ChatToResponsesStreamState from the pool. +func AcquireChatToResponsesStreamState() *ChatToResponsesStreamState { + state := chatToResponsesStreamStatePool.Get().(*ChatToResponsesStreamState) + // Clear maps (they're already initialized from New or previous flush) + // Only initialize if nil (shouldn't happen, but defensive) + if state.ToolArgumentBuffers == nil { + state.ToolArgumentBuffers = make(map[string]string) + } else { + clear(state.ToolArgumentBuffers) + } + if state.ItemIDs == nil { + state.ItemIDs = make(map[string]string) + } else { + clear(state.ItemIDs) + } + if state.ToolCallNames == nil { + state.ToolCallNames = make(map[string]string) + } else { + clear(state.ToolCallNames) + } + if state.ToolCallIndexToID == nil { + state.ToolCallIndexToID = make(map[uint16]string) + } else { + clear(state.ToolCallIndexToID) + } + if state.ToolCallOutputIndices == nil { + state.ToolCallOutputIndices = make(map[string]int) + } else { + clear(state.ToolCallOutputIndices) + } + // Reset other fields + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + state.TextItemAdded = false + state.TextItemClosed = false + state.TextItemHasContent = false + state.SequenceNumber = 0 + return state +} + +// ReleaseChatToResponsesStreamState returns a ChatToResponsesStreamState to the pool. +func ReleaseChatToResponsesStreamState(state *ChatToResponsesStreamState) { + if state != nil { + // Clear maps before returning to pool + if state.ToolArgumentBuffers != nil { + clear(state.ToolArgumentBuffers) + } + if state.ItemIDs != nil { + clear(state.ItemIDs) + } + if state.ToolCallNames != nil { + clear(state.ToolCallNames) + } + if state.ToolCallIndexToID != nil { + clear(state.ToolCallIndexToID) + } + if state.ToolCallOutputIndices != nil { + clear(state.ToolCallOutputIndices) + } + // Reset other fields + state.CurrentOutputIndex = 0 + state.MessageID = nil + state.Model = nil + state.CreatedAt = int(time.Now().Unix()) + state.HasEmittedCreated = false + state.HasEmittedInProgress = false + state.TextItemAdded = false + state.TextItemClosed = false + state.TextItemHasContent = false + state.SequenceNumber = 0 + chatToResponsesStreamStatePool.Put(state) + } +} + +// ToBifrostResponsesStreamResponse converts the BifrostChatResponse from Chat streaming format to Responses streaming format +// This converts Chat stream chunks (Choices with Deltas) to BifrostResponsesStreamResponse format +// Returns a slice of responses to support cases where a single event produces multiple responses +func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToResponsesStreamState) []*BifrostResponsesStreamResponse { + if cr == nil || state == nil { + return nil + } + + // If no choices to convert, return early + if len(cr.Choices) == 0 { + return nil + } + + // Convert first streaming choice to BifrostResponsesStreamResponse + // Note: Chat API typically has one choice per chunk in streaming + choice := cr.Choices[0] + if choice.ChatStreamResponseChoice == nil || choice.ChatStreamResponseChoice.Delta == nil { + return nil + } + + delta := choice.ChatStreamResponseChoice.Delta + var responses []*BifrostResponsesStreamResponse + + // Store message ID and model from first chunk + if state.MessageID == nil && cr.ID != "" { + state.MessageID = &cr.ID + } + if state.Model == nil && cr.Model != "" { + state.Model = &cr.Model + } + + // Emit lifecycle events on first chunk with role + if delta.Role != nil && !state.HasEmittedCreated { + // Emit response.created + response := &BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + } + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeCreated, + SequenceNumber: state.SequenceNumber, + Response: response, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + state.HasEmittedCreated = true + + // Emit response.in_progress + response = &BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + } + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeInProgress, + SequenceNumber: state.SequenceNumber, + Response: response, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + state.HasEmittedInProgress = true + } + + // Handle different types of streaming content + if delta.Content != nil && *delta.Content != "" { + // Text content delta + if !state.TextItemAdded { + // Add text item if not already added + outputIndex := 0 + // Generate stable ID for text item + var itemID string + if state.MessageID == nil { + itemID = fmt.Sprintf("item_%d", outputIndex) + } else { + itemID = fmt.Sprintf("msg_%s_item_%d", *state.MessageID, outputIndex) + } + state.ItemIDs["text"] = itemID + + messageType := ResponsesMessageTypeMessage + role := ResponsesInputMessageRoleAssistant + + item := &ResponsesMessage{ + ID: &itemID, + Type: &messageType, + Role: &role, + Content: &ResponsesMessageContent{ + ContentBlocks: []ResponsesMessageContentBlock{}, + }, + } + + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(0), + Item: item, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + state.TextItemAdded = true + } + + // Emit text delta + itemID := state.ItemIDs["text"] + response := &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeOutputTextDelta, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(0), + ContentIndex: Ptr(0), + Delta: delta.Content, + ExtraFields: cr.ExtraFields, + } + if itemID != "" { + response.ItemID = &itemID + } + responses = append(responses, response) + state.SequenceNumber++ + state.TextItemHasContent = true + } + + if len(delta.ToolCalls) > 0 { + // Tool call delta - handle function call arguments + toolCall := delta.ToolCalls[0] // Take first tool call + contentIndex := 1 // Tool calls use content_index:1 + + // Determine tool call ID: use ID if present, otherwise look up by index + var toolCallID string + if toolCall.ID != nil && *toolCall.ID != "" { + toolCallID = *toolCall.ID + } else { + // Look up ID by index for subsequent chunks that don't include the ID + if id, exists := state.ToolCallIndexToID[toolCall.Index]; exists { + toolCallID = id + } else { + // No ID and no mapping found - skip this chunk + // This can happen if the stream is malformed or out of order + return responses + } + } + + // Check if this is a new tool call (only when ID is present) + if toolCall.ID != nil && *toolCall.ID != "" { + if _, exists := state.ToolCallOutputIndices[toolCallID]; !exists { + // Close text item if still open and has content + if state.TextItemAdded && !state.TextItemClosed && state.TextItemHasContent { + outputIndex := 0 + statusCompleted := "completed" + itemID := state.ItemIDs["text"] + doneItem := &ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(0), + Item: doneItem, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + state.TextItemClosed = true + } + + // Assign new output index for tool call + outputIndex := state.CurrentOutputIndex + if outputIndex == 0 { + outputIndex = 1 // Skip 0 if text is using it + } + state.CurrentOutputIndex = outputIndex + 1 + state.ToolCallOutputIndices[toolCallID] = outputIndex + + // Store tool call info and index mapping + state.ItemIDs[toolCallID] = toolCallID + state.ToolCallIndexToID[toolCall.Index] = toolCallID + if toolCall.Function.Name != nil { + state.ToolCallNames[toolCallID] = *toolCall.Function.Name + } + + // Initialize argument buffer + state.ToolArgumentBuffers[toolCallID] = "" + + // Emit output_item.added for function call + statusInProgress := "in_progress" + item := &ResponsesMessage{ + ID: &toolCallID, + Type: Ptr(ResponsesMessageTypeFunctionCall), + Status: &statusInProgress, + ResponsesToolMessage: &ResponsesToolMessage{ + CallID: &toolCallID, + Name: toolCall.Function.Name, + Arguments: Ptr(""), // Arguments will be filled by deltas + }, + } + + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeOutputItemAdded, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(contentIndex), + Item: item, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + } + } + + // Accumulate and emit function call arguments delta + // This works for both chunks with ID and chunks without ID (using looked-up ID) + if toolCall.Function.Arguments != "" { + outputIndex := state.ToolCallOutputIndices[toolCallID] + state.ToolArgumentBuffers[toolCallID] += toolCall.Function.Arguments + + itemID := state.ItemIDs[toolCallID] + response := &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeFunctionCallArgumentsDelta, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(contentIndex), + Delta: &toolCall.Function.Arguments, + ExtraFields: cr.ExtraFields, + } + if itemID != "" { + response.ItemID = &itemID + } + responses = append(responses, response) + state.SequenceNumber++ + } + } + + if delta.Thought != nil && *delta.Thought != "" { + // Reasoning/thought content delta (for models that support reasoning) + response := &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeReasoningSummaryTextDelta, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(0), + Delta: delta.Thought, + ExtraFields: cr.ExtraFields, + } + responses = append(responses, response) + state.SequenceNumber++ + } + + if delta.Refusal != nil && *delta.Refusal != "" { + // Refusal delta + response := &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeRefusalDelta, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(0), + Refusal: delta.Refusal, + ExtraFields: cr.ExtraFields, + } + responses = append(responses, response) + state.SequenceNumber++ + } + + // Check if this is a completion chunk with finish_reason + if choice.FinishReason != nil { + // Close text item if still open and has content + if state.TextItemAdded && !state.TextItemClosed && state.TextItemHasContent { + outputIndex := 0 + statusCompleted := "completed" + itemID := state.ItemIDs["text"] + doneItem := &ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + doneItem.ID = &itemID + } + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(0), + Item: doneItem, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + state.TextItemClosed = true + } + + // Close any open tool call items and emit function_call_arguments.done + for toolCallID, args := range state.ToolArgumentBuffers { + if args != "" { + outputIndex := state.ToolCallOutputIndices[toolCallID] + itemID := state.ItemIDs[toolCallID] + contentIndex := 1 // Tool calls use content_index:1 + argsCopy := args + // Emit function_call_arguments.done with full arguments (no item field, just item_id and arguments) + response := &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeFunctionCallArgumentsDone, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(contentIndex), + Arguments: &argsCopy, + ExtraFields: cr.ExtraFields, + } + if itemID != "" { + response.ItemID = &itemID + } + responses = append(responses, response) + state.SequenceNumber++ + + // Emit output_item.done for function call + statusCompleted := "completed" + outputItemDone := &ResponsesMessage{ + Status: &statusCompleted, + } + if itemID != "" { + outputItemDone.ID = &itemID + } + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeOutputItemDone, + SequenceNumber: state.SequenceNumber, + OutputIndex: Ptr(outputIndex), + ContentIndex: Ptr(contentIndex), + Item: outputItemDone, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + } + } + + // Emit response.completed + var usage *ResponsesResponseUsage + if cr.Usage != nil { + usage = cr.Usage.ToResponsesResponseUsage() + } + + response := &BifrostResponsesResponse{ + ID: state.MessageID, + CreatedAt: state.CreatedAt, + Usage: usage, + } + + responses = append(responses, &BifrostResponsesStreamResponse{ + Type: ResponsesStreamResponseTypeCompleted, + SequenceNumber: state.SequenceNumber, + Response: response, + ExtraFields: cr.ExtraFields, + }) + state.SequenceNumber++ + } + + // Set RequestType for all responses + for _, resp := range responses { + if resp != nil { + resp.ExtraFields.RequestType = ResponsesStreamRequest + // Copy other extra fields + resp.SearchResults = cr.SearchResults + resp.Videos = cr.Videos + resp.Citations = cr.Citations + } + } + + return responses +} diff --git a/core/schemas/plugin.go b/core/schemas/plugin.go index c10adebf3..f2d275e63 100644 --- a/core/schemas/plugin.go +++ b/core/schemas/plugin.go @@ -3,28 +3,96 @@ package schemas import "context" +// PluginShortCircuit represents a plugin's decision to short-circuit the normal flow. +// It can contain either a response (success short-circuit), a stream (streaming short-circuit), or an error (error short-circuit). +type PluginShortCircuit struct { + Response *BifrostResponse // If set, short-circuit with this response (skips provider call) + Stream chan *BifrostStream // If set, short-circuit with this stream (skips provider call) + Error *BifrostError // If set, short-circuit with this error (can set AllowFallbacks field) +} + +// PluginStatus constants +const ( + PluginStatusActive = "active" + PluginStatusError = "error" + PluginStatusDisabled = "disabled" + PluginStatusLoading = "loading" + PluginStatusUninitialized = "uninitialized" + PluginStatusUnloaded = "unloaded" + PluginStatusLoaded = "loaded" +) + +// PluginStatus represents the status of a plugin. +type PluginStatus struct { + Name string `json:"name"` + Status string `json:"status"` + Logs []string `json:"logs"` +} + // Plugin defines the interface for Bifrost plugins. // Plugins can intercept and modify requests and responses at different stages // of the processing pipeline. // User can provide multiple plugins in the BifrostConfig. // PreHooks are executed in the order they are registered. // PostHooks are executed in the reverse order of PreHooks. - -// PreHooks and PostHooks can be used to implement custom logic, such as: -// - Rate limiting -// - Caching -// - Logging -// - Monitoring +// +// Execution order: +// 1. TransportInterceptor (HTTP transport only, modifies raw headers/body before entering Bifrost core) +// 2. PreHook (executed in registration order) +// 3. Provider call +// 4. PostHook (executed in reverse order of PreHooks) +// +// Common use cases: rate limiting, caching, logging, monitoring, request transformation, governance. +// +// Plugin error handling: +// - No Plugin errors are returned to the caller; they are logged as warnings by the Bifrost instance. +// - PreHook and PostHook can both modify the request/response and the error. Plugins can recover from errors (set error to nil and provide a response), or invalidate a response (set response to nil and provide an error). +// - PostHook is always called with both the current response and error, and should handle either being nil. +// - Only truly empty errors (no message, no error, no status code, no type) are treated as recoveries by the pipeline. +// - If a PreHook returns a PluginShortCircuit, the provider call may be skipped and only the PostHook methods of plugins that had their PreHook executed are called in reverse order. +// - The plugin pipeline ensures symmetry: for every PreHook executed, the corresponding PostHook will be called in reverse order. +// +// IMPORTANT: When returning BifrostError from PreHook or PostHook: +// - You can set the AllowFallbacks field to control fallback behavior +// - AllowFallbacks = &true: Allow Bifrost to try fallback providers +// - AllowFallbacks = &false: Do not try fallbacks, return error immediately +// - AllowFallbacks = nil: Treated as true by default (allow fallbacks for resilience) +// +// Plugin authors should ensure their hooks are robust to both response and error being nil, and should not assume either is always present. type Plugin interface { + // GetName returns the name of the plugin. + GetName() string + + // TransportInterceptor is called at the HTTP transport layer before requests enter Bifrost core. + // It allows plugins to modify raw HTTP headers and body before transformation into BifrostRequest. + // Only invoked when using HTTP transport (bifrost-http), not when using Bifrost as a Go SDK directly. + // Returns modified headers, modified body, and any error that occurred during interception. + TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) + // PreHook is called before a request is processed by a provider. // It allows plugins to modify the request before it is sent to the provider. // The context parameter can be used to maintain state across plugin calls. - // Returns the modified request and any error that occurred during processing. - PreHook(ctx *context.Context, req *BifrostRequest) (*BifrostRequest, error) + // Returns the modified request, an optional short-circuit decision, and any error that occurred during processing. + PreHook(ctx *context.Context, req *BifrostRequest) (*BifrostRequest, *PluginShortCircuit, error) + + // PostHook is called after a response is received from a provider or a PreHook short-circuit. + // It allows plugins to modify the response and/or error before it is returned to the caller. + // Plugins can recover from errors (set error to nil and provide a response), or invalidate a response (set response to nil and provide an error). + // Returns the modified response, bifrost error, and any error that occurred during processing. + PostHook(ctx *context.Context, result *BifrostResponse, err *BifrostError) (*BifrostResponse, *BifrostError, error) + + // Cleanup is called on bifrost shutdown. + // It allows plugins to clean up any resources they have allocated. + // Returns any error that occurred during cleanup, which will be logged as a warning by the Bifrost instance. + Cleanup() error +} - // PostHook is called after a response is received from a provider. - // It allows plugins to modify the response before it is returned to the caller. - // Returns the modified response and any error that occurred during processing. - PostHook(ctx *context.Context, result *BifrostResponse) (*BifrostResponse, error) +// PluginConfig is the configuration for a plugin. +// It contains the name of the plugin, whether it is enabled, and the configuration for the plugin. +type PluginConfig struct { + Enabled bool `json:"enabled"` + Name string `json:"name"` + Path *string `json:"path,omitempty"` + Config any `json:"config,omitempty"` } diff --git a/core/schemas/provider.go b/core/schemas/provider.go index 56376b730..cb83b6e63 100644 --- a/core/schemas/provider.go +++ b/core/schemas/provider.go @@ -1,54 +1,57 @@ // Package schemas defines the core schemas and types used by the Bifrost system. package schemas -import "time" +import ( + "context" + "maps" + "time" +) const ( - DefaultMaxRetries = 0 - DefaultRetryBackoffInitial = 500 * time.Millisecond - DefaultRetryBackoffMax = 5 * time.Second - DefaultRequestTimeoutInSeconds = 30 - DefaultBufferSize = 100 - DefaultConcurrency = 10 + DefaultMaxRetries = 0 + DefaultRetryBackoffInitial = 500 * time.Millisecond + DefaultRetryBackoffMax = 5 * time.Second + DefaultRequestTimeoutInSeconds = 30 + DefaultStreamInactivityTimeoutInSeconds = 60 + DefaultBufferSize = 5000 + DefaultConcurrency = 1000 + DefaultStreamBufferSize = 5000 ) // Pre-defined errors for provider operations const ( - ErrProviderRequest = "failed to make HTTP request to provider API" - ErrProviderResponseUnmarshal = "failed to unmarshal response from provider API" - ErrProviderJSONMarshaling = "failed to marshal request body to JSON" - ErrProviderDecodeStructured = "failed to decode provider's structured response" - ErrProviderDecodeRaw = "failed to decode provider's raw response" - ErrProviderDecompress = "failed to decompress provider's response" + ErrProviderRequestTimedOut = "request timed out (default is 30 seconds). You can increase it by setting the default_request_timeout_in_seconds in the network_config or in UI - Providers > Provider Name > Network Config." + ErrRequestCancelled = "request cancelled by caller" + ErrRequestBodyConversion = "failed to convert bifrost request to the expected provider request body" + ErrProviderRequestMarshal = "failed to marshal request body to JSON" + ErrProviderCreateRequest = "failed to create HTTP request to provider API" + ErrProviderDoRequest = "failed to execute HTTP request to provider API" + ErrProviderResponseDecode = "failed to decode response body from provider API" + ErrProviderResponseUnmarshal = "failed to unmarshal response from provider API" + ErrProviderRawResponseUnmarshal = "failed to unmarshal raw response from provider API" + ErrProviderResponseDecompress = "failed to decompress provider's response" ) // NetworkConfig represents the network configuration for provider connections. +// ExtraHeaders is automatically copied during provider initialization to prevent data races. type NetworkConfig struct { - DefaultRequestTimeoutInSeconds int `json:"default_request_timeout_in_seconds"` // Default timeout for requests - MaxRetries int `json:"max_retries"` // Maximum number of retries - RetryBackoffInitial time.Duration `json:"retry_backoff_initial"` // Initial backoff duration - RetryBackoffMax time.Duration `json:"retry_backoff_max"` // Maximum backoff duration + // BaseURL is supported for OpenAI, Anthropic, Cohere, Mistral, and Ollama providers (required for Ollama) + BaseURL string `json:"base_url,omitempty"` // Base URL for the provider (optional) + ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to include in requests (optional) + DefaultRequestTimeoutInSeconds int `json:"default_request_timeout_in_seconds"` // Default timeout for requests + StreamInactivityTimeoutInSeconds int `json:"stream_inactivity_timeout_in_seconds"` // Timeout for streaming request inactivity (default: 60 seconds) + MaxRetries int `json:"max_retries"` // Maximum number of retries + RetryBackoffInitial time.Duration `json:"retry_backoff_initial"` // Initial backoff duration + RetryBackoffMax time.Duration `json:"retry_backoff_max"` // Maximum backoff duration } -// MetaConfig defines the interface for provider-specific configuration. -// Check /meta folder for implemented provider-specific meta configurations. -type MetaConfig interface { - // GetSecretAccessKey returns the secret access key for authentication - GetSecretAccessKey() *string - // GetRegion returns the region for the provider - GetRegion() *string - // GetSessionToken returns the session token for authentication - GetSessionToken() *string - // GetARN returns the Amazon Resource Name (ARN) - GetARN() *string - // GetInferenceProfiles returns the inference profiles - GetInferenceProfiles() map[string]string - // GetEndpoint returns the provider endpoint - GetEndpoint() *string - // GetDeployments returns the deployment configurations - GetDeployments() map[string]string - // GetAPIVersion returns the API version - GetAPIVersion() *string +// DefaultNetworkConfig is the default network configuration for provider connections. +var DefaultNetworkConfig = NetworkConfig{ + DefaultRequestTimeoutInSeconds: DefaultRequestTimeoutInSeconds, + StreamInactivityTimeoutInSeconds: DefaultStreamInactivityTimeoutInSeconds, + MaxRetries: DefaultMaxRetries, + RetryBackoffInitial: DefaultRetryBackoffInitial, + RetryBackoffMax: DefaultRetryBackoffMax, } // ConcurrencyAndBufferSize represents configuration for concurrent operations and buffer sizes. @@ -57,14 +60,20 @@ type ConcurrencyAndBufferSize struct { BufferSize int `json:"buffer_size"` // Size of the buffer } +// DefaultConcurrencyAndBufferSize is the default concurrency and buffer size for provider operations. +var DefaultConcurrencyAndBufferSize = ConcurrencyAndBufferSize{ + Concurrency: DefaultConcurrency, + BufferSize: DefaultBufferSize, +} + // ProxyType defines the type of proxy to use for connections. type ProxyType string const ( // NoProxy indicates no proxy should be used NoProxy ProxyType = "none" - // HttpProxy indicates an HTTP proxy should be used - HttpProxy ProxyType = "http" + // HTTPProxy indicates an HTTP proxy should be used + HTTPProxy ProxyType = "http" // Socks5Proxy indicates a SOCKS5 proxy should be used Socks5Proxy ProxyType = "socks5" // EnvProxy indicates the proxy should be read from environment variables @@ -79,24 +88,154 @@ type ProxyConfig struct { Password string `json:"password"` // Password for proxy authentication } +// AllowedRequests controls which operations are permitted. +// A nil *AllowedRequests means "all operations allowed." +// A non-nil value only allows fields set to true; omitted or false fields are disallowed. +type AllowedRequests struct { + ListModels bool `json:"list_models"` + TextCompletion bool `json:"text_completion"` + TextCompletionStream bool `json:"text_completion_stream"` + ChatCompletion bool `json:"chat_completion"` + ChatCompletionStream bool `json:"chat_completion_stream"` + Responses bool `json:"responses"` + ResponsesStream bool `json:"responses_stream"` + Embedding bool `json:"embedding"` + Speech bool `json:"speech"` + SpeechStream bool `json:"speech_stream"` + Transcription bool `json:"transcription"` + TranscriptionStream bool `json:"transcription_stream"` +} + +// IsOperationAllowed checks if a specific operation is allowed +func (ar *AllowedRequests) IsOperationAllowed(operation RequestType) bool { + if ar == nil { + return true // Default to allowed if no restrictions + } + + switch operation { + case ListModelsRequest: + return ar.ListModels + case TextCompletionRequest: + return ar.TextCompletion + case TextCompletionStreamRequest: + return ar.TextCompletionStream + case ChatCompletionRequest: + return ar.ChatCompletion + case ChatCompletionStreamRequest: + return ar.ChatCompletionStream + case ResponsesRequest: + return ar.Responses + case ResponsesStreamRequest: + return ar.ResponsesStream + case EmbeddingRequest: + return ar.Embedding + case SpeechRequest: + return ar.Speech + case SpeechStreamRequest: + return ar.SpeechStream + case TranscriptionRequest: + return ar.Transcription + case TranscriptionStreamRequest: + return ar.TranscriptionStream + default: + return false // Default to not allowed for unknown operations + } +} + +type CustomProviderConfig struct { + CustomProviderKey string `json:"-"` // Custom provider key, internally set by Bifrost + IsKeyLess bool `json:"is_key_less"` // Whether the custom provider requires a key (not allowed for Bedrock) + BaseProviderType ModelProvider `json:"base_provider_type"` // Base provider type + AllowedRequests *AllowedRequests `json:"allowed_requests,omitempty"` // Allowed requests for the custom provider + RequestPathOverrides map[RequestType]string `json:"request_path_overrides,omitempty"` // Mapping of request type to its custom path which will override the default path of the provider (not allowed for Bedrock) +} + +// IsOperationAllowed checks if a specific operation is allowed for this custom provider +func (cpc *CustomProviderConfig) IsOperationAllowed(operation RequestType) bool { + if cpc == nil || cpc.AllowedRequests == nil { + return true // Default to allowed if no restrictions + } + return cpc.AllowedRequests.IsOperationAllowed(operation) +} + // ProviderConfig represents the complete configuration for a provider. -// An array of ProviderConfig needs to provided in GetConfigForProvider +// An array of ProviderConfig needs to be provided in GetConfigForProvider // in your account interface implementation. type ProviderConfig struct { NetworkConfig NetworkConfig `json:"network_config"` // Network configuration - MetaConfig MetaConfig `json:"meta_config,omitempty"` // Provider-specific configuration ConcurrencyAndBufferSize ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size"` // Concurrency settings // Logger instance, can be provided by the user or bifrost default logger is used if not provided - Logger Logger `json:"logger"` - ProxyConfig *ProxyConfig `json:"proxy_config,omitempty"` // Proxy configuration + Logger Logger `json:"-"` + ProxyConfig *ProxyConfig `json:"proxy_config,omitempty"` // Proxy configuration + SendBackRawResponse bool `json:"send_back_raw_response"` // Send raw response back in the bifrost response (default: false) + CustomProviderConfig *CustomProviderConfig `json:"custom_provider_config,omitempty"` } +func (config *ProviderConfig) CheckAndSetDefaults() { + if config.ConcurrencyAndBufferSize.Concurrency == 0 { + config.ConcurrencyAndBufferSize.Concurrency = DefaultConcurrency + } + + if config.ConcurrencyAndBufferSize.BufferSize == 0 { + config.ConcurrencyAndBufferSize.BufferSize = DefaultBufferSize + } + + if config.NetworkConfig.DefaultRequestTimeoutInSeconds == 0 { + config.NetworkConfig.DefaultRequestTimeoutInSeconds = DefaultRequestTimeoutInSeconds + } + + if config.NetworkConfig.StreamInactivityTimeoutInSeconds == 0 { + config.NetworkConfig.StreamInactivityTimeoutInSeconds = DefaultStreamInactivityTimeoutInSeconds + } + + if config.NetworkConfig.MaxRetries == 0 { + config.NetworkConfig.MaxRetries = DefaultMaxRetries + } + + if config.NetworkConfig.RetryBackoffInitial == 0 { + config.NetworkConfig.RetryBackoffInitial = DefaultRetryBackoffInitial + } + + if config.NetworkConfig.RetryBackoffMax == 0 { + config.NetworkConfig.RetryBackoffMax = DefaultRetryBackoffMax + } + + // Create a defensive copy of ExtraHeaders to prevent data races + if config.NetworkConfig.ExtraHeaders != nil { + headersCopy := make(map[string]string, len(config.NetworkConfig.ExtraHeaders)) + maps.Copy(headersCopy, config.NetworkConfig.ExtraHeaders) + config.NetworkConfig.ExtraHeaders = headersCopy + } +} + +type PostHookRunner func(ctx *context.Context, result *BifrostResponse, err *BifrostError) (*BifrostResponse, *BifrostError) + // Provider defines the interface for AI model providers. type Provider interface { // GetProviderKey returns the provider's identifier GetProviderKey() ModelProvider + // ListModels performs a list models request + ListModels(ctx context.Context, keys []Key, request *BifrostListModelsRequest) (*BifrostListModelsResponse, *BifrostError) // TextCompletion performs a text completion request - TextCompletion(model, key, text string, params *ModelParameters) (*BifrostResponse, *BifrostError) + TextCompletion(ctx context.Context, key Key, request *BifrostTextCompletionRequest) (*BifrostTextCompletionResponse, *BifrostError) + // TextCompletionStream performs a text completion stream request + TextCompletionStream(ctx context.Context, postHookRunner PostHookRunner, key Key, request *BifrostTextCompletionRequest) (chan *BifrostStream, *BifrostError) // ChatCompletion performs a chat completion request - ChatCompletion(model, key string, messages []Message, params *ModelParameters) (*BifrostResponse, *BifrostError) + ChatCompletion(ctx context.Context, key Key, request *BifrostChatRequest) (*BifrostChatResponse, *BifrostError) + // ChatCompletionStream performs a chat completion stream request + ChatCompletionStream(ctx context.Context, postHookRunner PostHookRunner, key Key, request *BifrostChatRequest) (chan *BifrostStream, *BifrostError) + // Responses performs a completion request using the Responses API (uses chat completion request internally for non-openai providers) + Responses(ctx context.Context, key Key, request *BifrostResponsesRequest) (*BifrostResponsesResponse, *BifrostError) + // ResponsesStream performs a completion request using the Responses API stream (uses chat completion stream request internally for non-openai providers) + ResponsesStream(ctx context.Context, postHookRunner PostHookRunner, key Key, request *BifrostResponsesRequest) (chan *BifrostStream, *BifrostError) + // Embedding performs an embedding request + Embedding(ctx context.Context, key Key, request *BifrostEmbeddingRequest) (*BifrostEmbeddingResponse, *BifrostError) + // Speech performs a text to speech request + Speech(ctx context.Context, key Key, request *BifrostSpeechRequest) (*BifrostSpeechResponse, *BifrostError) + // SpeechStream performs a text to speech stream request + SpeechStream(ctx context.Context, postHookRunner PostHookRunner, key Key, request *BifrostSpeechRequest) (chan *BifrostStream, *BifrostError) + // Transcription performs a transcription request + Transcription(ctx context.Context, key Key, request *BifrostTranscriptionRequest) (*BifrostTranscriptionResponse, *BifrostError) + // TranscriptionStream performs a transcription stream request + TranscriptionStream(ctx context.Context, postHookRunner PostHookRunner, key Key, request *BifrostTranscriptionRequest) (chan *BifrostStream, *BifrostError) } diff --git a/core/schemas/responses.go b/core/schemas/responses.go new file mode 100644 index 000000000..048f925af --- /dev/null +++ b/core/schemas/responses.go @@ -0,0 +1,1450 @@ +package schemas + +import ( + "fmt" + + "github.com/bytedance/sonic" +) + +// ============================================================================= +// OPENAI RESPONSES API SCHEMAS +// ============================================================================= +// +// This file contains all the schema definitions for the OpenAI Responses API. +// +// Structure: +// 1. Core API Request/Response Structures +// 2. Input Message Structures +// 3. Output Message Structures +// 4. Tool Call Structures (organized by tool type) +// 5. Tool Configuration Structures +// 6. Tool Choice Configuration +// +// Union Types: +// - Many structs use "union types" where only one field should be set +// - These are implemented with pointer fields and custom JSON marshaling +// ============================================================================= + +// ============================================================================= +// 1. CORE API REQUEST/RESPONSE STRUCTURES +// ============================================================================= + +type BifrostResponsesRequest struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` + Input []ResponsesMessage `json:"input,omitempty"` + Params *ResponsesParameters `json:"params,omitempty"` + Fallbacks []Fallback `json:"fallbacks,omitempty"` + RawRequestBody []byte `json:"-"` // set bifrost-use-raw-request-body to true in ctx to use the raw request body. Bifrost will directly send this to the downstream provider. +} + +func (r *BifrostResponsesRequest) GetRawRequestBody() []byte { + return r.RawRequestBody +} + +type BifrostResponsesResponse struct { + ID *string `json:"id,omitempty"` // used for internal conversions + + Background *bool `json:"background,omitempty"` + Conversation *ResponsesResponseConversation `json:"conversation,omitempty"` + CreatedAt int `json:"created_at"` // Unix timestamp when Response was created + Error *ResponsesResponseError `json:"error,omitempty"` + Include []string `json:"include,omitempty"` // Supported values: "web_search_call.action.sources", "code_interpreter_call.outputs", "computer_call_output.output.image_url", "file_search_call.results", "message.input_image.image_url", "message.output_text.logprobs", "reasoning.encrypted_content" + IncompleteDetails *ResponsesResponseIncompleteDetails `json:"incomplete_details,omitempty"` // Details about why the response is incomplete + Instructions *ResponsesResponseInstructions `json:"instructions,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + Metadata *map[string]any `json:"metadata,omitempty"` + Output []ResponsesMessage `json:"output,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + PreviousResponseID *string `json:"previous_response_id,omitempty"` + Prompt *ResponsesPrompt `json:"prompt,omitempty"` // Reference to a prompt template and variables + PromptCacheKey *string `json:"prompt_cache_key,omitempty"` // Prompt cache key + Reasoning *ResponsesParametersReasoning `json:"reasoning,omitempty"` // Configuration options for reasoning models + SafetyIdentifier *string `json:"safety_identifier,omitempty"` // Safety identifier + ServiceTier *string `json:"service_tier,omitempty"` + StreamOptions *ResponsesStreamOptions `json:"stream_options,omitempty"` + Store *bool `json:"store,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Text *ResponsesTextConfig `json:"text,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + TopP *float64 `json:"top_p,omitempty"` // Controls diversity via nucleus sampling + ToolChoice *ResponsesToolChoice `json:"tool_choice,omitempty"` // Whether to call a tool + Tools []ResponsesTool `json:"tools,omitempty"` // Tools to use + Truncation *string `json:"truncation,omitempty"` + Usage *ResponsesResponseUsage `json:"usage,omitempty"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` + + // Perplexity-specific fields + SearchResults []SearchResult `json:"search_results,omitempty"` + Videos []VideoResult `json:"videos,omitempty"` + Citations []string `json:"citations,omitempty"` +} + +type ResponsesParameters struct { + Background *bool `json:"background,omitempty"` + Conversation *string `json:"conversation,omitempty"` + Include []string `json:"include,omitempty"` // Supported values: "web_search_call.action.sources", "code_interpreter_call.outputs", "computer_call_output.output.image_url", "file_search_call.results", "message.input_image.image_url", "message.output_text.logprobs", "reasoning.encrypted_content" + Instructions *string `json:"instructions,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + Metadata *map[string]any `json:"metadata,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + PreviousResponseID *string `json:"previous_response_id,omitempty"` + PromptCacheKey *string `json:"prompt_cache_key,omitempty"` // Prompt cache key + Reasoning *ResponsesParametersReasoning `json:"reasoning,omitempty"` // Configuration options for reasoning models + SafetyIdentifier *string `json:"safety_identifier,omitempty"` // Safety identifier + ServiceTier *string `json:"service_tier,omitempty"` + StreamOptions *ResponsesStreamOptions `json:"stream_options,omitempty"` + Store *bool `json:"store,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Text *ResponsesTextConfig `json:"text,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + TopP *float64 `json:"top_p,omitempty"` // Controls diversity via nucleus sampling + ToolChoice *ResponsesToolChoice `json:"tool_choice,omitempty"` // Whether to call a tool + Tools []ResponsesTool `json:"tools,omitempty"` // Tools to use + Truncation *string `json:"truncation,omitempty"` + User *string `json:"user,omitempty"` + // Dynamic parameters that can be provider-specific, they are directly + // added to the request as is. + ExtraParams map[string]interface{} `json:"-"` +} + +type ResponsesStreamOptions struct { + IncludeObfuscation *bool `json:"include_obfuscation,omitempty"` +} + +type ResponsesTextConfig struct { + Format *ResponsesTextConfigFormat `json:"format,omitempty"` // An object specifying the format that the model must output + Verbosity *string `json:"verbosity,omitempty"` // "low" | "medium" | "high" or null +} + +type ResponsesTextConfigFormat struct { + Type string `json:"type"` // "text" | "json_schema" | "json_object" + Name *string `json:"name,omitempty"` // Name of the format + JSONSchema *ResponsesTextConfigFormatJSONSchema `json:"schema,omitempty"` // when type == "json_schema" + Strict *bool `json:"strict,omitempty"` +} + +// ResponsesTextConfigFormatJSONSchema represents a JSON schema specification +type ResponsesTextConfigFormatJSONSchema struct { + AdditionalProperties *bool `json:"additionalProperties,omitempty"` + Properties *map[string]any `json:"properties,omitempty"` + Required *[]string `json:"required,omitempty"` + Type *string `json:"type,omitempty"` +} + +type ResponsesResponseConversation struct { + ResponsesResponseConversationStr *string + ResponsesResponseConversationStruct *ResponsesResponseConversationStruct +} + +// MarshalJSON implements custom JSON marshalling for ResponsesMessageContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (rc ResponsesResponseConversation) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if rc.ResponsesResponseConversationStr != nil && rc.ResponsesResponseConversationStruct != nil { + return nil, fmt.Errorf("both ResponsesResponseConversationStr and ResponsesResponseConversationStruct are set; only one should be non-nil") + } + + if rc.ResponsesResponseConversationStr != nil { + return sonic.Marshal(*rc.ResponsesResponseConversationStr) + } + if rc.ResponsesResponseConversationStruct != nil { + return sonic.Marshal(rc.ResponsesResponseConversationStruct) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ResponsesMessageContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (rc *ResponsesResponseConversation) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + rc.ResponsesResponseConversationStr = &stringContent + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var structContent ResponsesResponseConversationStruct + if err := sonic.Unmarshal(data, &structContent); err == nil { + rc.ResponsesResponseConversationStruct = &structContent + return nil + } + + return fmt.Errorf("content field is neither a string nor a struct") +} + +type ResponsesResponseInstructions struct { + ResponsesResponseInstructionsStr *string + ResponsesResponseInstructionsArray []ResponsesMessage +} + +// MarshalJSON implements custom JSON marshalling for ResponsesMessageContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (rc ResponsesResponseInstructions) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if rc.ResponsesResponseInstructionsStr != nil && rc.ResponsesResponseInstructionsArray != nil { + return nil, fmt.Errorf("both ResponsesMessageContentStr and ResponsesMessageContentBlocks are set; only one should be non-nil") + } + + if rc.ResponsesResponseInstructionsStr != nil { + return sonic.Marshal(*rc.ResponsesResponseInstructionsStr) + } + if rc.ResponsesResponseInstructionsArray != nil { + return sonic.Marshal(rc.ResponsesResponseInstructionsArray) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ResponsesMessageContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (rc *ResponsesResponseInstructions) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + rc.ResponsesResponseInstructionsStr = &stringContent + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var arrayContent []ResponsesMessage + if err := sonic.Unmarshal(data, &arrayContent); err == nil { + rc.ResponsesResponseInstructionsArray = arrayContent + return nil + } + + return fmt.Errorf("content field is neither a string nor an array of Messages") +} + +type ResponsesPrompt struct { + ID string `json:"id"` + Variables map[string]any `json:"variables"` + Version *string `json:"version,omitempty"` +} + +type ResponsesParametersReasoning struct { + Effort *string `json:"effort,omitempty"` // "minimal" | "low" | "medium" | "high" + GenerateSummary *string `json:"generate_summary,omitempty"` // Deprecated: use summary instead + Summary *string `json:"summary,omitempty"` // "auto" | "concise" | "detailed" +} + +type ResponsesResponseConversationStruct struct { + ID string `json:"id"` // The unique ID of the conversation +} + +type ResponsesResponseError struct { + Code string `json:"code"` // The error code for the response + Message string `json:"message"` // A human-readable description of the error +} + +type ResponsesResponseIncompleteDetails struct { + Reason string `json:"reason"` // The reason why the response is incomplete +} + +type ResponsesResponseUsage struct { + InputTokens int `json:"input_tokens"` // Number of input tokens + InputTokensDetails *ResponsesResponseInputTokens `json:"input_tokens_details"` // Detailed breakdown of input tokens + OutputTokens int `json:"output_tokens"` // Number of output tokens + OutputTokensDetails *ResponsesResponseOutputTokens `json:"output_tokens_details"` // Detailed breakdown of output tokens TotalTokens int `json:"total_tokens"` // Total number of tokens used + TotalTokens int `json:"total_tokens"` // Total number of tokens used + Cost *BifrostCost `json:"cost,omitempty"` // Only for the providers which support cost calculation +} + +type ResponsesResponseInputTokens struct { + AudioTokens int `json:"audio_tokens"` // Tokens for audio input + CachedTokens int `json:"cached_tokens"` // Tokens retrieved from cache +} + +type ResponsesResponseOutputTokens struct { + AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` + CitationTokens *int `json:"citation_tokens,omitempty"` + NumSearchQueries *int `json:"num_search_queries,omitempty"` +} + +// ============================================================================= +// 2. INPUT MESSAGE STRUCTURES +// ============================================================================= + +type ResponsesMessageType string + +const ( + ResponsesMessageTypeMessage ResponsesMessageType = "message" + ResponsesMessageTypeFileSearchCall ResponsesMessageType = "file_search_call" + ResponsesMessageTypeComputerCall ResponsesMessageType = "computer_call" + ResponsesMessageTypeComputerCallOutput ResponsesMessageType = "computer_call_output" + ResponsesMessageTypeWebSearchCall ResponsesMessageType = "web_search_call" + ResponsesMessageTypeFunctionCall ResponsesMessageType = "function_call" + ResponsesMessageTypeFunctionCallOutput ResponsesMessageType = "function_call_output" + ResponsesMessageTypeCodeInterpreterCall ResponsesMessageType = "code_interpreter_call" + ResponsesMessageTypeLocalShellCall ResponsesMessageType = "local_shell_call" + ResponsesMessageTypeLocalShellCallOutput ResponsesMessageType = "local_shell_call_output" + ResponsesMessageTypeMCPCall ResponsesMessageType = "mcp_call" + ResponsesMessageTypeCustomToolCall ResponsesMessageType = "custom_tool_call" + ResponsesMessageTypeCustomToolCallOutput ResponsesMessageType = "custom_tool_call_output" + ResponsesMessageTypeImageGenerationCall ResponsesMessageType = "image_generation_call" + ResponsesMessageTypeMCPListTools ResponsesMessageType = "mcp_list_tools" + ResponsesMessageTypeMCPApprovalRequest ResponsesMessageType = "mcp_approval_request" + ResponsesMessageTypeMCPApprovalResponses ResponsesMessageType = "mcp_approval_responses" + ResponsesMessageTypeReasoning ResponsesMessageType = "reasoning" + ResponsesMessageTypeItemReference ResponsesMessageType = "item_reference" + ResponsesMessageTypeRefusal ResponsesMessageType = "refusal" +) + +// ResponsesMessage is a union type that can contain different types of input items +// Only one of the fields should be set at a time +type ResponsesMessage struct { + ID *string `json:"id,omitempty"` // Common ID field for most item types + Type *ResponsesMessageType `json:"type,omitempty"` + Status *string `json:"status,omitempty"` // "in_progress" | "completed" | "incomplete" | "interpreting" | "failed" + + Role *ResponsesMessageRoleType `json:"role,omitempty"` + Content *ResponsesMessageContent `json:"content,omitempty"` + + *ResponsesToolMessage // For Tool calls and outputs + + // Reasoning + *ResponsesReasoning +} + +type ResponsesMessageRoleType string + +const ( + ResponsesInputMessageRoleAssistant ResponsesMessageRoleType = "assistant" + ResponsesInputMessageRoleUser ResponsesMessageRoleType = "user" + ResponsesInputMessageRoleSystem ResponsesMessageRoleType = "system" + ResponsesInputMessageRoleDeveloper ResponsesMessageRoleType = "developer" +) + +// ResponsesMessageContent is a union type that can be either a string or array of content blocks +type ResponsesMessageContent struct { + ContentStr *string // Simple text content + + // Output will ALWAYS be an array of content blocks + ContentBlocks []ResponsesMessageContentBlock // Rich content with multiple media types +} + +// MarshalJSON implements custom JSON marshalling for ResponsesMessageContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (rc ResponsesMessageContent) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if rc.ContentStr != nil && rc.ContentBlocks != nil { + return nil, fmt.Errorf("both ResponsesMessageContentStr and ResponsesMessageContentBlocks are set; only one should be non-nil") + } + + if rc.ContentStr != nil { + return sonic.Marshal(*rc.ContentStr) + } + if rc.ContentBlocks != nil { + return sonic.Marshal(rc.ContentBlocks) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ResponsesMessageContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (rc *ResponsesMessageContent) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + rc.ContentStr = &stringContent + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var arrayContent []ResponsesMessageContentBlock + if err := sonic.Unmarshal(data, &arrayContent); err == nil { + rc.ContentBlocks = arrayContent + return nil + } + + return fmt.Errorf("content field is neither a string nor an array of Content blocks") +} + +type ResponsesMessageContentBlockType string + +const ( + ResponsesInputMessageContentBlockTypeText ResponsesMessageContentBlockType = "input_text" + ResponsesInputMessageContentBlockTypeImage ResponsesMessageContentBlockType = "input_image" + ResponsesInputMessageContentBlockTypeFile ResponsesMessageContentBlockType = "input_file" + ResponsesInputMessageContentBlockTypeAudio ResponsesMessageContentBlockType = "input_audio" + ResponsesOutputMessageContentTypeText ResponsesMessageContentBlockType = "output_text" + ResponsesOutputMessageContentTypeRefusal ResponsesMessageContentBlockType = "refusal" + ResponsesOutputMessageContentTypeReasoning ResponsesMessageContentBlockType = "reasoning_text" +) + +// ResponsesMessageContentBlock represents different types of content (text, image, file, audio) +// Only one of the content type fields should be set +type ResponsesMessageContentBlock struct { + Type ResponsesMessageContentBlockType `json:"type"` + FileID *string `json:"file_id,omitempty"` // Reference to uploaded file + Text *string `json:"text,omitempty"` + + *ResponsesInputMessageContentBlockImage + *ResponsesInputMessageContentBlockFile + Audio *ResponsesInputMessageContentBlockAudio `json:"input_audio,omitempty"` + + *ResponsesOutputMessageContentText // Normal text output from the model + *ResponsesOutputMessageContentRefusal // Model refusal to answer +} + +type ResponsesInputMessageContentBlockImage struct { + ImageURL *string `json:"image_url,omitempty"` + Detail *string `json:"detail,omitempty"` // "low" | "high" | "auto" +} + +type ResponsesInputMessageContentBlockFile struct { + FileData *string `json:"file_data,omitempty"` // Base64 encoded file data + FileURL *string `json:"file_url,omitempty"` // Direct URL to file + Filename *string `json:"filename,omitempty"` // Name of the file +} + +type ResponsesInputMessageContentBlockAudio struct { + Format string `json:"format"` // "mp3" or "wav" + Data string `json:"data"` // base64 encoded audio data +} + +// ============================================================================= +// 3. OUTPUT MESSAGE STRUCTURES +// ============================================================================= + +type ResponsesOutputMessageContentText struct { + Annotations []ResponsesOutputMessageContentTextAnnotation `json:"annotations,omitempty"` // Citations and references + LogProbs []ResponsesOutputMessageContentTextLogProb `json:"logprobs,omitempty"` // Token log probabilities +} + +type ResponsesOutputMessageContentTextAnnotation struct { + Type string `json:"type"` // "file_citation" | "url_citation" | "container_file_citation" | "file_path" + Index *int `json:"index,omitempty"` // Common index field (FileCitation, FilePath) + FileID *string `json:"file_id,omitempty"` // Common file ID field (FileCitation, ContainerFileCitation, FilePath) + Text *string `json:"text,omitempty"` // Text of the citation + StartIndex *int `json:"start_index,omitempty"` // Common start index field (URLCitation, ContainerFileCitation) + EndIndex *int `json:"end_index,omitempty"` // Common end index field (URLCitation, ContainerFileCitation) + Filename *string `json:"filename,omitempty"` + Title *string `json:"title,omitempty"` + URL *string `json:"url,omitempty"` + ContainerID *string `json:"container_id,omitempty"` +} + +// ResponsesOutputMessageContentTextLogProb represents log probability information for content. +type ResponsesOutputMessageContentTextLogProb struct { + Bytes []int `json:"bytes"` + LogProb float64 `json:"logprob"` + Token string `json:"token"` + TopLogProbs []LogProb `json:"top_logprobs"` +} +type ResponsesOutputMessageContentRefusal struct { + Refusal string `json:"refusal"` +} + +type ResponsesToolMessage struct { + CallID *string `json:"call_id,omitempty"` // Common call ID for tool calls and outputs + Name *string `json:"name,omitempty"` // Common name field for tool calls + Arguments *string `json:"arguments,omitempty"` + Output *ResponsesToolMessageOutputStruct `json:"output,omitempty"` + Action *ResponsesToolMessageActionStruct `json:"action,omitempty"` + Error *string `json:"error,omitempty"` + + // Tool calls and outputs + *ResponsesFileSearchToolCall + *ResponsesComputerToolCall + *ResponsesComputerToolCallOutput + *ResponsesCodeInterpreterToolCall + *ResponsesMCPToolCall + *ResponsesCustomToolCall + *ResponsesImageGenerationCall + + // MCP-specific + *ResponsesMCPListTools + *ResponsesMCPApprovalResponse +} + +type ResponsesToolMessageActionStruct struct { + ResponsesComputerToolCallAction *ResponsesComputerToolCallAction + ResponsesWebSearchToolCallAction *ResponsesWebSearchToolCallAction + ResponsesLocalShellToolCallAction *ResponsesLocalShellToolCallAction + ResponsesMCPApprovalRequestAction *ResponsesMCPApprovalRequestAction +} + +func (action ResponsesToolMessageActionStruct) MarshalJSON() ([]byte, error) { + if action.ResponsesComputerToolCallAction != nil { + return sonic.Marshal(action.ResponsesComputerToolCallAction) + } + if action.ResponsesWebSearchToolCallAction != nil { + return sonic.Marshal(action.ResponsesWebSearchToolCallAction) + } + if action.ResponsesLocalShellToolCallAction != nil { + return sonic.Marshal(action.ResponsesLocalShellToolCallAction) + } + if action.ResponsesMCPApprovalRequestAction != nil { + return sonic.Marshal(action.ResponsesMCPApprovalRequestAction) + } + return nil, fmt.Errorf("responses tool message action struct is neither a computer tool call action nor a web search tool call action nor a local shell tool call action nor a mcp approval request action") +} + +func (action *ResponsesToolMessageActionStruct) UnmarshalJSON(data []byte) error { + var computerToolCallAction ResponsesComputerToolCallAction + if err := sonic.Unmarshal(data, &computerToolCallAction); err == nil { + action.ResponsesComputerToolCallAction = &computerToolCallAction + return nil + } + var webSearchToolCallAction ResponsesWebSearchToolCallAction + if err := sonic.Unmarshal(data, &webSearchToolCallAction); err == nil { + action.ResponsesWebSearchToolCallAction = &webSearchToolCallAction + return nil + } + var localShellToolCallAction ResponsesLocalShellToolCallAction + if err := sonic.Unmarshal(data, &localShellToolCallAction); err == nil { + action.ResponsesLocalShellToolCallAction = &localShellToolCallAction + return nil + } + var mcpApprovalRequestAction ResponsesMCPApprovalRequestAction + if err := sonic.Unmarshal(data, &mcpApprovalRequestAction); err == nil { + action.ResponsesMCPApprovalRequestAction = &mcpApprovalRequestAction + return nil + } + return fmt.Errorf("responses tool message action struct is neither a computer tool call action nor a web search tool call action nor a local shell tool call action nor a mcp approval request action") +} + +type ResponsesToolMessageOutputStruct struct { + ResponsesToolCallOutputStr *string // Common output string for tool calls and outputs (used by function, custom and local shell tool calls) + ResponsesFunctionToolCallOutputBlocks []ResponsesMessageContentBlock + ResponsesComputerToolCallOutput *ResponsesComputerToolCallOutputData +} + +func (output ResponsesToolMessageOutputStruct) MarshalJSON() ([]byte, error) { + if output.ResponsesToolCallOutputStr != nil { + return sonic.Marshal(*output.ResponsesToolCallOutputStr) + } + if output.ResponsesFunctionToolCallOutputBlocks != nil { + return sonic.Marshal(output.ResponsesFunctionToolCallOutputBlocks) + } + if output.ResponsesComputerToolCallOutput != nil { + return sonic.Marshal(output.ResponsesComputerToolCallOutput) + } + return nil, fmt.Errorf("responses tool message output struct is neither a string nor an array of responses message content blocks nor a computer tool call output data") +} +func (output *ResponsesToolMessageOutputStruct) UnmarshalJSON(data []byte) error { + var str string + if err := sonic.Unmarshal(data, &str); err == nil { + output.ResponsesToolCallOutputStr = &str + return nil + } + var array []ResponsesMessageContentBlock + if err := sonic.Unmarshal(data, &array); err == nil { + output.ResponsesFunctionToolCallOutputBlocks = array + return nil + } + var computerToolCallOutput ResponsesComputerToolCallOutputData + if err := sonic.Unmarshal(data, &computerToolCallOutput); err == nil { + output.ResponsesComputerToolCallOutput = &computerToolCallOutput + return nil + } + return fmt.Errorf("responses tool message output struct is neither a string nor an array of responses message content blocks nor a computer tool call output data") +} + +// ============================================================================= +// 4. TOOL CALL STRUCTURES (organized by tool type) +// ============================================================================= + +// ----------------------------------------------------------------------------- +// File Search Tool +// ----------------------------------------------------------------------------- + +type ResponsesFileSearchToolCall struct { + Queries []string `json:"queries"` + Results []ResponsesFileSearchToolCallResult `json:"results,omitempty"` +} + +type ResponsesFileSearchToolCallResult struct { + Attributes *map[string]any `json:"attributes,omitempty"` + FileID *string `json:"file_id,omitempty"` + Filename *string `json:"filename,omitempty"` + Score *float64 `json:"score,omitempty"` + Text *string `json:"text,omitempty"` +} + +// ResponsesComputerToolCall represents a computer tool call +type ResponsesComputerToolCall struct { + PendingSafetyChecks []ResponsesComputerToolCallPendingSafetyCheck `json:"pending_safety_checks,omitempty"` +} + +// ResponsesComputerToolCallPendingSafetyCheck represents a pending safety check +type ResponsesComputerToolCallPendingSafetyCheck struct { + ID string `json:"id"` + Code string `json:"code"` + Message string `json:"message"` +} + +// ResponsesComputerToolCallAction represents the different types of computer actions +type ResponsesComputerToolCallAction struct { + Type string `json:"type"` // "click" | "double_click" | "drag" | "keypress" | "move" | "screenshot" | "scroll" | "type" | "wait" + X *int `json:"x,omitempty"` // Common X coordinate field (Click, DoubleClick, Move, Scroll) + Y *int `json:"y,omitempty"` // Common Y coordinate field (Click, DoubleClick, Move, Scroll) + Button *string `json:"button,omitempty"` // "left" | "right" | "wheel" | "back" | "forward" + Path []ResponsesComputerToolCallActionPath `json:"path,omitempty"` + Keys []string `json:"keys,omitempty"` + ScrollX *int `json:"scroll_x,omitempty"` + ScrollY *int `json:"scroll_y,omitempty"` + Text *string `json:"text,omitempty"` +} + +type ResponsesComputerToolCallActionPath struct { + X int `json:"x"` + Y int `json:"y"` +} + +// ResponsesComputerToolCallOutput represents a computer tool call output +type ResponsesComputerToolCallOutput struct { + AcknowledgedSafetyChecks []ResponsesComputerToolCallAcknowledgedSafetyCheck `json:"acknowledged_safety_checks,omitempty"` +} + +// ResponsesComputerToolCallOutputData represents a computer screenshot image used with the computer use tool +type ResponsesComputerToolCallOutputData struct { + Type string `json:"type"` // always "computer_screenshot" + FileID *string `json:"file_id,omitempty"` + ImageURL *string `json:"image_url,omitempty"` +} + +// ResponsesComputerToolCallAcknowledgedSafetyCheck represents a safety check that has been acknowledged by the developer +type ResponsesComputerToolCallAcknowledgedSafetyCheck struct { + ID string `json:"id"` + Code *string `json:"code,omitempty"` + Message *string `json:"message,omitempty"` +} + +// ----------------------------------------------------------------------------- +// Web Search Tool +// ----------------------------------------------------------------------------- + +// ResponsesWebSearchToolCallAction represents the different types of web search actions +type ResponsesWebSearchToolCallAction struct { + Type string `json:"type"` // "search" | "open_page" | "find" + URL *string `json:"url,omitempty"` // Common URL field (OpenPage, Find) + Query *string `json:"query,omitempty"` + Sources []ResponsesWebSearchToolCallActionSearchSource `json:"sources,omitempty"` + Pattern *string `json:"pattern,omitempty"` +} + +// ResponsesWebSearchToolCallActionSearchSource represents a web search action search source +type ResponsesWebSearchToolCallActionSearchSource struct { + Type string `json:"type"` // always "url" + URL string `json:"url"` +} + +// ----------------------------------------------------------------------------- +// Function Tool +// ----------------------------------------------------------------------------- + +// ResponsesFunctionToolCallOutput represents a function tool call output +type ResponsesFunctionToolCallOutput struct { + ResponsesFunctionToolCallOutputStr *string //A JSON string of the output of the function tool call. + ResponsesFunctionToolCallOutputBlocks []ResponsesMessageContentBlock +} + +// MarshalJSON implements custom JSON marshalling for ResponsesFunctionToolCallOutput. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (rf ResponsesFunctionToolCallOutput) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if rf.ResponsesFunctionToolCallOutputStr != nil && rf.ResponsesFunctionToolCallOutputBlocks != nil { + return nil, fmt.Errorf("both ResponsesFunctionToolCallOutputStr and ResponsesFunctionToolCallOutputBlocks are set; only one should be non-nil") + } + + if rf.ResponsesFunctionToolCallOutputStr != nil { + return sonic.Marshal(*rf.ResponsesFunctionToolCallOutputStr) + } + if rf.ResponsesFunctionToolCallOutputBlocks != nil { + return sonic.Marshal(rf.ResponsesFunctionToolCallOutputBlocks) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ResponsesFunctionToolCallOutput. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (rf *ResponsesFunctionToolCallOutput) UnmarshalJSON(data []byte) error { + // Parse as generic object to check if it contains content-like fields + var genericObj map[string]interface{} + if err := sonic.Unmarshal(data, &genericObj); err != nil { + return err + } + + // If the object doesn't contain typical content fields, it's probably not meant for this struct + // (e.g., it's a tool call, not a tool call output) + hasContentFields := false + for key := range genericObj { + if key == "content" || key == "output" || key == "result" { + hasContentFields = true + break + } + } + + if !hasContentFields { + return nil // Skip unmarshaling if no relevant content fields + } + + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + rf.ResponsesFunctionToolCallOutputStr = &stringContent + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var arrayContent []ResponsesMessageContentBlock + if err := sonic.Unmarshal(data, &arrayContent); err == nil { + rf.ResponsesFunctionToolCallOutputBlocks = arrayContent + return nil + } + + return fmt.Errorf("content field is neither a string nor an array of Content blocks") +} + +// ----------------------------------------------------------------------------- +// Reasoning +// ----------------------------------------------------------------------------- + +// ResponsesReasoning represents a reasoning output +type ResponsesReasoning struct { + Summary []ResponsesReasoningContent `json:"summary"` + EncryptedContent *string `json:"encrypted_content,omitempty"` +} + +// ResponsesReasoningContentBlockType represents the type of reasoning content +type ResponsesReasoningContentBlockType string + +// ResponsesReasoningContentBlockType values +const ( + ResponsesReasoningContentBlockTypeSummaryText ResponsesReasoningContentBlockType = "summary_text" +) + +// ResponsesReasoningContent represents a reasoning content block +type ResponsesReasoningContent struct { + Type ResponsesReasoningContentBlockType `json:"type"` + Text string `json:"text"` +} + +// ----------------------------------------------------------------------------- +// Image Generation Tool +// ----------------------------------------------------------------------------- + +// ResponsesImageGenerationCall represents an image generation tool call +type ResponsesImageGenerationCall struct { + Result string `json:"result"` +} + +// ----------------------------------------------------------------------------- +// Code Interpreter Tool +// ----------------------------------------------------------------------------- + +// ResponsesCodeInterpreterToolCall represents a code interpreter tool call +type ResponsesCodeInterpreterToolCall struct { + Code *string `json:"code"` // The code to run, or null if not available + ContainerID string `json:"container_id"` // The ID of the container used to run the code + Outputs []ResponsesCodeInterpreterOutput `json:"outputs"` // The outputs generated by the code interpreter, can be null +} + +// ResponsesCodeInterpreterOutput represents a code interpreter output +type ResponsesCodeInterpreterOutput struct { + *ResponsesCodeInterpreterOutputLogs + *ResponsesCodeInterpreterOutputImage +} + +// MarshalJSON implements custom JSON marshaling for ResponsesCodeInterpreterOutput +func (o ResponsesCodeInterpreterOutput) MarshalJSON() ([]byte, error) { + // Error if both variants are set + if o.ResponsesCodeInterpreterOutputLogs != nil && o.ResponsesCodeInterpreterOutputImage != nil { + return nil, fmt.Errorf("ResponsesCodeInterpreterOutput cannot have both Logs and Image set") + } + + // Marshal whichever one is present + if o.ResponsesCodeInterpreterOutputLogs != nil { + return sonic.Marshal(o.ResponsesCodeInterpreterOutputLogs) + } + if o.ResponsesCodeInterpreterOutputImage != nil { + return sonic.Marshal(o.ResponsesCodeInterpreterOutputImage) + } + + // Return null if neither is set + return []byte("null"), nil +} + +// UnmarshalJSON implements custom JSON unmarshaling for ResponsesCodeInterpreterOutput +func (o *ResponsesCodeInterpreterOutput) UnmarshalJSON(data []byte) error { + // Handle null case + if string(data) == "null" { + return nil + } + + // First, peek at the type field to determine which variant to unmarshal + var typeStruct struct { + Type string `json:"type"` + } + if err := sonic.Unmarshal(data, &typeStruct); err != nil { + return fmt.Errorf("failed to read type field: %w", err) + } + + // Unmarshal into the appropriate concrete type based on the type field + switch typeStruct.Type { + case "logs": + var logs ResponsesCodeInterpreterOutputLogs + if err := sonic.Unmarshal(data, &logs); err != nil { + return fmt.Errorf("failed to unmarshal logs output: %w", err) + } + o.ResponsesCodeInterpreterOutputLogs = &logs + o.ResponsesCodeInterpreterOutputImage = nil + return nil + + case "image": + var image ResponsesCodeInterpreterOutputImage + if err := sonic.Unmarshal(data, &image); err != nil { + return fmt.Errorf("failed to unmarshal image output: %w", err) + } + o.ResponsesCodeInterpreterOutputImage = &image + o.ResponsesCodeInterpreterOutputLogs = nil + return nil + + default: + return fmt.Errorf("unknown ResponsesCodeInterpreterOutput type: %s", typeStruct.Type) + } +} + +// ResponsesCodeInterpreterOutputLogs represents the logs output from the code interpreter +type ResponsesCodeInterpreterOutputLogs struct { + Logs string `json:"logs"` + Type string `json:"type"` // always "logs" +} + +// ResponsesCodeInterpreterOutputImage represents the image output from the code interpreter +type ResponsesCodeInterpreterOutputImage struct { + Type string `json:"type"` // always "image" + URL string `json:"url"` +} + +// ----------------------------------------------------------------------------- +// Local Shell Tool +// ----------------------------------------------------------------------------- + +// ResponsesLocalShellCallAction represents the different types of local shell actions +type ResponsesLocalShellToolCallAction struct { + Command []string `json:"command"` + Env []string `json:"env"` + Type string `json:"type"` // always "exec" + TimeoutMS *int `json:"timeout_ms,omitempty"` + User *string `json:"user,omitempty"` + WorkingDirectory *string `json:"working_directory,omitempty"` +} + +// ----------------------------------------------------------------------------- +// MCP (Model Context Protocol) Tools +// ----------------------------------------------------------------------------- + +// ResponsesMCPListTools represents a list of MCP tools +type ResponsesMCPListTools struct { + ServerLabel string `json:"server_label"` + Tools []ResponsesMCPTool `json:"tools"` +} + +// ResponsesMCPTool represents an MCP tool +type ResponsesMCPTool struct { + Name string `json:"name"` + InputSchema map[string]any `json:"input_schema"` + Description *string `json:"description,omitempty"` + Annotations *map[string]any `json:"annotations,omitempty"` +} + +// ResponsesMCPApprovalRequestAction represents the different types of MCP approval request actions +type ResponsesMCPApprovalRequestAction struct { + ID string `json:"id"` + Type string `json:"type"` // always "mcp_approval_request" + Name string `json:"name"` + ServerLabel string `json:"server_label"` + Arguments string `json:"arguments"` +} + +// ResponsesMCPApprovalResponse represents a MCP approval response +type ResponsesMCPApprovalResponse struct { + ApprovalResponseID string `json:"approval_response_id"` + Approve bool `json:"approve"` + Reason *string `json:"reason,omitempty"` +} + +// ResponsesMCPToolCall represents a MCP tool call +type ResponsesMCPToolCall struct { + ServerLabel string `json:"server_label"` // The label of the MCP server running the tool +} + +// ----------------------------------------------------------------------------- +// Custom Tools +// ----------------------------------------------------------------------------- + +// ResponsesCustomToolCall represents a custom tool call +type ResponsesCustomToolCall struct { + Input string `json:"input"` // The input for the custom tool call generated by the model +} + +// ============================================================================= +// 5. TOOL CHOICE CONFIGURATION +// ============================================================================= + +// Combined tool choices for all providers, make sure to check the provider's +// documentation to see which tool choices are supported + +// ResponsesToolChoiceType represents the type of tool choice +type ResponsesToolChoiceType string + +// ResponsesToolChoiceType values +const ( + // ResponsesToolChoiceTypeNone means no tool should be called + ResponsesToolChoiceTypeNone ResponsesToolChoiceType = "none" + // ResponsesToolChoiceTypeAuto means an automatic tool should be called + ResponsesToolChoiceTypeAuto ResponsesToolChoiceType = "auto" + // ResponsesToolChoiceTypeAny means any tool can be called + ResponsesToolChoiceTypeAny ResponsesToolChoiceType = "any" + // ResponsesToolChoiceTypeRequired means a specific tool must be called + ResponsesToolChoiceTypeRequired ResponsesToolChoiceType = "required" + // ResponsesToolChoiceTypeFunction means a specific tool must be called + ResponsesToolChoiceTypeFunction ResponsesToolChoiceType = "function" + // ResponsesToolChoiceTypeAllowedTools means a specific tool must be called + ResponsesToolChoiceTypeAllowedTools ResponsesToolChoiceType = "allowed_tools" + // ResponsesToolChoiceTypeFileSearch means a file search tool must be called + ResponsesToolChoiceTypeFileSearch ResponsesToolChoiceType = "file_search" + // ResponsesToolChoiceTypeWebSearchPreview means a web search preview tool must be called + ResponsesToolChoiceTypeWebSearchPreview ResponsesToolChoiceType = "web_search_preview" + // ResponsesToolChoiceTypeComputerUsePreview means a computer use preview tool must be called + ResponsesToolChoiceTypeComputerUsePreview ResponsesToolChoiceType = "computer_use_preview" + // ResponsesToolChoiceTypeCodeInterpreter means a code interpreter tool must be called + ResponsesToolChoiceTypeCodeInterpreter ResponsesToolChoiceType = "code_interpreter" + // ResponsesToolChoiceTypeImageGeneration means an image generation tool must be called + ResponsesToolChoiceTypeImageGeneration ResponsesToolChoiceType = "image_generation" + // ResponsesToolChoiceTypeMCP means an MCP tool must be called + ResponsesToolChoiceTypeMCP ResponsesToolChoiceType = "mcp" + // ResponsesToolChoiceTypeCustom means a custom tool must be called + ResponsesToolChoiceTypeCustom ResponsesToolChoiceType = "custom" +) + +// ResponsesToolChoiceStruct represents a tool choice struct +type ResponsesToolChoiceStruct struct { + Type ResponsesToolChoiceType `json:"type"` // Type of tool choice + Mode *string `json:"mode,omitempty"` //"none" | "auto" | "required" + Name *string `json:"name,omitempty"` // Common name field for function/MCP/custom tools + ServerLabel *string `json:"server_label,omitempty"` // Common server label field for MCP tools + Tools []ResponsesToolChoiceAllowedToolDef `json:"tools,omitempty"` +} + +// ResponsesToolChoice represents a tool choice +type ResponsesToolChoice struct { + ResponsesToolChoiceStr *string + ResponsesToolChoiceStruct *ResponsesToolChoiceStruct +} + +// MarshalJSON implements custom JSON marshalling for ChatMessageContent. +// It marshals either ContentStr or ContentBlocks directly without wrapping. +func (tc ResponsesToolChoice) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if tc.ResponsesToolChoiceStr != nil && tc.ResponsesToolChoiceStruct != nil { + return nil, fmt.Errorf("both ResponsesToolChoiceStr, ResponsesToolChoiceStruct are set; only one should be non-nil") + } + + if tc.ResponsesToolChoiceStr != nil { + return sonic.Marshal(tc.ResponsesToolChoiceStr) + } + if tc.ResponsesToolChoiceStruct != nil { + return sonic.Marshal(tc.ResponsesToolChoiceStruct) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ChatMessageContent. +// It determines whether "content" is a string or array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (tc *ResponsesToolChoice) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var toolChoiceStr string + if err := sonic.Unmarshal(data, &toolChoiceStr); err == nil { + tc.ResponsesToolChoiceStr = &toolChoiceStr + return nil + } + + // Try to unmarshal as a direct array of ContentBlock + var responsesToolChoiceStruct ResponsesToolChoiceStruct + if err := sonic.Unmarshal(data, &responsesToolChoiceStruct); err == nil { + tc.ResponsesToolChoiceStruct = &responsesToolChoiceStruct + return nil + } + + return fmt.Errorf("tool_choice field is neither a string nor a ResponsesToolChoiceStruct object") +} + +// ResponsesToolChoiceAllowedToolDef represents a tool choice allowed tool definition +type ResponsesToolChoiceAllowedToolDef struct { + Type string `json:"type"` // "function" | "mcp" | "image_generation" + Name *string `json:"name,omitempty"` // for function tools + ServerLabel *string `json:"server_label,omitempty"` // for MCP tools +} + +// ============================================================================= +// 7. TOOL CONFIGURATION STRUCTURES +// ============================================================================= + +type ResponsesToolType string + +const ( + ResponsesToolTypeFunction ResponsesToolType = "function" + ResponsesToolTypeFileSearch ResponsesToolType = "file_search" + ResponsesToolTypeComputerUsePreview ResponsesToolType = "computer_use_preview" + ResponsesToolTypeWebSearch ResponsesToolType = "web_search" + ResponsesToolTypeMCP ResponsesToolType = "mcp" + ResponsesToolTypeCodeInterpreter ResponsesToolType = "code_interpreter" + ResponsesToolTypeImageGeneration ResponsesToolType = "image_generation" + ResponsesToolTypeLocalShell ResponsesToolType = "local_shell" + ResponsesToolTypeCustom ResponsesToolType = "custom" + ResponsesToolTypeWebSearchPreview ResponsesToolType = "web_search_preview" +) + +// ResponsesTool represents a tool +type ResponsesTool struct { + Type ResponsesToolType `json:"type"` // "function" | "file_search" | "computer_use_preview" | "web_search" | "web_search_2025_08_26" | "mcp" | "code_interpreter" | "image_generation" | "local_shell" | "custom" | "web_search_preview" | "web_search_preview_2025_03_11" + Name *string `json:"name,omitempty"` // Common name field (Function, Custom tools) + Description *string `json:"description,omitempty"` // Common description field (Function, Custom tools) + + *ResponsesToolFunction + *ResponsesToolFileSearch + *ResponsesToolComputerUsePreview + *ResponsesToolWebSearch + *ResponsesToolMCP + *ResponsesToolCodeInterpreter + *ResponsesToolImageGeneration + *ResponsesToolLocalShell + *ResponsesToolCustom + *ResponsesToolWebSearchPreview +} + +// ResponsesToolFunction represents a tool function +type ResponsesToolFunction struct { + Parameters *ToolFunctionParameters `json:"parameters,omitempty"` // A JSON schema object describing the parameters + Strict *bool `json:"strict,omitempty"` // Whether to enforce strict parameter validation +} + +// ResponsesToolFileSearch represents a tool file search +type ResponsesToolFileSearch struct { + VectorStoreIDs []string `json:"vector_store_ids"` // The IDs of the vector stores to search + Filters *ResponsesToolFileSearchFilter `json:"filters,omitempty"` // A filter to apply + MaxNumResults *int `json:"max_num_results,omitempty"` // Maximum results (1-50) + RankingOptions *ResponsesToolFileSearchRankingOptions `json:"ranking_options,omitempty"` // Ranking options for search +} + +// ResponsesToolFileSearchFilter represents a file search filter +type ResponsesToolFileSearchFilter struct { + Type string `json:"type"` // "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "and" | "or" + + // Filter types - only one should be set + *ResponsesToolFileSearchComparisonFilter + *ResponsesToolFileSearchCompoundFilter +} + +// MarshalJSON implements custom JSON marshaling for ResponsesToolFileSearchFilter +func (f *ResponsesToolFileSearchFilter) MarshalJSON() ([]byte, error) { + // Validate that exactly one filter type is set + if f.ResponsesToolFileSearchComparisonFilter != nil && f.ResponsesToolFileSearchCompoundFilter != nil { + return nil, fmt.Errorf("both comparison and compound filters are set; only one should be non-nil") + } + if f.ResponsesToolFileSearchComparisonFilter == nil && f.ResponsesToolFileSearchCompoundFilter == nil { + return nil, fmt.Errorf("neither comparison nor compound filter is set; exactly one must be non-nil") + } + + // Create a map to hold the JSON data + result := make(map[string]interface{}) + result["type"] = f.Type + + // Marshal the appropriate embedded struct based on type + switch f.Type { + case "eq", "ne", "gt", "gte", "lt", "lte": + if f.ResponsesToolFileSearchComparisonFilter == nil { + return nil, fmt.Errorf("comparison filter is nil but type is %s", f.Type) + } + // Copy fields from the embedded struct + result["key"] = f.ResponsesToolFileSearchComparisonFilter.Key + result["value"] = f.ResponsesToolFileSearchComparisonFilter.Value + case "and", "or": + if f.ResponsesToolFileSearchCompoundFilter == nil { + return nil, fmt.Errorf("compound filter is nil but type is %s", f.Type) + } + // Copy fields from the embedded struct + result["filters"] = f.ResponsesToolFileSearchCompoundFilter.Filters + default: + return nil, fmt.Errorf("unknown filter type: %s", f.Type) + } + + return sonic.Marshal(result) +} + +// UnmarshalJSON implements custom JSON unmarshaling for ResponsesToolFileSearchFilter +func (f *ResponsesToolFileSearchFilter) UnmarshalJSON(data []byte) error { + // First, unmarshal into a map to inspect the type field + var raw map[string]interface{} + if err := sonic.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("failed to unmarshal filter JSON: %w", err) + } + + // Extract the type field + typeValue, ok := raw["type"] + if !ok { + return fmt.Errorf("missing required 'type' field in filter") + } + + typeStr, ok := typeValue.(string) + if !ok { + return fmt.Errorf("'type' field must be a string, got %T", typeValue) + } + + f.Type = typeStr + + // Initialize the appropriate embedded struct based on type + switch typeStr { + case "eq", "ne", "gt", "gte", "lt", "lte": + // This is a comparison filter + f.ResponsesToolFileSearchComparisonFilter = &ResponsesToolFileSearchComparisonFilter{} + f.ResponsesToolFileSearchCompoundFilter = nil + + // Unmarshal into the comparison filter + if err := sonic.Unmarshal(data, f.ResponsesToolFileSearchComparisonFilter); err != nil { + return fmt.Errorf("failed to unmarshal comparison filter: %w", err) + } + + // Validate required fields + if f.ResponsesToolFileSearchComparisonFilter.Key == "" { + return fmt.Errorf("comparison filter missing required 'key' field") + } + if f.ResponsesToolFileSearchComparisonFilter.Value == nil { + return fmt.Errorf("comparison filter missing required 'value' field") + } + + case "and", "or": + // This is a compound filter + f.ResponsesToolFileSearchCompoundFilter = &ResponsesToolFileSearchCompoundFilter{} + f.ResponsesToolFileSearchComparisonFilter = nil + + // Unmarshal into the compound filter + if err := sonic.Unmarshal(data, f.ResponsesToolFileSearchCompoundFilter); err != nil { + return fmt.Errorf("failed to unmarshal compound filter: %w", err) + } + + // Validate required fields + if f.ResponsesToolFileSearchCompoundFilter.Filters == nil { + return fmt.Errorf("compound filter missing required 'filters' field") + } + if len(f.ResponsesToolFileSearchCompoundFilter.Filters) == 0 { + return fmt.Errorf("compound filter 'filters' array cannot be empty") + } + + default: + return fmt.Errorf("unknown filter type: %s (supported types: eq, ne, gt, gte, lt, lte, and, or)", typeStr) + } + + return nil +} + +// ResponsesToolFileSearchComparisonFilter represents a file search comparison filter +type ResponsesToolFileSearchComparisonFilter struct { + Key string `json:"key"` // The key to compare against the value + Type string `json:"type"` // + Value interface{} `json:"value"` // The value to compare (string, number, or boolean) +} + +// ResponsesToolFileSearchCompoundFilter represents a file search compound filter +type ResponsesToolFileSearchCompoundFilter struct { + Filters []ResponsesToolFileSearchFilter `json:"filters"` // Array of filters to combine +} + +// ResponsesToolFileSearchRankingOptions represents a file search ranking options +type ResponsesToolFileSearchRankingOptions struct { + Ranker *string `json:"ranker,omitempty"` // The ranker to use + ScoreThreshold *float64 `json:"score_threshold,omitempty"` // Score threshold (0-1) +} + +// ResponsesToolComputerUsePreview represents a tool computer use preview +type ResponsesToolComputerUsePreview struct { + DisplayHeight int `json:"display_height"` // The height of the computer display + DisplayWidth int `json:"display_width"` // The width of the computer display + Environment string `json:"environment"` // The type of computer environment to control +} + +// ResponsesToolWebSearch represents a tool web search +type ResponsesToolWebSearch struct { + Filters *ResponsesToolWebSearchFilters `json:"filters,omitempty"` // Filters for the search + SearchContextSize *string `json:"search_context_size,omitempty"` // "low" | "medium" | "high" + UserLocation *ResponsesToolWebSearchUserLocation `json:"user_location,omitempty"` // The approximate location of the user +} + +// ResponsesToolWebSearchFilters represents filters for web search +type ResponsesToolWebSearchFilters struct { + AllowedDomains []string `json:"allowed_domains"` // Allowed domains for the search +} + +// ResponsesToolWebSearchUserLocation - The approximate location of the user +type ResponsesToolWebSearchUserLocation struct { + City *string `json:"city,omitempty"` // Free text input for the city + Country *string `json:"country,omitempty"` // Two-letter ISO country code + Region *string `json:"region,omitempty"` // Free text input for the region + Timezone *string `json:"timezone,omitempty"` // IANA timezone + Type *string `json:"type,omitempty"` // always "approximate" +} + +// ResponsesToolMCP - Give the model access to additional tools via remote MCP servers +type ResponsesToolMCP struct { + ServerLabel string `json:"server_label"` // A label for this MCP server + AllowedTools *ResponsesToolMCPAllowedTools `json:"allowed_tools,omitempty"` // List of allowed tool names or filter + Authorization *string `json:"authorization,omitempty"` // OAuth access token + ConnectorID *string `json:"connector_id,omitempty"` // Service connector ID + Headers *map[string]string `json:"headers,omitempty"` // Optional HTTP headers + RequireApproval *ResponsesToolMCPAllowedToolsApprovalSetting `json:"require_approval,omitempty"` // Tool approval settings + ServerDescription *string `json:"server_description,omitempty"` // Optional server description + ServerURL *string `json:"server_url,omitempty"` // The URL for the MCP server +} + +// ResponsesToolMCPAllowedTools - List of allowed tool names or a filter object +type ResponsesToolMCPAllowedTools struct { + // Either a simple array of tool names or a filter object + ToolNames []string `json:",omitempty"` + Filter *ResponsesToolMCPAllowedToolsFilter `json:",omitempty"` +} + +// ResponsesToolMCPAllowedToolsFilter - A filter object to specify which tools are allowed +type ResponsesToolMCPAllowedToolsFilter struct { + ReadOnly *bool `json:"read_only,omitempty"` // Whether tool is read-only + ToolNames []string `json:"tool_names,omitempty"` // List of allowed tool names +} + +// ResponsesToolMCPAllowedToolsApprovalSetting - Specify which tools require approval +type ResponsesToolMCPAllowedToolsApprovalSetting struct { + // Either a string setting or filter objects + Setting *string `json:",omitempty"` // "always" | "never" + Always *ResponsesToolMCPAllowedToolsApprovalFilter `json:"always,omitempty"` + Never *ResponsesToolMCPAllowedToolsApprovalFilter `json:"never,omitempty"` +} + +// MarshalJSON implements custom JSON marshalling for ResponsesToolMCPAllowedToolsApprovalSetting +func (as ResponsesToolMCPAllowedToolsApprovalSetting) MarshalJSON() ([]byte, error) { + // Validation: ensure only one representation is set + if as.Setting != nil && (as.Always != nil || as.Never != nil) { + return nil, fmt.Errorf("only one of 'Setting' or ('Always'/'Never') can be set") + } + + if as.Setting != nil { + return sonic.Marshal(*as.Setting) + } + if as.Always != nil || as.Never != nil { + // Marshal as an object with always/never fields + obj := make(map[string]interface{}) + if as.Always != nil { + obj["always"] = as.Always + } + if as.Never != nil { + obj["never"] = as.Never + } + return sonic.Marshal(obj) + } + // If all are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for ResponsesToolMCPAllowedToolsApprovalSetting +func (as *ResponsesToolMCPAllowedToolsApprovalSetting) UnmarshalJSON(data []byte) error { + // First, try to unmarshal as a direct string + var settingStr string + if err := sonic.Unmarshal(data, &settingStr); err == nil { + as.Setting = &settingStr + return nil + } + + // Try to unmarshal as an object with always/never fields + var obj struct { + Always *ResponsesToolMCPAllowedToolsApprovalFilter `json:"always,omitempty"` + Never *ResponsesToolMCPAllowedToolsApprovalFilter `json:"never,omitempty"` + } + if err := sonic.Unmarshal(data, &obj); err == nil { + as.Always = obj.Always + as.Never = obj.Never + return nil + } + + return fmt.Errorf("require_approval field is neither a string nor an object with always/never filters") +} + +// ResponsesToolMCPAllowedToolsApprovalFilter - Filter for approval settings +type ResponsesToolMCPAllowedToolsApprovalFilter struct { + ReadOnly *bool `json:"read_only,omitempty"` // Whether tool is read-only + ToolNames []string `json:"tool_names,omitempty"` // List of tool names +} + +// ResponsesToolCodeInterpreter represents a tool code interpreter +type ResponsesToolCodeInterpreter struct { + Container interface{} `json:"container"` // Container ID or object with file IDs +} + +// ResponsesToolImageGeneration represents a tool image generation +type ResponsesToolImageGeneration struct { + Background *string `json:"background,omitempty"` // "transparent" | "opaque" | "auto" + InputFidelity *string `json:"input_fidelity,omitempty"` // "high" | "low" + InputImageMask *ResponsesToolImageGenerationInputImageMask `json:"input_image_mask,omitempty"` // Optional mask for inpainting + Model *string `json:"model,omitempty"` // Image generation model + Moderation *string `json:"moderation,omitempty"` // Moderation level + OutputCompression *int `json:"output_compression,omitempty"` // Compression level (0-100) + OutputFormat *string `json:"output_format,omitempty"` // "png" | "webp" | "jpeg" + PartialImages *int `json:"partial_images,omitempty"` // Number of partial images (0-3) + Quality *string `json:"quality,omitempty"` // "low" | "medium" | "high" | "auto" + Size *string `json:"size,omitempty"` // Image size +} + +// ResponsesToolImageGenerationInputImageMask represents a image generation input image mask +type ResponsesToolImageGenerationInputImageMask struct { + FileID *string `json:"file_id,omitempty"` // File ID for the mask image + ImageURL *string `json:"image_url,omitempty"` // Base64-encoded mask image +} + +// ResponsesToolLocalShell represents a tool local shell +type ResponsesToolLocalShell struct { + // No unique fields needed since Type is now in the top-level struct +} + +// ResponsesToolCustom represents a custom tool +type ResponsesToolCustom struct { + Format *ResponsesToolCustomFormat `json:"format,omitempty"` // The input format +} + +// ResponsesToolCustomFormat represents the input format for the custom tool +type ResponsesToolCustomFormat struct { + Type string `json:"type"` // always "text" + + // For Grammar + Definition *string `json:"definition,omitempty"` // The grammar definition + Syntax *string `json:"syntax,omitempty"` // "lark" | "regex" +} + +// ResponsesToolWebSearchPreview represents a web search preview +type ResponsesToolWebSearchPreview struct { + SearchContextSize *string `json:"search_context_size,omitempty"` // "low" | "medium" | "high" + UserLocation *ResponsesToolWebSearchUserLocation `json:"user_location,omitempty"` // The user's location +} + +// ======================================================= Streaming Structs ======================================================= + +type ResponsesStreamResponseType string + +const ( + ResponsesStreamResponseTypeCreated ResponsesStreamResponseType = "response.created" + ResponsesStreamResponseTypeInProgress ResponsesStreamResponseType = "response.in_progress" + ResponsesStreamResponseTypeCompleted ResponsesStreamResponseType = "response.completed" + ResponsesStreamResponseTypeFailed ResponsesStreamResponseType = "response.failed" + ResponsesStreamResponseTypeIncomplete ResponsesStreamResponseType = "response.incomplete" + + ResponsesStreamResponseTypeOutputItemAdded ResponsesStreamResponseType = "response.output_item.added" + ResponsesStreamResponseTypeOutputItemDone ResponsesStreamResponseType = "response.output_item.done" + + ResponsesStreamResponseTypeContentPartAdded ResponsesStreamResponseType = "response.content_part.added" + ResponsesStreamResponseTypeContentPartDone ResponsesStreamResponseType = "response.content_part.done" + + ResponsesStreamResponseTypeOutputTextDelta ResponsesStreamResponseType = "response.output_text.delta" + ResponsesStreamResponseTypeOutputTextDone ResponsesStreamResponseType = "response.output_text.done" + + ResponsesStreamResponseTypeRefusalDelta ResponsesStreamResponseType = "response.refusal.delta" + ResponsesStreamResponseTypeRefusalDone ResponsesStreamResponseType = "response.refusal.done" + + ResponsesStreamResponseTypeFunctionCallArgumentsDelta ResponsesStreamResponseType = "response.function_call_arguments.delta" + ResponsesStreamResponseTypeFunctionCallArgumentsDone ResponsesStreamResponseType = "response.function_call_arguments.done" + ResponsesStreamResponseTypeFileSearchCallInProgress ResponsesStreamResponseType = "response.file_search_call.in_progress" + ResponsesStreamResponseTypeFileSearchCallSearching ResponsesStreamResponseType = "response.file_search_call.searching" + ResponsesStreamResponseTypeFileSearchCallResultsAdded ResponsesStreamResponseType = "response.file_search_call.results.added" + ResponsesStreamResponseTypeFileSearchCallResultsCompleted ResponsesStreamResponseType = "response.file_search_call.results.completed" + ResponsesStreamResponseTypeWebSearchCallSearching ResponsesStreamResponseType = "response.web_search_call.searching" + ResponsesStreamResponseTypeWebSearchCallResultsAdded ResponsesStreamResponseType = "response.web_search_call.results.added" + ResponsesStreamResponseTypeWebSearchCallResultsCompleted ResponsesStreamResponseType = "response.web_search_call.results.completed" + + ResponsesStreamResponseTypeReasoningSummaryPartAdded ResponsesStreamResponseType = "response.reasoning_summary_part.added" + ResponsesStreamResponseTypeReasoningSummaryPartDone ResponsesStreamResponseType = "response.reasoning_summary_part.done" + ResponsesStreamResponseTypeReasoningSummaryTextDelta ResponsesStreamResponseType = "response.reasoning_summary_text.delta" + ResponsesStreamResponseTypeReasoningSummaryTextDone ResponsesStreamResponseType = "response.reasoning_summary_text.done" + + ResponsesStreamResponseTypeImageGenerationCallCompleted ResponsesStreamResponseType = "response.image_generation_call.completed" + ResponsesStreamResponseTypeImageGenerationCallGenerating ResponsesStreamResponseType = "response.image_generation_call.generating" + ResponsesStreamResponseTypeImageGenerationCallInProgress ResponsesStreamResponseType = "response.image_generation_call.in_progress" + ResponsesStreamResponseTypeImageGenerationCallPartialImage ResponsesStreamResponseType = "response.image_generation_call.partial_image" + + ResponsesStreamResponseTypeMCPCallArgumentsDelta ResponsesStreamResponseType = "response.mcp_call_arguments.delta" + ResponsesStreamResponseTypeMCPCallArgumentsDone ResponsesStreamResponseType = "response.mcp_call_arguments.done" + ResponsesStreamResponseTypeMCPCallCompleted ResponsesStreamResponseType = "response.mcp_call.completed" + ResponsesStreamResponseTypeMCPCallFailed ResponsesStreamResponseType = "response.mcp_call.failed" + ResponsesStreamResponseTypeMCPCallInProgress ResponsesStreamResponseType = "response.mcp_call.in_progress" + ResponsesStreamResponseTypeMCPListToolsCompleted ResponsesStreamResponseType = "response.mcp_list_tools.completed" + ResponsesStreamResponseTypeMCPListToolsFailed ResponsesStreamResponseType = "response.mcp_list_tools.failed" + ResponsesStreamResponseTypeMCPListToolsInProgress ResponsesStreamResponseType = "response.mcp_list_tools.in_progress" + + ResponsesStreamResponseTypeCodeInterpreterCallInProgress ResponsesStreamResponseType = "response.code_interpreter_call.in_progress" + ResponsesStreamResponseTypeCodeInterpreterCallInterpreting ResponsesStreamResponseType = "response.code_interpreter_call.interpreting" + ResponsesStreamResponseTypeCodeInterpreterCallCompleted ResponsesStreamResponseType = "response.code_interpreter_call.completed" + ResponsesStreamResponseTypeCodeInterpreterCallCodeDelta ResponsesStreamResponseType = "response.code_interpreter_call_code.delta" + ResponsesStreamResponseTypeCodeInterpreterCallCodeDone ResponsesStreamResponseType = "response.code_interpreter_call_code.done" + + ResponsesStreamResponseTypeOutputTextAnnotationAdded ResponsesStreamResponseType = "response.output_text.annotation.added" + ResponsesStreamResponseTypeOutputTextAnnotationDone ResponsesStreamResponseType = "response.output_text.annotation.done" + + ResponsesStreamResponseTypeQueued ResponsesStreamResponseType = "response.queued" + + ResponsesStreamResponseTypeCustomToolCallInputDelta ResponsesStreamResponseType = "response.custom_tool_call_input.delta" + ResponsesStreamResponseTypeCustomToolCallInputDone ResponsesStreamResponseType = "response.custom_tool_call_input.done" + + ResponsesStreamResponseTypeError ResponsesStreamResponseType = "error" +) + +type BifrostResponsesStreamResponse struct { + Type ResponsesStreamResponseType `json:"type"` + SequenceNumber int `json:"sequence_number"` + + Response *BifrostResponsesResponse `json:"response,omitempty"` + + OutputIndex *int `json:"output_index,omitempty"` + Item *ResponsesMessage `json:"item,omitempty"` + + ContentIndex *int `json:"content_index,omitempty"` + ItemID *string `json:"item_id,omitempty"` + Part *ResponsesMessageContentBlock `json:"part,omitempty"` + + Delta *string `json:"delta,omitempty"` + LogProbs []ResponsesOutputMessageContentTextLogProb `json:"logprobs,omitempty"` + + Text *string `json:"text,omitempty"` // Full text of the output item, comes with event "response.output_text.done" + + Refusal *string `json:"refusal,omitempty"` + + Arguments *string `json:"arguments,omitempty"` + + PartialImageB64 *string `json:"partial_image_b64,omitempty"` + PartialImageIndex *int `json:"partial_image_index,omitempty"` + + Annotation *ResponsesOutputMessageContentTextAnnotation `json:"annotation,omitempty"` + AnnotationIndex *int `json:"annotation_index,omitempty"` + + Code *string `json:"code,omitempty"` + Message *string `json:"message,omitempty"` + Param *string `json:"param,omitempty"` + + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` + + // Perplexity-specific fields + SearchResults []SearchResult `json:"search_results,omitempty"` + Videos []VideoResult `json:"videos,omitempty"` + Citations []string `json:"citations,omitempty"` +} diff --git a/core/schemas/speech.go b/core/schemas/speech.go new file mode 100644 index 000000000..a4f09fca9 --- /dev/null +++ b/core/schemas/speech.go @@ -0,0 +1,123 @@ +package schemas + +import ( + "fmt" + + "github.com/bytedance/sonic" +) + +type BifrostSpeechRequest struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` + Input *SpeechInput `json:"input,omitempty"` + Params *SpeechParameters `json:"params,omitempty"` + Fallbacks []Fallback `json:"fallbacks,omitempty"` + RawRequestBody []byte `json:"-"` // set bifrost-use-raw-request-body to true in ctx to use the raw request body. Bifrost will directly send this to the downstream provider. +} + +func (r *BifrostSpeechRequest) GetRawRequestBody() []byte { + return r.RawRequestBody +} + +type BifrostSpeechResponse struct { + Audio []byte `json:"audio"` + Usage *SpeechUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +// SpeechInput represents the input for a speech request. +type SpeechInput struct { + Input string `json:"input"` +} + +type SpeechParameters struct { + VoiceConfig *SpeechVoiceInput `json:"voice"` + Instructions string `json:"instructions,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` // Default is "mp3" + Speed *float64 `json:"speed,omitempty"` + + // Dynamic parameters that can be provider-specific, they are directly + // added to the request as is. + ExtraParams map[string]interface{} `json:"-"` +} + +type SpeechVoiceInput struct { + Voice *string + MultiVoiceConfig []VoiceConfig +} + +type VoiceConfig struct { + Speaker string `json:"speaker"` + Voice string `json:"voice"` +} + +// MarshalJSON implements custom JSON marshalling for SpeechVoiceInput. +// It marshals either Voice or MultiVoiceConfig directly without wrapping. +func (vi *SpeechVoiceInput) MarshalJSON() ([]byte, error) { + // Validation: ensure only one field is set at a time + if vi.Voice != nil && len(vi.MultiVoiceConfig) > 0 { + return nil, fmt.Errorf("both Voice and MultiVoiceConfig are set; only one should be non-nil") + } + + if vi.Voice != nil { + return sonic.Marshal(*vi.Voice) + } + if len(vi.MultiVoiceConfig) > 0 { + return sonic.Marshal(vi.MultiVoiceConfig) + } + // If both are nil, return null + return sonic.Marshal(nil) +} + +// UnmarshalJSON implements custom JSON unmarshalling for SpeechVoiceInput. +// It determines whether "voice" is a string or a VoiceConfig object/array and assigns to the appropriate field. +// It also handles direct string/array content without a wrapper object. +func (vi *SpeechVoiceInput) UnmarshalJSON(data []byte) error { + // Reset receiver state before attempting any decode to avoid stale data + vi.Voice = nil + vi.MultiVoiceConfig = nil + + // First, try to unmarshal as a direct string + var stringContent string + if err := sonic.Unmarshal(data, &stringContent); err == nil { + vi.Voice = &stringContent + return nil + } + + // Try to unmarshal as an array of VoiceConfig objects + var voiceConfigs []VoiceConfig + if err := sonic.Unmarshal(data, &voiceConfigs); err == nil { + // Validate each VoiceConfig and build a new slice deterministically + validConfigs := make([]VoiceConfig, 0, len(voiceConfigs)) + for _, config := range voiceConfigs { + if config.Voice == "" { + return fmt.Errorf("voice config has empty voice field") + } + validConfigs = append(validConfigs, config) + } + vi.MultiVoiceConfig = validConfigs + return nil + } + + return fmt.Errorf("voice field is neither a string, nor an array of VoiceConfig objects") +} + +type SpeechStreamResponseType string + +const ( + SpeechStreamResponseTypeDelta SpeechStreamResponseType = "speech.audio.delta" + SpeechStreamResponseTypeDone SpeechStreamResponseType = "speech.audio.done" +) + +type BifrostSpeechStreamResponse struct { + Type SpeechStreamResponseType `json:"type"` + Audio []byte `json:"audio"` + Usage *SpeechUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +type SpeechUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/core/schemas/textcompletions.go b/core/schemas/textcompletions.go new file mode 100644 index 000000000..c65f0db2f --- /dev/null +++ b/core/schemas/textcompletions.go @@ -0,0 +1,148 @@ +package schemas + +import ( + "fmt" + + "github.com/bytedance/sonic" +) + +// BifrostTextCompletionRequest is the request struct for text completion requests +type BifrostTextCompletionRequest struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` + Input *TextCompletionInput `json:"input,omitempty"` + Params *TextCompletionParameters `json:"params,omitempty"` + Fallbacks []Fallback `json:"fallbacks,omitempty"` + RawRequestBody []byte `json:"-"` // set bifrost-use-raw-request-body to true in ctx to use the raw request body. Bifrost will directly send this to the downstream provider. +} + +func (r *BifrostTextCompletionRequest) GetRawRequestBody() []byte { + return r.RawRequestBody +} + +// ToBifrostChatRequest converts a Bifrost text completion request to a Bifrost chat completion request +// This method is discouraged to use, but is useful for litellm fallback flows +func (r *BifrostTextCompletionRequest) ToBifrostChatRequest() *BifrostChatRequest { + if r == nil || r.Input == nil { + return nil + } + message := ChatMessage{Role: ChatMessageRoleUser} + if r.Input.PromptStr != nil { + message.Content = &ChatMessageContent{ + ContentStr: r.Input.PromptStr, + } + } else if len(r.Input.PromptArray) > 0 { + blocks := make([]ChatContentBlock, 0, len(r.Input.PromptArray)) + for _, prompt := range r.Input.PromptArray { + blocks = append(blocks, ChatContentBlock{ + Type: ChatContentBlockTypeText, + Text: &prompt, + }) + } + message.Content = &ChatMessageContent{ + ContentBlocks: blocks, + } + } + params := ChatParameters{} + if r.Params != nil { + params.MaxCompletionTokens = r.Params.MaxTokens + params.Temperature = r.Params.Temperature + params.TopP = r.Params.TopP + params.Stop = r.Params.Stop + params.ExtraParams = r.Params.ExtraParams + params.StreamOptions = r.Params.StreamOptions + params.User = r.Params.User + params.FrequencyPenalty = r.Params.FrequencyPenalty + params.LogitBias = r.Params.LogitBias + params.PresencePenalty = r.Params.PresencePenalty + params.Seed = r.Params.Seed + } + return &BifrostChatRequest{ + Provider: r.Provider, + Model: r.Model, + Fallbacks: r.Fallbacks, + Input: []ChatMessage{message}, + Params: ¶ms, + } +} + +type BifrostTextCompletionResponse struct { + ID string `json:"id"` + Choices []BifrostResponseChoice `json:"choices"` + Model string `json:"model"` + Object string `json:"object"` // "text_completion" (same for text completion stream) + SystemFingerprint string `json:"system_fingerprint"` + Usage *BifrostLLMUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +type TextCompletionInput struct { + PromptStr *string + PromptArray []string +} + +func (t *TextCompletionInput) MarshalJSON() ([]byte, error) { + set := 0 + if t.PromptStr != nil { + set++ + } + if t.PromptArray != nil { + set++ + } + if set == 0 { + return nil, fmt.Errorf("text completion input is empty") + } + if set > 1 { + return nil, fmt.Errorf("text completion input must set exactly one of: prompt_str or prompt_array") + } + if t.PromptStr != nil { + return sonic.Marshal(*t.PromptStr) + } + return sonic.Marshal(t.PromptArray) +} + +func (t *TextCompletionInput) UnmarshalJSON(data []byte) error { + var prompt string + if err := sonic.Unmarshal(data, &prompt); err == nil { + t.PromptStr = &prompt + t.PromptArray = nil + return nil + } + var promptArray []string + if err := sonic.Unmarshal(data, &promptArray); err == nil { + t.PromptStr = nil + t.PromptArray = promptArray + return nil + } + return fmt.Errorf("invalid text completion input") +} + +type TextCompletionParameters struct { + BestOf *int `json:"best_of,omitempty"` + Echo *bool `json:"echo,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + LogitBias *map[string]float64 `json:"logit_bias,omitempty"` + LogProbs *int `json:"logprobs,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + N *int `json:"n,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + Seed *int `json:"seed,omitempty"` + Stop []string `json:"stop,omitempty"` + Suffix *string `json:"suffix,omitempty"` + StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + User *string `json:"user,omitempty"` + + // Dynamic parameters that can be provider-specific, they are directly + // added to the request as is. + ExtraParams map[string]interface{} `json:"-"` +} + +// TextCompletionLogProb represents log probability information for text completion. +type TextCompletionLogProb struct { + TextOffset []int `json:"text_offset"` + TokenLogProbs []float64 `json:"token_logprobs"` + Tokens []string `json:"tokens"` + TopLogProbs []map[string]float64 `json:"top_logprobs"` +} diff --git a/core/schemas/transcriptions.go b/core/schemas/transcriptions.go new file mode 100644 index 000000000..5f9a68cc3 --- /dev/null +++ b/core/schemas/transcriptions.go @@ -0,0 +1,101 @@ +package schemas + +type BifrostTranscriptionRequest struct { + Provider ModelProvider `json:"provider"` + Model string `json:"model"` + Input *TranscriptionInput `json:"input,omitempty"` + Params *TranscriptionParameters `json:"params,omitempty"` + Fallbacks []Fallback `json:"fallbacks,omitempty"` + RawRequestBody []byte `json:"-"` // set bifrost-use-raw-request-body to true in ctx to use the raw request body. Bifrost will directly send this to the downstream provider. +} + +func (r *BifrostTranscriptionRequest) GetRawRequestBody() []byte { + return r.RawRequestBody +} + +type BifrostTranscriptionResponse struct { + Duration *float64 `json:"duration,omitempty"` // Duration in seconds + Language *string `json:"language,omitempty"` // e.g., "english" + LogProbs []TranscriptionLogProb `json:"logprobs,omitempty"` + Segments []TranscriptionSegment `json:"segments,omitempty"` + Task *string `json:"task,omitempty"` // e.g., "transcribe" + Text string `json:"text"` + Usage *TranscriptionUsage `json:"usage,omitempty"` + Words []TranscriptionWord `json:"words,omitempty"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +type TranscriptionInput struct { + File []byte `json:"file"` +} + +type TranscriptionParameters struct { + Language *string `json:"language,omitempty"` + Prompt *string `json:"prompt,omitempty"` + ResponseFormat *string `json:"response_format,omitempty"` // Default is "json" + Format *string `json:"file_format,omitempty"` // Type of file, not required in openai, but required in gemini + + // Dynamic parameters that can be provider-specific, they are directly + // added to the request as is. + ExtraParams map[string]interface{} `json:"-"` +} + +// TranscriptionLogProb represents log probability information for transcription +type TranscriptionLogProb struct { + Token string `json:"token"` + LogProb float64 `json:"logprob"` + Bytes []int `json:"bytes"` +} + +// TranscriptionWord represents word-level timing information +type TranscriptionWord struct { + Word string `json:"word"` + Start float64 `json:"start"` + End float64 `json:"end"` +} + +// TranscriptionSegment represents segment-level transcription information +type TranscriptionSegment struct { + ID int `json:"id"` + Seek int `json:"seek"` + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` + Tokens []int `json:"tokens"` + Temperature float64 `json:"temperature"` + AvgLogProb float64 `json:"avg_logprob"` + CompressionRatio float64 `json:"compression_ratio"` + NoSpeechProb float64 `json:"no_speech_prob"` +} + +// TranscriptionUsage represents usage information for transcription +type TranscriptionUsage struct { + Type string `json:"type"` // "tokens" or "duration" + InputTokens *int `json:"input_tokens,omitempty"` + InputTokenDetails *TranscriptionUsageInputTokenDetails `json:"input_token_details,omitempty"` + OutputTokens *int `json:"output_tokens,omitempty"` + TotalTokens *int `json:"total_tokens,omitempty"` + Seconds *int `json:"seconds,omitempty"` // For duration-based usage +} + +type TranscriptionUsageInputTokenDetails struct { + TextTokens int `json:"text_tokens"` + AudioTokens int `json:"audio_tokens"` +} + +type TranscriptionStreamResponseType string + +const ( + TranscriptionStreamResponseTypeDelta TranscriptionStreamResponseType = "transcript.text.delta" + TranscriptionStreamResponseTypeDone TranscriptionStreamResponseType = "transcript.text.done" +) + +// BifrostTranscriptionStreamResponse represents streaming specific fields only +type BifrostTranscriptionStreamResponse struct { + Delta *string `json:"delta,omitempty"` // For delta events + LogProbs []TranscriptionLogProb `json:"logprobs,omitempty"` + Text string `json:"text"` + Type TranscriptionStreamResponseType `json:"type"` + Usage *TranscriptionUsage `json:"usage,omitempty"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} diff --git a/core/schemas/utils.go b/core/schemas/utils.go new file mode 100644 index 000000000..1e5db295b --- /dev/null +++ b/core/schemas/utils.go @@ -0,0 +1,491 @@ +package schemas + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +// Ptr creates a pointer to any value. +// This is a helper function for creating pointers to values. +func Ptr[T any](v T) *T { + return &v +} + +// ParseModelString extracts provider and model from a model string. +// For model strings like "anthropic/claude", it returns ("anthropic", "claude"). +// For model strings like "claude", it returns ("", "claude"). +func ParseModelString(model string, defaultProvider ModelProvider) (ModelProvider, string) { + // Check if model contains a provider prefix (only split on first "/" to preserve model names with "/") + if strings.Contains(model, "/") { + parts := strings.SplitN(model, "/", 2) + if len(parts) == 2 { + extractedProvider := parts[0] + extractedModel := parts[1] + + return ModelProvider(extractedProvider), extractedModel + } + } + // No provider prefix found, return empty provider and the original model + return defaultProvider, model +} + +//* IMAGE UTILS *// + +// dataURIRegex is a precompiled regex for matching data URI format patterns. +// It matches patterns like: data:image/png;base64,iVBORw0KGgo... +var dataURIRegex = regexp.MustCompile(`^data:([^;]+)(;base64)?,(.+)$`) + +// base64Regex is a precompiled regex for matching base64 strings. +// It matches strings containing only valid base64 characters with optional padding. +var base64Regex = regexp.MustCompile(`^[A-Za-z0-9+/]*={0,2}$`) + +// fileExtensionToMediaType maps common image file extensions to their corresponding media types. +// This map is used to infer media types from file extensions in URLs. +var fileExtensionToMediaType = map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", +} + +// ImageContentType represents the type of image content +type ImageContentType string + +const ( + ImageContentTypeBase64 ImageContentType = "base64" + ImageContentTypeURL ImageContentType = "url" +) + +// URLTypeInfo contains extracted information about a URL +type URLTypeInfo struct { + Type ImageContentType + MediaType *string + DataURLWithoutPrefix *string // URL without the prefix (eg data:image/png;base64,iVBORw0KGgo...) +} + +// SanitizeImageURL sanitizes and validates an image URL. +// It handles both data URLs and regular HTTP/HTTPS URLs. +// It also detects raw base64 image data and adds proper data URL headers. +func SanitizeImageURL(rawURL string) (string, error) { + if rawURL == "" { + return rawURL, fmt.Errorf("URL cannot be empty") + } + + // Trim whitespace + rawURL = strings.TrimSpace(rawURL) + + // Check if it's already a proper data URL + if strings.HasPrefix(rawURL, "data:") { + // Validate data URL format + if !dataURIRegex.MatchString(rawURL) { + return rawURL, fmt.Errorf("invalid data URL format") + } + return rawURL, nil + } + + // Check if it looks like raw base64 image data + if isLikelyBase64(rawURL) { + // Detect the image type from the base64 data + mediaType := detectImageTypeFromBase64(rawURL) + + // Remove any whitespace/newlines from base64 data + cleanBase64 := strings.ReplaceAll(strings.ReplaceAll(rawURL, "\n", ""), " ", "") + + // Create proper data URL + return fmt.Sprintf("data:%s;base64,%s", mediaType, cleanBase64), nil + } + + // Parse as regular URL + parsedURL, err := url.Parse(rawURL) + if err != nil { + return rawURL, fmt.Errorf("invalid URL format: %w", err) + } + + // Validate scheme + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return rawURL, fmt.Errorf("URL must use http or https scheme") + } + + // Validate host + if parsedURL.Host == "" { + return rawURL, fmt.Errorf("URL must have a valid host") + } + + return parsedURL.String(), nil +} + +// ExtractURLTypeInfo extracts type and media type information from a sanitized URL. +// For data URLs, it parses the media type and encoding. +// For regular URLs, it attempts to infer the media type from the file extension. +func ExtractURLTypeInfo(sanitizedURL string) URLTypeInfo { + if strings.HasPrefix(sanitizedURL, "data:") { + return extractDataURLInfo(sanitizedURL) + } + return extractRegularURLInfo(sanitizedURL) +} + +// extractDataURLInfo extracts information from a data URL +func extractDataURLInfo(dataURL string) URLTypeInfo { + // Parse data URL: data:[][;base64], + matches := dataURIRegex.FindStringSubmatch(dataURL) + + if len(matches) != 4 { + return URLTypeInfo{Type: ImageContentTypeBase64} + } + + mediaType := matches[1] + isBase64 := matches[2] == ";base64" + + dataURLWithoutPrefix := dataURL + if isBase64 { + dataURLWithoutPrefix = dataURL[len("data:")+len(mediaType)+len(";base64,"):] + } + + info := URLTypeInfo{ + MediaType: &mediaType, + DataURLWithoutPrefix: &dataURLWithoutPrefix, + } + + if isBase64 { + info.Type = ImageContentTypeBase64 + } else { + info.Type = ImageContentTypeURL // Non-base64 data URL + } + + return info +} + +// extractRegularURLInfo extracts information from a regular HTTP/HTTPS URL +func extractRegularURLInfo(regularURL string) URLTypeInfo { + info := URLTypeInfo{ + Type: ImageContentTypeURL, + } + + // Try to infer media type from file extension + parsedURL, err := url.Parse(regularURL) + if err != nil { + return info + } + + path := strings.ToLower(parsedURL.Path) + + // Check for known file extensions using the map + for ext, mediaType := range fileExtensionToMediaType { + if strings.HasSuffix(path, ext) { + info.MediaType = &mediaType + break + } + } + // For URLs without recognizable extensions, MediaType remains nil + + return info +} + +// detectImageTypeFromBase64 detects the image type from base64 data by examining the header bytes +func detectImageTypeFromBase64(base64Data string) string { + // Remove any whitespace or newlines + cleanData := strings.ReplaceAll(strings.ReplaceAll(base64Data, "\n", ""), " ", "") + + // Check common image format signatures in base64 + switch { + case strings.HasPrefix(cleanData, "/9j/") || strings.HasPrefix(cleanData, "/9k/"): + // JPEG images typically start with /9j/ or /9k/ in base64 (FFD8 in hex) + return "image/jpeg" + case strings.HasPrefix(cleanData, "iVBORw0KGgo"): + // PNG images start with iVBORw0KGgo in base64 (89504E470D0A1A0A in hex) + return "image/png" + case strings.HasPrefix(cleanData, "R0lGOD"): + // GIF images start with R0lGOD in base64 (474946 in hex) + return "image/gif" + case strings.HasPrefix(cleanData, "Qk"): + // BMP images start with Qk in base64 (424D in hex) + return "image/bmp" + case strings.HasPrefix(cleanData, "UklGR") && len(cleanData) >= 16 && cleanData[12:16] == "V0VC": + // WebP images start with RIFF header (UklGR in base64) and have WEBP signature at offset 8-11 (V0VC in base64) + return "image/webp" + case strings.HasPrefix(cleanData, "PHN2Zy") || strings.HasPrefix(cleanData, "PD94bW"): + // SVG images often start with 0 { - toolCall := *result.Choices[0].Message.ToolCalls - fmt.Printf("\nπŸ’ %s Tool Call Result %d: %s\n", config.Provider, index+1, toolCall[0].Function.Arguments) - } else { - fmt.Printf("\nπŸ’ %s No tool calls in response %d\n", config.Provider, index+1) - if result.ExtraFields.RawResponse != nil { - fmt.Println("\nRaw JSON Response", result.ExtraFields.RawResponse) - } - } - } - }(message, delay, i) - } -} - -// SetupAllRequests sets up and executes all configured test requests for a provider. -// It coordinates the execution of text completion, chat completion, image, and tool call tests -// based on the provided configuration. -// -// Parameters: -// - bifrost: The Bifrost instance to use for the requests -// - config: Test configuration specifying which tests to run -func SetupAllRequests(bifrost *bifrost.Bifrost, config TestConfig) { - ctx := context.Background() - - if config.SetupText { - setupTextCompletionRequest(bifrost, config, ctx) - } - - setupChatCompletionRequests(bifrost, config, ctx) - - if config.SetupImage { - setupImageTests(bifrost, config, ctx) - } - - if config.SetupToolCalls { - setupToolCalls(bifrost, config, ctx) - } -} diff --git a/core/utils.go b/core/utils.go new file mode 100644 index 000000000..3f04662f3 --- /dev/null +++ b/core/utils.go @@ -0,0 +1,269 @@ +package bifrost + +import ( + "bytes" + "context" + "encoding/json" + "math/rand" + "strings" + "time" + + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// Define a set of retryable status codes +var retryableStatusCodes = map[int]bool{ + 500: true, // Internal Server Error + 502: true, // Bad Gateway + 503: true, // Service Unavailable + 504: true, // Gateway Timeout + 429: true, // Too Many Requests +} + +// Define rate limit error message patterns (case-insensitive) +var rateLimitPatterns = []string{ + "rate limit", + "rate_limit", + "ratelimit", + "too many requests", + "quota exceeded", + "quota_exceeded", + "request limit", + "throttled", + "throttling", + "rate exceeded", + "limit exceeded", + "requests per", + "rpm exceeded", + "tpm exceeded", + "tokens per minute", + "requests per minute", + "requests per second", + "api rate limit", + "usage limit", + "concurrent requests limit", +} + +// Ptr returns a pointer to the given value. +func Ptr[T any](v T) *T { + return &v +} + +// providerRequiresKey returns true if the given provider requires an API key for authentication. +// Some providers like Ollama and SGL are keyless and don't require API keys. +func providerRequiresKey(providerKey schemas.ModelProvider, customConfig *schemas.CustomProviderConfig) bool { + // Keyless custom providers are not allowed for Bedrock. + if customConfig != nil && customConfig.IsKeyLess && customConfig.BaseProviderType != schemas.Bedrock { + return false + } + return providerKey != schemas.Ollama && providerKey != schemas.SGL +} + +// canProviderKeyValueBeEmpty returns true if the given provider allows the API key to be empty. +// Some providers like Vertex and Bedrock have their credentials in additional key configs.. +func canProviderKeyValueBeEmpty(providerKey schemas.ModelProvider) bool { + return providerKey == schemas.Vertex || providerKey == schemas.Bedrock +} + +func isKeySkippingAllowed(providerKey schemas.ModelProvider) bool { + return providerKey != schemas.Azure && providerKey != schemas.Bedrock && providerKey != schemas.Vertex +} + +// calculateBackoff implements exponential backoff with jitter for retry attempts. +func calculateBackoff(attempt int, config *schemas.ProviderConfig) time.Duration { + // Calculate an exponential backoff: initial * 2^attempt + backoff := min(config.NetworkConfig.RetryBackoffInitial*time.Duration(1<text" + } + } + }, + "ModelPricing": { + "type": "object", + "description": "All pricing values are in USD per token/request/unit. A value of '0' indicates the feature is free.", + "properties": { + "prompt": { + "type": "string", + "description": "Cost per input token in USD", + "example": "0.0000025" + }, + "completion": { + "type": "string", + "description": "Cost per output token in USD", + "example": "0.00001" + }, + "request": { + "type": "string", + "description": "Fixed cost per API request in USD", + "example": "0" + }, + "image": { + "type": "string", + "description": "Cost per image input in USD", + "example": "0.003613" + }, + "web_search": { + "type": "string", + "description": "Cost per web search operation in USD", + "example": "0" + }, + "internal_reasoning": { + "type": "string", + "description": "Cost for internal reasoning tokens in USD", + "example": "0" + }, + "input_cache_read": { + "type": "string", + "description": "Cost per cached input token read in USD", + "example": "0.00000125" + }, + "input_cache_write": { + "type": "string", + "description": "Cost per cached input token write in USD", + "example": "0" + } + } + }, + "ModelTopProvider": { + "type": "object", + "description": "Configuration details for the primary provider", + "properties": { + "context_length": { + "type": "integer", + "description": "Provider-specific context limit in tokens" + }, + "max_completion_tokens": { + "type": "integer", + "description": "Maximum completion tokens" + }, + "is_moderated": { + "type": "boolean", + "description": "Whether content moderation is applied to the model output" + } + } + }, + "ModelPerRequestLimits": { + "type": "object", + "properties": { + "prompt_tokens": { + "type": "integer", + "description": "Maximum prompt tokens per request" + }, + "completion_tokens": { + "type": "integer", + "description": "Maximum completion tokens per request" + } + } + }, + "ModelDefaultParameters": { + "type": "object" + } + }, + "responses": { + "BadRequest": { + "description": "Bad Request - Invalid request format or missing required fields", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + }, + "example": { + "is_bifrost_error": true, + "status_code": 400, + "error": { + "type": "invalid_request_error", + "message": "Invalid request format" + } + } + } + } + }, + "Unauthorized": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + }, + "example": { + "is_bifrost_error": true, + "status_code": 401, + "error": { + "type": "authentication_error", + "message": "Invalid API key provided" + } + } + } + } + }, + "RateLimited": { + "description": "Rate Limited - Too many requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + }, + "example": { + "is_bifrost_error": true, + "status_code": 429, + "error": { + "type": "rate_limit_error", + "message": "Rate limit exceeded" + } + } + } + } + }, + "InternalServerError": { + "description": "Internal Server Error - An unexpected error occurred", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + }, + "example": { + "is_bifrost_error": true, + "status_code": 500, + "error": { + "type": "api_error", + "message": "Internal server error occurred" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/architecture/README.mdx b/docs/architecture/README.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/core/concurrency.mdx b/docs/architecture/core/concurrency.mdx new file mode 100644 index 000000000..83c9aa806 --- /dev/null +++ b/docs/architecture/core/concurrency.mdx @@ -0,0 +1,764 @@ +--- +title: "Concurrency" +description: "Deep dive into Bifrost's advanced concurrency architecture - worker pools, goroutine management, channel-based communication, and resource isolation patterns." +icon: "traffic-light" +--- + +## Concurrency Philosophy + +### **Core Principles** + +| Principle | Implementation | Benefit | +| ---------------------------------- | -------------------------------------- | -------------------------------------- | +| **Provider Isolation** | Independent worker pools per provider | Fault tolerance, no cascade failures | +| **Channel-Based Communication** | Go channels for all async operations | Type-safe, deadlock-free communication | +| **Resource Pooling** | Object pools with lifecycle management | Predictable memory usage, minimal GC | +| **Non-Blocking Operations** | Async processing throughout pipeline | Maximum concurrency, no blocking waits | +| **Backpressure Handling** | Configurable buffers and flow control | Graceful degradation under load | + +### **Threading Architecture Overview** + +```mermaid +graph TB + subgraph "Main Thread" + Main[Main Process
HTTP Server] + Router[Request Router
Goroutine] + PluginMgr[Plugin Manager
Goroutine] + end + + subgraph "Provider Worker Pools" + subgraph "OpenAI Pool" + OAI1[Worker 1
Goroutine] + OAI2[Worker 2
Goroutine] + OAIN[Worker N
Goroutine] + end + subgraph "Anthropic Pool" + ANT1[Worker 1
Goroutine] + ANT2[Worker 2
Goroutine] + ANTN[Worker N
Goroutine] + end + subgraph "Bedrock Pool" + BED1[Worker 1
Goroutine] + BED2[Worker 2
Goroutine] + BEDN[Worker N
Goroutine] + end + end + + subgraph "Memory Pools" + ChannelPool[Channel Pool
sync.Pool] + MessagePool[Message Pool
sync.Pool] + ResponsePool[Response Pool
sync.Pool] + end + + Main --> Router + Router --> PluginMgr + PluginMgr --> OAI1 + PluginMgr --> ANT1 + PluginMgr --> BED1 + + OAI1 --> ChannelPool + ANT1 --> MessagePool + BED1 --> ResponsePool +``` + +--- + +## Worker Pool Architecture + +### **Provider-Isolated Worker Pools** + +```mermaid +stateDiagram-v2 + [*] --> PoolInit: Worker Pool Creation + PoolInit --> WorkerSpawn: Spawn Worker Goroutines + WorkerSpawn --> Listening: Workers Listen on Channels + + Listening --> Processing: Job Received + Processing --> API_Call: Provider API Request + API_Call --> Response: Process Response + Response --> Listening: Job Complete + + Listening --> Shutdown: Graceful Shutdown + Processing --> Shutdown: Complete Current Job + Shutdown --> [*]: Pool Destroyed +``` + +**Worker Pool Architecture:** + +The worker pool system maintains a sophisticated balance between resource efficiency and performance isolation: + +**Key Components:** + +- **Worker Pool Management** - Pre-spawned workers reduce startup latency +- **Job Queue System** - Buffered channels provide smooth load balancing +- **Resource Pools** - HTTP clients and API keys are pooled for efficiency +- **Health Monitoring** - Circuit breakers detect and isolate failing providers +- **Graceful Shutdown** - Workers complete current jobs before terminating + +**Startup Process:** + +1. **Worker Pre-spawning** - Workers are created during pool initialization +2. **Channel Setup** - Job queues and worker channels are established +3. **Resource Allocation** - HTTP clients and API keys are distributed +4. **Health Checks** - Initial connectivity tests verify provider availability +5. **Ready State** - Pool becomes available for request processing + +**Job Dispatch Logic:** + +- **Round-Robin Assignment** - Jobs are distributed evenly across available workers +- **Load Balancing** - Worker availability determines job assignment +- **Overflow Handling** - Excess jobs are queued or dropped based on configuration + +### **Worker Lifecycle Management** + +```mermaid +sequenceDiagram + participant Pool + participant Worker + participant HTTPClient + participant Provider + participant Metrics + + Pool->>Worker: Start() + Worker->>Worker: Initialize HTTP Client + Worker->>Pool: Ready Signal + + loop Job Processing + Pool->>Worker: Job Assignment + Worker->>HTTPClient: Prepare Request + HTTPClient->>Provider: API Call + Provider-->>HTTPClient: Response + HTTPClient-->>Worker: Parsed Response + Worker->>Metrics: Record Performance + Worker->>Pool: Job Complete + end + + Pool->>Worker: Shutdown Signal + Worker->>Worker: Complete Current Job + Worker-->>Pool: Shutdown Confirmed +```` + +--- + +## Channel-Based Communication + +### **Channel Architecture** + +```mermaid +graph TB + subgraph "Channel Types" + JobQueue[Job Queue
Buffered Channel] + WorkerPool[Worker Pool
Buffered Channel] + ResultChan[Result Channel
Buffered Channel] + QuitChan[Quit Channel
Unbuffered] + end + + subgraph "Flow Control" + BackPressure[Backpressure
Buffer Limits] + Timeout[Timeout
Context Cancellation] + Graceful[Graceful Shutdown
Channel Closing] + end + + JobQueue --> BackPressure + WorkerPool --> Timeout + ResultChan --> Graceful +``` + +**Channel Configuration Principles:** + +Bifrost's channel system balances throughput and memory usage through careful buffer sizing: + +**Job Queuing Configuration:** + +- **Job Queue Buffer** - Sized based on expected burst traffic (100-1000 jobs) +- **Worker Pool Size** - Matches provider concurrency limits (10-100 workers) +- **Result Buffer** - Accommodates response processing delays (50-500 responses) + +**Flow Control Parameters:** + +- **Queue Wait Limits** - Maximum time jobs wait before timeout (1-10 seconds) +- **Processing Timeouts** - Per-job execution limits (30-300 seconds) +- **Shutdown Timeouts** - Graceful termination periods (5-30 seconds) + +**Backpressure Policies:** + +- **Drop Policy** - Discard excess jobs when queues are full +- **Block Policy** - Wait for queue space with timeout +- **Error Policy** - Immediately return error for full queues + +**Channel Type Selection:** + +- **Buffered Channels** - Used for async job processing and result handling +- **Unbuffered Channels** - Used for synchronization signals (quit, done) +- **Context Cancellation** - Used for timeout and cancellation propagation + +### **Backpressure and Flow Control** + +```mermaid +flowchart TD + Request[Incoming Request] --> QueueCheck{Queue Full?} + QueueCheck -->|No| Queue[Add to Queue] + QueueCheck -->|Yes| Policy{Drop Policy?} + + Policy -->|Drop| Drop[Drop Request
Return Error] + Policy -->|Block| Block[Block Until Space
With Timeout] + Policy -->|Error| Error[Return Queue Full Error] + + Queue --> Worker[Assign to Worker] + Block --> TimeoutCheck{Timeout?} + TimeoutCheck -->|Yes| Error + TimeoutCheck -->|No| Queue + + Worker --> Processing[Process Request] + Processing --> Complete[Complete] + + Drop --> Client[Client Response] + Error --> Client + Complete --> Client +```` + +**Backpressure Implementation Strategy:** + +The backpressure system protects Bifrost from being overwhelmed while maintaining service availability: + +**Non-Blocking Job Submission:** + +- **Immediate Queue Check** - Jobs are submitted without blocking on queue space +- **Success Path** - Available queue space allows immediate job acceptance +- **Overflow Detection** - Full queues trigger backpressure policies +- **Metrics Collection** - All queue operations are tracked for monitoring + +**Backpressure Policy Execution:** + +- **Drop Policy** - Immediately rejects excess jobs with meaningful error messages +- **Block Policy** - Waits for queue space with configurable timeout limits +- **Error Policy** - Returns queue full errors for immediate client feedback +- **Metrics Tracking** - Dropped, blocked, and successful submissions are measured + +**Timeout Management:** + +- **Context-Based Timeouts** - All blocking operations respect timeout boundaries +- **Graceful Degradation** - Timeouts result in controlled error responses +- **Resource Protection** - Prevents goroutine leaks from infinite waits + +```go + case pool.jobQueue <- job: + pool.metrics.IncQueuedJobs() + return nil + case <-ctx.Done(): + pool.metrics.IncTimeoutJobs() + return errors.New("queue full, timeout waiting") + } + + case "error": + pool.metrics.IncRejectedJobs() + return errors.New("queue full, job rejected") + + default: + return errors.New("unknown queue policy") + } + } + } +``` + +--- + +## Memory Pool Concurrency + +### **Thread-Safe Object Pools** + +```mermaid +graph TD + subgraph "sync.Pool Lifecycle" + direction LR + GetObject[Get Object
sync.Pool.Get] + PoolCheck{Is Pool Empty?} + NewObject[New Object
Factory Function] + UseObject[Use Object
Application Logic] + ResetObject[Reset Object
Clear State] + ReturnObject[Return Object
sync.Pool.Put] + + GetObject --> PoolCheck + PoolCheck -- Yes --> NewObject + PoolCheck -- No --> UseObject + NewObject --> UseObject + UseObject --> ResetObject + ResetObject --> ReturnObject + ReturnObject --> GetObject + end + + subgraph "GC Interaction" + direction TB + GCRun[GC Runs] + PoolCleanup[Pool Cleanup
Removes idle objects] + + GCRun --> PoolCleanup + end +``` + +**Thread-Safe Pool Architecture:** + +Bifrost's memory pool system ensures thread-safe object reuse across multiple goroutines: + +**Pool Structure Design:** + +- **Multiple Pool Types** - Separate pools for channels, messages, responses, and buffers +- **Factory Functions** - Dynamic object creation when pools are empty +- **Statistics Tracking** - Comprehensive metrics for pool performance monitoring +- **Thread Safety** - Synchronized access using Go's sync.Pool and read-write mutexes + +**Object Lifecycle Management:** + +- **Pool Initialization** - Factory functions define object creation patterns +- **Unique Identification** - Each pooled object gets a unique ID for tracking +- **Timestamp Tracking** - Creation, acquisition, and return times are recorded +- **Reusability Flags** - Objects can be marked as non-reusable for single-use scenarios + +**Acquisition Strategy:** + +- **Request Tracking** - All pool requests are counted for monitoring +- **Hit/Miss Tracking** - Pool effectiveness is measured through hit ratios +- **Fallback Creation** - New objects are created when pools are empty +- **Performance Metrics** - Acquisition times and patterns are monitored + +**Return and Reset Process:** + +- **State Validation** - Only reusable objects are returned to pools +- **Object Reset** - All object state is cleared before returning to pool +- **Return Tracking** - Return operations are counted and timed +- **Pool Replenishment** - Returned objects become available for reuse + +### **Pool Performance Monitoring** + +Comprehensive metrics provide insights into pool efficiency and system health: + +**Usage Statistics Collection:** +- **Request Counting** - Track total pool requests by object type +- **Creation Tracking** - Monitor new object allocations when pools are empty +- **Hit/Miss Ratios** - Measure pool effectiveness through reuse rates +- **Return Monitoring** - Track successful object returns to pools + +**Performance Metrics Analysis:** +- **Acquisition Times** - Measure how long it takes to get objects from pools +- **Reset Performance** - Track time spent cleaning objects for reuse +- **Hit Ratio Calculation** - Determine percentage of requests served from pools +- **Memory Efficiency** - Calculate memory savings from object reuse + +**Key Performance Indicators:** +- **Channel Pool Hit Ratio** - Typically 85-95% in steady state +- **Message Pool Efficiency** - Usually 80-90% reuse rate +- **Response Pool Utilization** - Often 70-85% hit ratio +- **Total Memory Savings** - Measured reduction in garbage collection pressure + +**Monitoring Integration:** +- **Thread-Safe Access** - All metrics collection is synchronized +- **Real-Time Updates** - Statistics are updated with each pool operation +- **Export Capability** - Metrics are available in JSON format for monitoring systems +- **Alerting Support** - Low hit ratios can trigger performance alerts + +--- + +## Goroutine Management + +### **Goroutine Lifecycle Patterns** + +```mermaid +stateDiagram-v2 + [*] --> Created: go routine() + Created --> Running: Execute Function + Running --> Waiting: Channel/Mutex Block + Waiting --> Running: Unblocked + Running --> Syscall: Network I/O + Syscall --> Running: I/O Complete + Running --> GCAssist: GC Triggered + GCAssist --> Running: GC Complete + Running --> Terminated: Function Exit + Terminated --> [*]: Cleanup +``` + +**Goroutine Pool Management Strategy:** + +Bifrost's goroutine management ensures optimal resource usage while preventing goroutine leaks: + +**Pool Configuration Management:** + +- **Goroutine Limits** - Maximum concurrent goroutines prevent resource exhaustion +- **Active Counting** - Atomic counters track currently running goroutines +- **Idle Timeouts** - Unused goroutines are cleaned up after configured periods +- **Resource Boundaries** - Hard limits prevent runaway goroutine creation + +**Lifecycle Orchestration:** + +- **Spawn Channels** - New goroutine creation is tracked through channels +- **Completion Monitoring** - Finished goroutines signal completion for cleanup +- **Shutdown Coordination** - Graceful shutdown ensures all goroutines complete properly +- **Health Monitoring** - Continuous monitoring tracks goroutine health and performance + +**Worker Creation Process:** + +- **Limit Enforcement** - Creation fails when maximum goroutine count is reached +- **Unique Identification** - Each goroutine gets a unique ID for tracking and debugging +- **Lifecycle Tracking** - Start times and names enable performance analysis +- **Atomic Operations** - Thread-safe counters prevent race conditions + +**Panic Recovery and Error Handling:** + +- **Panic Isolation** - Goroutine panics don't crash the entire system +- **Error Logging** - Panic details are logged with goroutine context +- **Metrics Updates** - Panic counts are tracked for monitoring and alerting +- **Resource Cleanup** - Failed goroutines are properly cleaned up and counted + +**Health Monitoring System:** + +- **Periodic Health Checks** - Regular intervals check goroutine pool health +- **Completion Tracking** - Finished goroutines are recorded for performance analysis +- **Shutdown Handling** - Clean shutdown process ensures no goroutine leaks + +### **Resource Leak Prevention** + +```mermaid +flowchart TD + GoroutineStart[Goroutine Start] --> ResourceCheck[Resource Allocation Check] + ResourceCheck --> Timeout[Set Timeout Context] + Timeout --> Work[Execute Work] + + Work --> Complete{Work Complete?} + Complete -->|Yes| Cleanup[Cleanup Resources] + Complete -->|No| TimeoutCheck{Timeout?} + + TimeoutCheck -->|Yes| ForceCleanup[Force Cleanup] + TimeoutCheck -->|No| Work + + Cleanup --> Return[Return Resources to Pool] + ForceCleanup --> Return + Return --> End[Goroutine End] +```` + +**Resource Leak Prevention:** + +```go +func (worker *Worker) ExecuteWithCleanup(job *Job) { + // Set timeout context + ctx, cancel := context.WithTimeout( + context.Background(), + worker.config.ProcessTimeout, + ) + defer cancel() + + // Acquire resources with timeout + resources, err := worker.acquireResources(ctx) + if err != nil { + job.resultChan <- &Result{Error: err} + return + } + + // Ensure cleanup happens + defer func() { + // Always return resources + worker.returnResources(resources) + + // Handle panics + if r := recover(); r != nil { + worker.metrics.IncPanics() + job.resultChan <- &Result{ + Error: fmt.Errorf("worker panic: %v", r), + } + } + }() + + // Execute job with context + result := worker.processJob(ctx, job, resources) + + // Return result + select { + case job.resultChan <- result: + // Success + case <-ctx.Done(): + // Timeout - result channel might be closed + worker.metrics.IncTimeouts() + } +} +``` + +--- + +## Concurrency Optimization Strategies + +### **Load-Based Worker Scaling** (Planned) + +```mermaid +graph TB + subgraph "Load Monitoring" + QueueDepth[Queue Depth
Monitoring] + ResponseTime[Response Time
Tracking] + WorkerUtil[Worker Utilization
Metrics] + end + + subgraph "Scaling Decisions" + ScaleUp{Scale Up?
Load > 80%} + ScaleDown{Scale Down?
Load < 30%} + Maintain[Maintain
Current Size] + end + + subgraph "Actions" + AddWorkers[Spawn Additional
Workers] + RemoveWorkers[Graceful Worker
Shutdown] + NoAction[No Action
Monitor Continue] + end + + QueueDepth --> ScaleUp + ResponseTime --> ScaleUp + WorkerUtil --> ScaleDown + + ScaleUp -->|Yes| AddWorkers + ScaleUp -->|No| ScaleDown + ScaleDown -->|Yes| RemoveWorkers + ScaleDown -->|No| Maintain + + Maintain --> NoAction +``` + +**Adaptive Scaling Implementation:** + +```go +type AdaptiveScaler struct { + pool *ProviderWorkerPool + config ScalingConfig + metrics *ScalingMetrics + lastScaleTime time.Time + scalingMutex sync.Mutex +} + +func (scaler *AdaptiveScaler) EvaluateScaling() { + scaler.scalingMutex.Lock() + defer scaler.scalingMutex.Unlock() + + // Prevent frequent scaling + if time.Since(scaler.lastScaleTime) < scaler.config.MinScaleInterval { + return + } + + current := scaler.getCurrentMetrics() + + // Scale up conditions + if current.QueueUtilization > scaler.config.ScaleUpThreshold || + current.AvgResponseTime > scaler.config.MaxResponseTime { + + scaler.scaleUp(current) + return + } + + // Scale down conditions + if current.QueueUtilization < scaler.config.ScaleDownThreshold && + current.AvgResponseTime < scaler.config.TargetResponseTime { + + scaler.scaleDown(current) + return + } +} + +func (scaler *AdaptiveScaler) scaleUp(metrics *CurrentMetrics) { + currentWorkers := scaler.pool.GetWorkerCount() + targetWorkers := int(float64(currentWorkers) * scaler.config.ScaleUpFactor) + + // Respect maximum limits + if targetWorkers > scaler.config.MaxWorkers { + targetWorkers = scaler.config.MaxWorkers + } + + additionalWorkers := targetWorkers - currentWorkers + if additionalWorkers > 0 { + scaler.pool.AddWorkers(additionalWorkers) + scaler.lastScaleTime = time.Now() + scaler.metrics.RecordScaleUp(additionalWorkers) + } +} +``` + +### **Provider-Specific Optimization** + +```go +type ProviderOptimization struct { + // Provider characteristics + ProviderName string `json:"provider_name"` + RateLimit int `json:"rate_limit"` // Requests per second + AvgLatency time.Duration `json:"avg_latency"` // Average response time + ErrorRate float64 `json:"error_rate"` // Historical error rate + + // Optimal configuration + OptimalWorkers int `json:"optimal_workers"` + OptimalBuffer int `json:"optimal_buffer"` + TimeoutConfig time.Duration `json:"timeout_config"` + RetryStrategy RetryConfig `json:"retry_strategy"` +} + +func CalculateOptimalConcurrency(provider ProviderOptimization) ConcurrencyConfig { + // Calculate based on rate limits and latency + optimalWorkers := provider.RateLimit * int(provider.AvgLatency.Seconds()) + + // Adjust for error rate (more workers for higher error rate) + errorAdjustment := 1.0 + provider.ErrorRate + optimalWorkers = int(float64(optimalWorkers) * errorAdjustment) + + // Buffer should be 2-3x worker count for smooth operation + optimalBuffer := optimalWorkers * 3 + + return ConcurrencyConfig{ + Concurrency: optimalWorkers, + BufferSize: optimalBuffer, + Timeout: provider.AvgLatency * 2, // 2x avg latency for timeout + } +} +``` + +--- + +## Concurrency Monitoring & Metrics + +### **Key Concurrency Metrics** + +```mermaid +graph TB + subgraph "Worker Metrics" + ActiveWorkers[Active Workers
Current Count] + IdleWorkers[Idle Workers
Available Count] + BusyWorkers[Busy Workers
Processing Count] + end + + subgraph "Queue Metrics" + QueueDepth[Queue Depth
Pending Jobs] + QueueThroughput[Queue Throughput
Jobs/Second] + QueueWaitTime[Queue Wait Time
Average Delay] + end + + subgraph "Performance Metrics" + GoroutineCount[Goroutine Count
Total Active] + MemoryUsage[Memory Usage
Pool Utilization] + GCPressure[GC Pressure
Collection Frequency] + end + + subgraph "Health Metrics" + ErrorRate[Error Rate
Failed Jobs %] + PanicCount[Panic Count
Crashed Goroutines] + DeadlockDetection[Deadlock Detection
Blocked Operations] + end +``` + +**Metrics Collection Strategy:** + +Comprehensive concurrency monitoring provides operational insights and performance optimization data: + +**Worker Pool Monitoring:** + +- **Total Worker Tracking** - Monitor configured vs actual worker counts +- **Active Worker Monitoring** - Track workers currently processing requests +- **Idle Worker Analysis** - Identify unused capacity and optimization opportunities +- **Queue Depth Monitoring** - Track pending job backlog and processing delays + +**Performance Data Collection:** + +- **Throughput Metrics** - Measure jobs processed per second across all pools +- **Wait Time Analysis** - Track how long jobs wait in queues before processing +- **Memory Pool Performance** - Monitor hit/miss ratios for memory pool effectiveness +- **Goroutine Count Tracking** - Ensure goroutine counts remain within healthy limits + +**Health and Reliability Metrics:** + +- **Panic Recovery Tracking** - Count and analyze worker panic occurrences +- **Timeout Monitoring** - Track jobs that exceed processing time limits +- **Circuit Breaker Events** - Monitor provider isolation events and recoveries +- **Error Rate Analysis** - Track failure patterns for capacity planning + +**Real-Time Updates:** + +- **Live Metric Updates** - Worker metrics are updated continuously during operation +- **Processing Event Recording** - Each job completion updates relevant metrics +- **Performance Correlation** - Queue times and processing times are correlated for analysis +- **Success/Failure Tracking** - All job outcomes are recorded for reliability analysis + +--- + +## Deadlock Prevention & Detection + +### **Deadlock Prevention Strategies** + +```mermaid +flowchart TD + Strategy1[Lock Ordering
Consistent Acquisition] + Strategy2[Timeout-Based Locks
Context Cancellation] + Strategy3[Channel Select
Non-blocking Operations] + Strategy4[Resource Hierarchy
Layered Locking] + + Prevention[Deadlock Prevention
Design Patterns] + + Prevention --> Strategy1 + Prevention --> Strategy2 + Prevention --> Strategy3 + Prevention --> Strategy4 + + Strategy1 --> Success[No Deadlocks
Guaranteed Order] + Strategy2 --> Success + Strategy3 --> Success + Strategy4 --> Success +```` + +**Deadlock Prevention Implementation Strategy:** + +Bifrost employs multiple complementary strategies to prevent deadlocks in concurrent operations: + +**Lock Ordering Management:** + +- **Consistent Acquisition Order** - All locks are acquired in a predetermined order +- **Global Lock Registry** - Centralized registry maintains lock ordering relationships +- **Order Enforcement** - Lock acquisition automatically sorts by predetermined order +- **Dependency Tracking** - Lock dependencies are mapped to prevent circular waits + +**Timeout-Based Protection:** + +- **Default Timeouts** - All lock acquisitions have reasonable timeout limits +- **Context Cancellation** - Operations respect context cancellation for cleanup +- **Maximum Timeout Limits** - Upper bounds prevent indefinite blocking +- **Graceful Timeout Handling** - Timeout errors provide meaningful context + +**Multi-Lock Acquisition Process:** + +- **Ordered Sorting** - Multiple locks are sorted before acquisition attempts +- **Progressive Acquisition** - Locks are acquired one by one in sorted order +- **Failure Recovery** - Failed acquisitions trigger automatic cleanup of held locks +- **Resource Tracking** - All acquired locks are tracked for proper release + +**Lock Acquisition Safety:** + +- **Non-Blocking Detection** - Channel-based lock attempts prevent indefinite blocking +- **Timeout Enforcement** - All lock attempts respect configured timeout limits +- **Error Propagation** - Lock failures are properly propagated with context +- **Cleanup Guarantees** - Failed operations always clean up partially acquired resources + +**Deadlock Detection and Recovery:** + +- **Active Monitoring** - Continuous monitoring for potential deadlock conditions +- **Automatic Recovery** - Detected deadlocks trigger automatic resolution procedures +- **Resource Release** - Deadlock resolution involves strategic resource release +- **Prevention Learning** - Deadlock patterns inform prevention strategy improvements + +--- + +## Related Architecture Documentation + +- **[Request Flow](./request-flow)** - How concurrency fits in request processing +- **[Benchmarks](../../benchmarking/getting-started)** - Concurrency performance characteristics +- **[Plugin System](./plugins)** - Plugin concurrency considerations +- **[MCP System](./mcp)** - MCP concurrency and worker integration + +## Usage Documentation + +- **[Provider Configuration](../../quickstart/gateway/provider-configuration)** - Configure concurrency settings per provider +- **[Performance Analysis](../../benchmarking/getting-started)** - Memory pool configuration and optimization +- **[Performance Monitoring](../../features/telemetry)** - Monitor concurrency metrics and health +- **[Go SDK Usage](../../quickstart/go-sdk/setting-up)** - Use Bifrost concurrency in Go applications +- **[Gateway Setup](../../quickstart/gateway/setting-up)** - Deploy Bifrost with optimal concurrency settings + +--- + +**🎯 Next Step:** Understand how plugins integrate with the concurrency model in **[Plugin System](./plugins)**. +``` diff --git a/docs/architecture/core/mcp.mdx b/docs/architecture/core/mcp.mdx new file mode 100644 index 000000000..5f73a0207 --- /dev/null +++ b/docs/architecture/core/mcp.mdx @@ -0,0 +1,564 @@ +--- +title: "Model Context Protocol (MCP)" +description: "Deep dive into Bifrost's Model Context Protocol (MCP) integration - how external tool discovery, execution, and integration work internally." +icon: "toolbox" +--- + +## MCP Architecture Overview + +### **What is MCP in Bifrost?** + +The Model Context Protocol (MCP) system in Bifrost enables AI models to seamlessly discover and execute external tools, transforming static chat models into dynamic, action-capable agents. This architecture bridges the gap between AI reasoning and real-world tool execution. + +**Core MCP Principles:** + +- **Dynamic Discovery** - Tools are discovered at runtime, not hardcoded +- **Client-Side Execution** - Bifrost controls all tool execution for security +- **Multi-Protocol Support** - STDIO, HTTP, and SSE connection types +- **Request-Level Filtering** - Granular control over tool availability +- **Async Execution** - Non-blocking tool invocation and response handling + +### **MCP System Components** + +```mermaid +graph TB + subgraph "MCP Management Layer" + MCPMgr[MCP Manager
Central Controller] + ClientRegistry[Client Registry
Connection Management] + ToolDiscovery[Tool Discovery
Runtime Registration] + end + + subgraph "MCP Execution Layer" + ToolFilter[Tool Filter
Access Control] + ToolExecutor[Tool Executor
Invocation Engine] + ResultProcessor[Result Processor
Response Handling] + end + + subgraph "Connection Types" + STDIOConn[STDIO Connections
Command-line Tools] + HTTPConn[HTTP Connections
Web Services] + SSEConn[SSE Connections
Real-time Streams] + end + + subgraph "External MCP Servers" + FileSystem[Filesystem Tools
File Operations] + WebSearch[Web Search
Information Retrieval] + Database[Database Tools
Data Access] + Custom[Custom Tools
Business Logic] + end + + MCPMgr --> ClientRegistry + ClientRegistry --> ToolDiscovery + ToolDiscovery --> ToolFilter + ToolFilter --> ToolExecutor + ToolExecutor --> ResultProcessor + + ClientRegistry --> STDIOConn + ClientRegistry --> HTTPConn + ClientRegistry --> SSEConn + + STDIOConn --> FileSystem + HTTPConn --> WebSearch + HTTPConn --> Database + STDIOConn --> Custom +``` + +--- + +## MCP Connection Architecture + +### **Multi-Protocol Connection System** + +Bifrost supports four MCP connection types, each optimized for different tool deployment patterns: + +```mermaid +graph TB + subgraph "InProcess Connections" + InProcess[In-Memory Tools
Same Process] + InProcessEx[Examples:
β€’ Embedded tools
β€’ High-perf operations
β€’ Testing tools] + end + + subgraph "STDIO Connections" + STDIO[Command Line Tools
Local Execution] + STDIOEx[Examples:
β€’ Filesystem tools
β€’ Local scripts
β€’ CLI utilities] + end + + subgraph "HTTP Connections" + HTTP[Web Service Tools
Remote APIs] + HTTPEx[Examples:
β€’ Web search APIs
β€’ Database services
β€’ External integrations] + end + + subgraph "SSE Connections" + SSE[Real-time Tools
Streaming Data] + SSEEx[Examples:
β€’ Live data feeds
β€’ Real-time monitoring
β€’ Event streams] + end + + subgraph "Connection Characteristics" + Latency[Latency:
InProcess < STDIO < HTTP < SSE] + Security[Security:
InProcess/Local > HTTP > SSE] + Scalability[Scalability:
HTTP > SSE > STDIO > InProcess] + Complexity[Complexity:
InProcess < STDIO < HTTP < SSE] + end + + InProcess --> Latency + STDIO --> Latency + HTTP --> Security + SSE --> Scalability + HTTP --> Complexity +``` + +### **Connection Type Details** + +**InProcess Connections (In-Memory Tools):** + +- **Use Case:** Embedded tools, high-performance operations, testing +- **Performance:** Lowest possible latency (~0.1ms) with no IPC overhead +- **Security:** Highest security as tools run in the same process +- **Limitations:** Go package only, cannot be configured via JSON + +**STDIO Connections (Local Tools):** + +- **Use Case:** Command-line tools, local scripts, filesystem operations +- **Performance:** Low latency (~1-10ms) due to local execution +- **Security:** High security with full local control +- **Limitations:** Single-server deployment, resource sharing + +**HTTP Connections (Remote Services):** + +- **Use Case:** Web APIs, microservices, cloud functions +- **Performance:** Network-dependent latency (~10-500ms) +- **Security:** Configurable with authentication and encryption +- **Advantages:** Scalable, multi-server deployment, service isolation + +**SSE Connections (Streaming Tools):** + +- **Use Case:** Real-time data feeds, live monitoring, event streams +- **Performance:** Variable latency depending on stream frequency +- **Security:** Similar to HTTP with streaming capabilities +- **Benefits:** Real-time updates, persistent connections, event-driven + +> **MCP Configuration:** [MCP Setup Guide β†’](../../features/mcp) + +--- + +## Tool Discovery & Registration + +### **Dynamic Tool Discovery Process** + +The MCP system discovers tools at runtime rather than requiring static configuration, enabling flexible and adaptive tool availability: + +```mermaid +sequenceDiagram + participant Bifrost + participant MCPManager + participant MCPServer + participant ToolRegistry + participant AIModel + + Note over Bifrost: System Startup + Bifrost->>MCPManager: Initialize MCP System + MCPManager->>MCPServer: Establish Connection + MCPServer-->>MCPManager: Connection Ready + + MCPManager->>MCPServer: List Available Tools + MCPServer-->>MCPManager: Tool Definitions + MCPManager->>ToolRegistry: Register Tools + + Note over Bifrost: Runtime Request Processing + AIModel->>MCPManager: Request Available Tools + MCPManager->>ToolRegistry: Query Tools + ToolRegistry-->>MCPManager: Filtered Tool List + MCPManager-->>AIModel: Available Tools + + AIModel->>MCPManager: Execute Tool Call + MCPManager->>MCPServer: Tool Invocation + MCPServer->>MCPServer: Execute Tool Logic + MCPServer-->>MCPManager: Tool Result + MCPManager-->>AIModel: Enhanced Response +``` + +### **Tool Registry Management** + +**Registration Process:** + +1. **Connection Establishment** - MCP client connects to configured servers +2. **Capability Exchange** - Server announces available tools and schemas +3. **Tool Validation** - Bifrost validates tool definitions and security +4. **Registry Update** - Tools are registered in the internal tool registry +5. **Availability Notification** - Tools become available for AI model use + +**Registry Features:** + +- **Dynamic Updates** - Tools can be added/removed during runtime +- **Version Management** - Support for tool versioning and compatibility +- **Access Control** - Request-level tool filtering and permissions +- **Health Monitoring** - Continuous tool availability checking + +**Tool Metadata Structure:** + +- **Name & Description** - Human-readable tool identification +- **Parameters Schema** - JSON schema for tool input validation +- **Return Schema** - Expected response format definition +- **Capabilities** - Tool feature flags and limitations +- **Authentication** - Required credentials and permissions + +--- + +## Tool Filtering & Access Control + +### **Multi-Level Filtering System** + +Bifrost provides granular control over tool availability through a sophisticated filtering system: + +```mermaid +flowchart TD + Request[Incoming Request] --> GlobalFilter{Global MCP Filter} + GlobalFilter -->|Enabled| ClientFilter[MCP Client Filtering] + GlobalFilter -->|Disabled| NoMCP[No MCP Tools] + + ClientFilter --> IncludeClients{Include Clients?} + IncludeClients -->|Yes| IncludeList[Include Specified
MCP Clients] + IncludeClients -->|No| AllClients[All MCP Clients] + + IncludeList --> ExcludeClients{Exclude Clients?} + AllClients --> ExcludeClients + ExcludeClients -->|Yes| RemoveClients[Remove Excluded
MCP Clients] + ExcludeClients -->|No| ClientsFiltered[Filtered Clients] + + RemoveClients --> ToolFilter[Tool-Level Filtering] + ClientsFiltered --> ToolFilter + + ToolFilter --> IncludeTools{Include Tools?} + IncludeTools -->|Yes| IncludeSpecific[Include Specified
Tools Only] + IncludeTools -->|No| AllTools[All Available Tools] + + IncludeSpecific --> ExcludeTools{Exclude Tools?} + AllTools --> ExcludeTools + ExcludeTools -->|Yes| RemoveTools[Remove Excluded
Tools] + ExcludeTools -->|No| FinalTools[Final Tool Set] + + RemoveTools --> FinalTools + FinalTools --> AIModel[Available to AI Model] + NoMCP --> AIModel +``` + +### **Filtering Configuration Levels** + +**Request-Level Filtering:** + +```bash +# Include only specific MCP clients +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-mcp-include-clients: filesystem,websearch" \ + -d '{"model": "gpt-4o-mini", "messages": [...]}' + +# Include only specific tools +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-mcp-include-tools: filesystem/read_file,websearch/search" \ + -d '{"model": "gpt-4o-mini", "messages": [...]}' +``` + +**Configuration-Level Filtering:** + +- **Client Selection** - Choose which MCP servers to connect to +- **Tool Blacklisting** - Permanently disable dangerous or unwanted tools +- **Permission Mapping** - Map user roles to available tool sets +- **Environment-Based** - Different tool sets for development vs production + +**Security Benefits:** + +- **Principle of Least Privilege** - Only necessary tools are exposed +- **Dynamic Access Control** - Per-request tool availability +- **Audit Trail** - Track which tools are used by which requests +- **Risk Mitigation** - Prevent access to dangerous operations + +> **πŸ“– Tool Filtering:** [MCP Tool Control β†’](../../features/mcp) + +--- + +## Tool Execution Engine + +### **Async Tool Execution Architecture** + +The MCP execution engine handles tool invocation asynchronously to maintain system responsiveness and enable complex multi-tool workflows: + +```mermaid +sequenceDiagram + participant AIModel + participant ExecutionEngine + participant ToolInvoker + participant MCPServer + participant ResultProcessor + + AIModel->>ExecutionEngine: Tool Call Request + ExecutionEngine->>ExecutionEngine: Validate Tool Call + ExecutionEngine->>ToolInvoker: Queue Tool Execution + + Note over ToolInvoker: Async Tool Execution + ToolInvoker->>MCPServer: Invoke Tool + MCPServer->>MCPServer: Execute Tool Logic + MCPServer-->>ToolInvoker: Raw Tool Result + + ToolInvoker->>ResultProcessor: Process Result + ResultProcessor->>ResultProcessor: Format & Validate + ResultProcessor-->>ExecutionEngine: Processed Result + + ExecutionEngine-->>AIModel: Tool Execution Complete + + Note over AIModel: Multi-turn Conversation + AIModel->>ExecutionEngine: Continue with Tool Results + ExecutionEngine->>ExecutionEngine: Merge Results into Context + ExecutionEngine-->>AIModel: Enhanced Response +``` + +### **Execution Flow Characteristics** + +**Validation Phase:** + +- **Parameter Validation** - Ensure tool arguments match expected schema +- **Permission Checking** - Verify tool access permissions for the request +- **Rate Limiting** - Apply per-tool and per-user rate limits +- **Security Scanning** - Check for potentially dangerous operations + +**Execution Phase:** + +- **Timeout Management** - Bounded execution time to prevent hanging +- **Error Handling** - Graceful handling of tool failures and timeouts +- **Result Streaming** - Support for tools that return streaming responses +- **Resource Monitoring** - Track tool resource usage and performance + +**Response Phase:** + +- **Result Formatting** - Convert tool outputs to consistent format +- **Error Enrichment** - Add context and suggestions for tool failures +- **Multi-Result Aggregation** - Combine multiple tool outputs coherently +- **Context Integration** - Merge tool results into conversation context + +### **Multi-Turn Conversation Support** + +The MCP system enables sophisticated multi-turn conversations where AI models can: + +1. **Initial Tool Discovery** - Request available tools for a given context +2. **Tool Execution** - Execute one or more tools based on user request +3. **Result Analysis** - Analyze tool outputs and determine next steps +4. **Follow-up Actions** - Execute additional tools based on previous results +5. **Response Synthesis** - Combine tool results into coherent user response + +**Example Multi-Turn Flow:** + +``` +User: "Find recent news about AI and save interesting articles" +AI: β†’ Execute web_search("AI news recent") +AI: β†’ Analyze search results +AI: β†’ Execute save_article() for each interesting result +AI: β†’ Respond with summary of saved articles +``` + +### **Complete User-Controlled Tool Execution Flow** + +The following diagram shows the end-to-end user experience with MCP tool execution, highlighting the critical user control points and decision-making process: + +```mermaid +flowchart TD + A["πŸ‘€ User Message
\"List files in current directory\""] --> B["πŸ€– Bifrost Core"] + + B --> C["πŸ”§ MCP Manager
Auto-discovers and adds
available tools to request"] + + C --> D["🌐 LLM Provider
(OpenAI, Anthropic, etc.)"] + + D --> E{"πŸ” Response contains
tool_calls?"} + + E -->|No| F["βœ… Final Response
Display to user"] + + E -->|Yes| G["πŸ“ Add assistant message
with tool_calls to history"] + + G --> H["πŸ›‘οΈ YOUR EXECUTION LOGIC
(Security, Approval, Logging)"] + + H --> I{"πŸ€” User Decision Point
Execute this tool?"} + + I -->|Deny| J["❌ Create denial result
Add to conversation history"] + + I -->|Approve| K["βš™οΈ client.ExecuteMCPTool()
Bifrost executes via MCP"] + + K --> L["πŸ“Š Tool Result
Add to conversation history"] + + J --> M["πŸ”„ Continue conversation loop
Send updated history back to LLM"] + L --> M + + M --> D + + style A fill:#e1f5fe + style F fill:#e8f5e8 + style H fill:#fff3e0 + style I fill:#fce4ec + style K fill:#f3e5f5 +``` + +**Key Flow Characteristics:** + +**User Control Points:** + +- **Security Layer** - Your application controls all tool execution decisions +- **Approval Gate** - Users can approve or deny each tool execution +- **Transparency** - Full visibility into what tools will be executed and why +- **Conversation Continuity** - Tool results seamlessly integrate into conversation flow + +**Security Benefits:** + +- **No Automatic Execution** - Tools never execute without explicit approval +- **Audit Trail** - Complete logging of all tool execution decisions +- **Contextual Security** - Approval decisions can consider full conversation context +- **Graceful Denials** - Denied tools result in informative responses, not errors + +**Implementation Patterns:** + +```go +// Example tool execution control in your application +func handleToolExecution(toolCall schemas.ChatToolCall, userContext UserContext) error { + // YOUR SECURITY AND APPROVAL LOGIC HERE + if !userContext.HasPermission(toolCall.Function.Name) { + return createDenialResponse("Tool not permitted for user role") + } + + if requiresApproval(toolCall) { + approved := promptUserForApproval(toolCall) + if !approved { + return createDenialResponse("User denied tool execution") + } + } + + // Execute the tool via Bifrost + result, err := client.ExecuteMCPTool(ctx, toolCall) + if err != nil { + return handleToolError(err) + } + + return addToolResultToHistory(result) +} +``` + +This flow ensures that while AI models can discover and request tool usage, all actual execution remains under user control, providing the perfect balance of AI capability and human oversight. + +--- + +## MCP Integration Patterns + +### **Common Integration Scenarios** + +**1. Filesystem Operations** + +- **Tools:** `list_files`, `read_file`, `write_file`, `create_directory` +- **Use Cases:** Code analysis, document processing, file management +- **Security:** Sandboxed file access, path validation, permission checks +- **Performance:** Local execution for fast file operations + +**2. Web Search & Information Retrieval** + +- **Tools:** `web_search`, `fetch_url`, `extract_content`, `summarize` +- **Use Cases:** Research assistance, fact-checking, content gathering +- **Integration:** External search APIs, content parsing services +- **Caching:** Response caching for repeated queries + +**3. Database Operations** + +- **Tools:** `query_database`, `insert_record`, `update_record`, `schema_info` +- **Use Cases:** Data analysis, report generation, database administration +- **Security:** Read-only access by default, query validation, injection prevention +- **Performance:** Connection pooling, query optimization + +**4. API Integrations** + +- **Tools:** Custom business logic tools, third-party service integration +- **Use Cases:** CRM operations, payment processing, notification sending +- **Authentication:** API key management, OAuth token handling +- **Error Handling:** Retry logic, fallback mechanisms + +### **MCP Server Development Patterns** + +**Simple STDIO Server:** + +- **Language:** Any language that can read/write JSON to stdin/stdout +- **Deployment:** Single executable, minimal dependencies +- **Use Case:** Local tools, development utilities, simple scripts + +**HTTP Service Server:** + +- **Architecture:** RESTful API with MCP protocol endpoints +- **Scalability:** Horizontal scaling, load balancing +- **Use Case:** Shared tools, enterprise integrations, cloud services + +**Hybrid Approach:** + +- **Local + Remote:** Combine STDIO tools for local operations with HTTP for remote services +- **Failover:** Use local fallbacks when remote services are unavailable +- **Optimization:** Route tool calls to most appropriate execution environment + +> **πŸ“– MCP Development:** [Tool Development Guide β†’](../../features/mcp) + +--- + +## Security & Safety Considerations + +### **MCP Security Architecture** + +```mermaid +graph TB + subgraph "Security Layers" + L1[Connection Security
Authentication & Encryption] + L2[Tool Validation
Schema & Permission Checks] + L3[Execution Security
Sandboxing & Limits] + L4[Result Security
Output Validation & Filtering] + end + + subgraph "Threat Mitigation" + T1[Malicious Tools
Code Injection Prevention] + T2[Resource Abuse
Rate Limiting & Quotas] + T3[Data Exposure
Output Sanitization] + T4[System Access
Privilege Isolation] + end + + L1 --> T1 + L2 --> T2 + L3 --> T4 + L4 --> T3 +``` + +**Security Measures:** + +**Connection Security:** + +- **Authentication** - API keys, certificates, or token-based auth for HTTP/SSE +- **Encryption** - TLS for HTTP connections, secure pipes for STDIO +- **Network Isolation** - Firewall rules and network segmentation + +**Execution Security:** + +- **Sandboxing** - Isolated execution environments for tools +- **Resource Limits** - CPU, memory, and time constraints +- **Permission Model** - Principle of least privilege for tool access + +**Data Security:** + +- **Input Validation** - Strict parameter validation before tool execution +- **Output Sanitization** - Remove sensitive data from tool responses +- **Audit Logging** - Complete audit trail of tool usage + +**Operational Security:** + +- **Regular Updates** - Keep MCP servers and tools updated +- **Monitoring** - Continuous security monitoring and alerting +- **Incident Response** - Procedures for security incidents involving tools + +> **πŸ“– MCP Security:** [Security Best Practices β†’](../../features/mcp) + +--- + +## Related Architecture Documentation + +- **[Request Flow](./request-flow)** - MCP integration in request processing +- **[Concurrency Model](./concurrency)** - MCP concurrency and worker integration +- **[Plugin System](./plugins)** - Integration between MCP and plugin systems +- **[Benchmarks](../../benchmarking/getting-started)** - MCP performance impact and optimization + + + diff --git a/docs/architecture/core/plugins.mdx b/docs/architecture/core/plugins.mdx new file mode 100644 index 000000000..7f948623d --- /dev/null +++ b/docs/architecture/core/plugins.mdx @@ -0,0 +1,552 @@ +--- +title: "Plugins" +description: "Deep dive into Bifrost's extensible plugin architecture - how plugins work internally, lifecycle management, execution model, and integration patterns." +icon: "puzzle-piece" +--- + +## Plugin Architecture Philosophy + +### **Core Design Principles** + +Bifrost's plugin system is built around five key principles that ensure extensibility without compromising performance or reliability: + +| Principle | Implementation | Benefit | +| ----------------------------- | ------------------------------------------------ | ------------------------------------------------ | +| **Plugin-First Design** | Core logic designed around plugin hook points | Maximum extensibility without core modifications | +| **Zero-Copy Integration** | Direct memory access to request/response objects | Minimal performance overhead | +| **Lifecycle Management** | Complete plugin lifecycle with automatic cleanup | Resource safety and leak prevention | +| **Interface-Based Safety** | Well-defined interfaces for type safety | Compile-time validation and consistency | +| **Failure Isolation** | Plugin errors don't crash the core system | Fault tolerance and system stability | + +### **Plugin System Overview** + +```mermaid +graph TB + subgraph "Plugin Management Layer" + PluginMgr[Plugin Manager
Central Controller] + Registry[Plugin Registry
Discovery & Loading] + Lifecycle[Lifecycle Manager
State Management] + end + + subgraph "Plugin Execution Layer" + Pipeline[Plugin Pipeline
Execution Orchestrator] + PreHooks[Pre-Processing Hooks
Request Modification] + PostHooks[Post-Processing Hooks
Response Enhancement] + end + + subgraph "Plugin Categories" + Auth[Authentication
& Authorization] + RateLimit[Rate Limiting
& Throttling] + Transform[Data Transformation
& Validation] + Monitor[Monitoring
& Analytics] + Custom[Custom Business
Logic] + end + + PluginMgr --> Registry + Registry --> Lifecycle + Lifecycle --> Pipeline + + Pipeline --> PreHooks + Pipeline --> PostHooks + + PreHooks --> Auth + PreHooks --> RateLimit + PostHooks --> Transform + PostHooks --> Monitor + PostHooks --> Custom +``` + +--- + +## Plugin Lifecycle Management + +### **Complete Lifecycle States** + +Every plugin goes through a well-defined lifecycle that ensures proper resource management and error handling: + +```mermaid +stateDiagram-v2 + [*] --> PluginInit: Plugin Creation + PluginInit --> Registered: Add to BifrostConfig + Registered --> PreHookCall: Request Received + + PreHookCall --> ModifyRequest: Normal Flow + PreHookCall --> ShortCircuitResponse: Return Response + PreHookCall --> ShortCircuitError: Return Error + + ModifyRequest --> ProviderCall: Send to Provider + ProviderCall --> PostHookCall: Receive Response + + ShortCircuitResponse --> PostHookCall: Skip Provider + ShortCircuitError --> PostHookCall: Pipeline Symmetry + + PostHookCall --> ModifyResponse: Process Result + PostHookCall --> RecoverError: Error Recovery + PostHookCall --> FallbackCheck: Check AllowFallbacks + PostHookCall --> ResponseReady: Pass Through + + FallbackCheck --> TryFallback: AllowFallbacks=true/nil + FallbackCheck --> ResponseReady: AllowFallbacks=false + TryFallback --> PreHookCall: Next Provider + + ModifyResponse --> ResponseReady: Modified + RecoverError --> ResponseReady: Recovered + ResponseReady --> [*]: Return to Client + + Registered --> CleanupCall: Bifrost Shutdown + CleanupCall --> [*]: Plugin Destroyed +``` + +### **Lifecycle Phase Details** + +**Discovery Phase:** + +- **Purpose:** Find and catalog available plugins +- **Sources:** Command line, environment variables, JSON configuration, directory scanning +- **Validation:** Basic existence and format checks +- **Output:** Plugin descriptors with metadata + +**Loading Phase:** + +- **Purpose:** Load plugin binaries into memory +- **Security:** Digital signature verification and checksum validation +- **Compatibility:** Interface implementation validation +- **Resource:** Memory and capability assessment + +**Initialization Phase:** + +- **Purpose:** Configure plugin with runtime settings +- **Timeout:** Bounded initialization time to prevent hanging +- **Dependencies:** External service connectivity verification +- **State:** Internal state setup and resource allocation + +**Runtime Phase:** + +- **Purpose:** Active request processing +- **Monitoring:** Continuous health checking and performance tracking +- **Recovery:** Automatic error recovery and degraded mode handling +- **Metrics:** Real-time performance and health metrics collection + +> **Plugin Lifecycle:** [Plugin Management β†’](../../enterprise/custom-plugins) + +--- + +## Plugin Execution Pipeline + +### **Request Processing Flow** + +The plugin pipeline ensures consistent, predictable execution while maintaining high performance: + +#### **Normal Execution Flow (No Short-Circuit)** + +```mermaid +sequenceDiagram + participant Client + participant Bifrost + participant Plugin1 + participant Plugin2 + participant Provider + + Client->>Bifrost: Request + Bifrost->>Plugin1: PreHook(request) + Plugin1-->>Bifrost: modified request + Bifrost->>Plugin2: PreHook(request) + Plugin2-->>Bifrost: modified request + Bifrost->>Provider: API Call + Provider-->>Bifrost: response + Bifrost->>Plugin2: PostHook(response) + Plugin2-->>Bifrost: modified response + Bifrost->>Plugin1: PostHook(response) + Plugin1-->>Bifrost: modified response + Bifrost-->>Client: Final Response +``` + +**Execution Order:** + +1. **PreHooks:** Execute in registration order (1 β†’ 2 β†’ N) +2. **Provider Call:** If no short-circuit occurred +3. **PostHooks:** Execute in reverse order (N β†’ 2 β†’ 1) + +#### **Short-Circuit Response Flow (Cache Hit)** + +```mermaid +sequenceDiagram + participant Client + participant Bifrost + participant Cache + participant Auth + participant Provider + + Client->>Bifrost: Request + Bifrost->>Auth: PreHook(request) + Auth-->>Bifrost: modified request + Bifrost->>Cache: PreHook(request) + Cache-->>Bifrost: PluginShortCircuit{Response} + Note over Provider: Provider call skipped + Bifrost->>Cache: PostHook(response) + Cache-->>Bifrost: modified response + Bifrost->>Auth: PostHook(response) + Auth-->>Bifrost: modified response + Bifrost-->>Client: Cached Response +``` + +#### **Streaming Response Flow** + +For streaming responses, the plugin pipeline executes post-hooks for every delta/chunk received from the provider: + +```mermaid +sequenceDiagram + participant Client + participant Bifrost + participant Plugin1 + participant Plugin2 + participant Provider + + Client->>Bifrost: Stream Request + Bifrost->>Plugin1: PreHook(request) + Plugin1-->>Bifrost: modified request + Bifrost->>Plugin2: PreHook(request) + Plugin2-->>Bifrost: modified request + Bifrost->>Provider: Stream API Call + + loop For Each Delta + Provider-->>Bifrost: stream delta + Bifrost->>Plugin2: PostHook(delta) + Plugin2-->>Bifrost: modified delta + Bifrost->>Plugin1: PostHook(delta) + Plugin1-->>Bifrost: modified delta + Bifrost-->>Client: Send Delta + end + + Provider-->>Bifrost: final chunk (finish reason) + Bifrost->>Plugin2: PostHook(final) + Plugin2-->>Bifrost: modified final + Bifrost->>Plugin1: PostHook(final) + Plugin1-->>Bifrost: modified final + Bifrost-->>Client: Final Chunk +``` + +**Streaming Execution Characteristics:** + +1. **Delta Processing:** + - Each stream delta (chunk) goes through all post-hooks + - Plugins can modify/transform each delta before it reaches the client + - Deltas can contain: text content, tool calls, role changes, or usage info + +2. **Special Delta Types:** + - **Start Event:** Initial delta with role information + - **Content Delta:** Regular text or tool call content + - **Usage Update:** Token usage statistics (if enabled) + - **Final Chunk:** Contains finish reason and any final metadata + +3. **Plugin Considerations:** + - Plugins must handle streaming responses efficiently + - Each delta should be processed quickly to maintain stream responsiveness + - Plugins can track state across deltas using context + - Heavy processing should be done asynchronously + +4. **Error Handling:** + - If a post-hook returns an error, it's sent as an error stream chunk + - Stream is terminated after error chunks + - Plugins can recover from errors by providing valid responses + +5. **Performance Optimization:** + - Lightweight delta processing to minimize latency + - Object pooling for common data structures + - Non-blocking operations for logging and metrics + - Efficient memory management for stream processing + +> **Streaming Details:** [Streaming Guide β†’](../../quickstart/gateway/streaming) + +**Short-Circuit Rules:** + +- **Provider Skipped:** When plugin returns short-circuit response/error +- **PostHook Guarantee:** All executed PreHooks get corresponding PostHook calls +- **Reverse Order:** PostHooks execute in reverse order of PreHooks + +#### **Short-Circuit Error Flow (Allow Fallbacks)** + +```mermaid +sequenceDiagram + participant Client + participant Bifrost + participant Plugin1 + participant Provider1 + participant Provider2 + + Client->>Bifrost: Request (Provider1 + Fallback Provider2) + Bifrost->>Plugin1: PreHook(request) + Plugin1-->>Bifrost: PluginShortCircuit{Error, AllowFallbacks=true} + Note over Provider1: Provider1 call skipped + Bifrost->>Plugin1: PostHook(error) + Plugin1-->>Bifrost: error unchanged + + Note over Bifrost: Try fallback provider + Bifrost->>Plugin1: PreHook(request for Provider2) + Plugin1-->>Bifrost: modified request + Bifrost->>Provider2: API Call + Provider2-->>Bifrost: response + Bifrost->>Plugin1: PostHook(response) + Plugin1-->>Bifrost: modified response + Bifrost-->>Client: Final Response +``` + +#### **Error Recovery Flow** + +```mermaid +sequenceDiagram + participant Client + participant Bifrost + participant Plugin1 + participant Plugin2 + participant Provider + participant RecoveryPlugin + + Client->>Bifrost: Request + Bifrost->>Plugin1: PreHook(request) + Plugin1-->>Bifrost: modified request + Bifrost->>Plugin2: PreHook(request) + Plugin2-->>Bifrost: modified request + Bifrost->>RecoveryPlugin: PreHook(request) + RecoveryPlugin-->>Bifrost: modified request + Bifrost->>Provider: API Call + Provider-->>Bifrost: error + Bifrost->>RecoveryPlugin: PostHook(error) + RecoveryPlugin-->>Bifrost: recovered response + Bifrost->>Plugin2: PostHook(response) + Plugin2-->>Bifrost: modified response + Bifrost->>Plugin1: PostHook(response) + Plugin1-->>Bifrost: modified response + Bifrost-->>Client: Recovered Response +``` + +**Error Recovery Features:** + +- **Error Transformation:** Plugins can convert errors to successful responses +- **Graceful Degradation:** Provide fallback responses for service failures +- **Context Preservation:** Error context is maintained through recovery process + +### **Complex Plugin Decision Flow** + +Real-world plugin interactions involving authentication, rate limiting, and caching with different decision paths: + +```mermaid +graph TD + A["Client Request"] --> B["Bifrost"] + B --> C["Auth Plugin PreHook"] + C --> D{"Authenticated?"} + D -->|No| E["Return Auth Error
AllowFallbacks=false"] + D -->|Yes| F["RateLimit Plugin PreHook"] + F --> G{"Rate Limited?"} + G -->|Yes| H["Return Rate Error
AllowFallbacks=nil"] + G -->|No| I["Cache Plugin PreHook"] + I --> J{"Cache Hit?"} + J -->|Yes| K["Return Cached Response"] + J -->|No| L["Provider API Call"] + L --> M["Cache Plugin PostHook"] + M --> N["Store in Cache"] + N --> O["RateLimit Plugin PostHook"] + O --> P["Auth Plugin PostHook"] + P --> Q["Final Response"] + + E --> R["Skip Fallbacks"] + H --> S["Try Fallback Provider"] + K --> T["Skip Provider Call"] +``` + +### **Execution Characteristics** + +**Symmetric Execution Pattern:** + +- **Pre-processing:** Plugins execute in priority order (high to low) +- **Post-processing:** Plugins execute in reverse order (low to high) +- **Rationale:** Ensures proper cleanup and state management (last in, first out) + +**Performance Optimizations:** + +- **Timeout Boundaries:** Each plugin has configurable execution timeouts +- **Panic Recovery:** Plugin panics are caught and logged without crashing the system +- **Resource Limits:** Memory and CPU limits prevent runaway plugins +- **Circuit Breaking:** Repeated failures trigger plugin isolation + +**Error Handling Strategies:** + +- **Continue:** Use original request/response if plugin fails +- **Fail Fast:** Return error immediately if critical plugin fails +- **Retry:** Attempt plugin execution with exponential backoff +- **Fallback:** Use alternative plugin or default behavior + +> **Plugin Execution:** [Request Flow β†’](./request-flow#stage-3-plugin-pipeline-processing) + +--- + +## Security & Validation + +### **Multi-Layer Security Model** + +Plugin security operates at multiple layers to ensure system integrity: + +```mermaid +graph TB + subgraph "Security Validation Layers" + L1[Layer 1: Binary Validation
Signature & Checksum] + L2[Layer 2: Interface Validation
Type Safety & Compatibility] + L3[Layer 3: Runtime Validation
Resource Limits & Timeouts] + L4[Layer 4: Execution Isolation
Panic Recovery & Error Handling] + end + + subgraph "Security Benefits" + Integrity[Code Integrity
Verified Authenticity] + Safety[Type Safety
Compile-time Checks] + Stability[System Stability
Isolated Failures] + Performance[Performance Protection
Resource Limits] + end + + L1 --> Integrity + L2 --> Safety + L3 --> Performance + L4 --> Stability +``` + +### **Validation Process** + +**Binary Security:** + +- **Digital Signatures:** Cryptographic verification of plugin authenticity +- **Checksum Validation:** File integrity verification +- **Source Verification:** Trusted source requirements + +**Interface Security:** + +- **Type Safety:** Interface implementation verification +- **Version Compatibility:** Plugin API version checking +- **Memory Safety:** Safe memory access patterns + +**Runtime Security:** + +- **Resource Quotas:** Memory and CPU usage limits +- **Execution Timeouts:** Bounded execution time +- **Sandbox Execution:** Isolated execution environment + +**Operational Security:** + +- **Health Monitoring:** Continuous plugin health assessment +- **Error Tracking:** Plugin error rate monitoring +- **Automatic Recovery:** Failed plugin restart and recovery + +--- + +## Plugin Performance & Monitoring + +### **Comprehensive Metrics System** + +Bifrost provides detailed metrics for plugin performance and health monitoring: + +```mermaid +graph TB + subgraph "Execution Metrics" + ExecTime[Execution Time
Latency per Plugin] + ExecCount[Execution Count
Request Volume] + SuccessRate[Success Rate
Error Percentage] + Throughput[Throughput
Requests/Second] + end + + subgraph "Resource Metrics" + MemoryUsage[Memory Usage
Per Plugin Instance] + CPUUsage[CPU Utilization
Processing Time] + IOMetrics[I/O Operations
Network/Disk Activity] + PoolUtilization[Pool Utilization
Resource Efficiency] + end + + subgraph "Health Metrics" + ErrorRate[Error Rate
Failed Executions] + PanicCount[Panic Recovery
Crash Events] + TimeoutCount[Timeout Events
Slow Executions] + RecoveryRate[Recovery Success
Failure Handling] + end + + subgraph "Business Metrics" + AddedLatency[Added Latency
Plugin Overhead] + SystemImpact[System Impact
Overall Performance] + FeatureUsage[Feature Usage
Plugin Utilization] + CostImpact[Cost Impact
Resource Consumption] + end +``` + +### **Performance Characteristics** + +**Plugin Execution Performance:** + +- **Typical Overhead:** 1-10ΞΌs per plugin for simple operations +- **Authentication Plugins:** 1-5ΞΌs for key validation +- **Rate Limiting Plugins:** 500ns for quota checks +- **Monitoring Plugins:** 200ns for metric collection +- **Transformation Plugins:** 2-10ΞΌs depending on complexity + +**Resource Usage Patterns:** + +- **Memory Efficiency:** Object pooling reduces allocations +- **CPU Optimization:** Minimal processing overhead +- **Network Impact:** Configurable external service calls +- **Storage Overhead:** Minimal for stateless plugins + +--- + +## Plugin Integration Patterns + +### **Common Integration Scenarios** + +**1. Authentication & Authorization** + +- **Pre-processing Hook:** Validate API keys or JWT tokens +- **Configuration:** External identity provider integration +- **Error Handling:** Return 401/403 responses for invalid credentials +- **Performance:** Sub-5ΞΌs validation with caching + +**2. Rate Limiting & Quotas** + +- **Pre-processing Hook:** Check request quotas and limits +- **Storage:** Redis or in-memory rate limit tracking +- **Algorithms:** Token bucket, sliding window, fixed window +- **Responses:** 429 Too Many Requests with retry headers + +**3. Request/Response Transformation** + +- **Dual Hooks:** Pre-processing for requests, post-processing for responses +- **Use Cases:** Data format conversion, field mapping, content filtering +- **Performance:** Streaming transformations for large payloads +- **Compatibility:** Provider-specific format adaptations + +**4. Monitoring & Analytics** + +- **Post-processing Hook:** Collect metrics and logs after request completion +- **Destinations:** Prometheus, DataDog, custom analytics systems +- **Data:** Request/response metadata, performance metrics, error tracking +- **Privacy:** Configurable data sanitization and filtering + +### **Plugin Communication Patterns** + +**Plugin-to-Plugin Communication:** + +- **Shared Context:** Plugins can store data in request context for downstream plugins +- **Event System:** Plugin can emit events for other plugins to consume +- **Data Passing:** Structured data exchange between related plugins + +**Plugin-to-External Service Communication:** + +- **HTTP Clients:** Built-in HTTP client pools for external API calls +- **Database Connections:** Connection pooling for database access +- **Message Queues:** Integration with message queue systems +- **Caching Systems:** Redis, Memcached integration for state storage + +> **πŸ“– Integration Examples:** [Plugin Development Guide β†’](../../enterprise/custom-plugins) + +--- + +## Related Architecture Documentation + +- **[Request Flow](./request-flow)** - Plugin execution in request processing pipeline +- **[Concurrency Model](./concurrency)** - Plugin concurrency and threading considerations +- **[Benchmarks](../../benchmarking/getting-started)** - Plugin performance characteristics and optimization +- **[MCP System](./mcp)** - Integration between plugins and MCP system + diff --git a/docs/architecture/core/providers.mdx b/docs/architecture/core/providers.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/core/request-flow.mdx b/docs/architecture/core/request-flow.mdx new file mode 100644 index 000000000..e1e5e6201 --- /dev/null +++ b/docs/architecture/core/request-flow.mdx @@ -0,0 +1,527 @@ +--- +title: "Request Flow" +description: "Deep dive into Bifrost's request processing pipeline - from transport layer ingestion through provider execution to response delivery." +icon: "route" +--- + +## Stage 1: Transport Layer Processing + +### **HTTP Transport Flow** + +```mermaid +sequenceDiagram + participant Client + participant HTTPTransport + participant Router + participant Validation + + Client->>HTTPTransport: POST /v1/chat/completions + HTTPTransport->>HTTPTransport: Parse Headers + HTTPTransport->>HTTPTransport: Extract Body + HTTPTransport->>Validation: Validate JSON Schema + Validation->>Router: BifrostRequest + Router-->>HTTPTransport: Processing Started + HTTPTransport-->>Client: HTTP 200 (async processing) +``` + +**Key Processing Steps:** + +1. **Request Reception** - FastHTTP server receives request +2. **Header Processing** - Extract authentication, content-type, custom headers +3. **Body Parsing** - JSON unmarshaling with schema validation +4. **Request Transformation** - Convert to internal `BifrostRequest` schema +5. **Context Creation** - Build request context with metadata + +**Performance Characteristics:** + +- **Parsing Time:** ~2.1ΞΌs for typical requests +- **Validation Overhead:** ~400ns for schema checks +- **Memory Allocation:** Zero-copy where possible + +### **Go SDK Flow** + +```mermaid +sequenceDiagram + participant Application + participant SDK + participant Core + participant Validation + + Application->>SDK: bifrost.ChatCompletion(req) + SDK->>SDK: Type Validation + SDK->>Core: Direct Function Call + Core->>Validation: Schema Validation + Validation-->>Core: Validated Request + Core-->>SDK: Processing Result + SDK-->>Application: Typed Response +``` + +**Advantages:** + +- **Zero Serialization** - Direct Go struct passing +- **Type Safety** - Compile-time validation +- **Lower Latency** - No HTTP/JSON overhead +- **Memory Efficiency** - No intermediate allocations + +--- + +## Stage 2: Request Routing & Load Balancing + +### **Provider Selection Logic** + +```mermaid +flowchart TD + Request[Incoming Request] --> ModelCheck{Model Available?} + ModelCheck -->|Yes| ProviderDirect[Use Specified Provider] + ModelCheck -->|No| ModelMapping[Model β†’ Provider Mapping] + + ProviderDirect --> KeyPool[API Key Pool] + ModelMapping --> KeyPool + + KeyPool --> WeightedSelect[Weighted Random Selection] + WeightedSelect --> HealthCheck{Provider Healthy?} + + HealthCheck -->|Yes| AssignWorker[Assign Worker] + HealthCheck -->|No| CircuitBreaker[Circuit Breaker] + + CircuitBreaker --> FallbackCheck{Fallback Available?} + FallbackCheck -->|Yes| FallbackProvider[Try Fallback] + FallbackCheck -->|No| ErrorResponse[Return Error] + + FallbackProvider --> KeyPool +``` + +**Key Selection Algorithm:** + +```go +// Weighted random key selection +type KeySelector struct { + keys []APIKey + weights []float64 + total float64 +} + +func (ks *KeySelector) SelectKey() *APIKey { + r := rand.Float64() * ks.total + cumulative := 0.0 + + for i, weight := range ks.weights { + cumulative += weight + if r <= cumulative { + return &ks.keys[i] + } + } + return &ks.keys[len(ks.keys)-1] +} +``` + +**Performance Metrics:** + +- **Key Selection Time:** ~10ns (constant time) +- **Health Check Overhead:** ~50ns (cached results) +- **Fallback Decision:** ~25ns (configuration lookup) + +--- + +## Stage 3: Plugin Pipeline Processing + +### **Pre-Processing Hooks** + +```mermaid +sequenceDiagram + participant Request + participant AuthPlugin + participant RateLimitPlugin + participant TransformPlugin + participant Core + + Request->>AuthPlugin: ProcessRequest() + AuthPlugin->>AuthPlugin: Validate API Key + AuthPlugin->>RateLimitPlugin: Authorized Request + + RateLimitPlugin->>RateLimitPlugin: Check Rate Limits + RateLimitPlugin->>TransformPlugin: Allowed Request + + TransformPlugin->>TransformPlugin: Modify Request + TransformPlugin->>Core: Final Request +``` + +**Plugin Execution Model:** + +```go +type PluginManager struct { + plugins []Plugin +} + +func (pm *PluginManager) ExecutePreHooks( + ctx BifrostContext, + req *BifrostRequest, +) (*BifrostRequest, *BifrostError) { + for _, plugin := range pm.plugins { + modifiedReq, err := plugin.ProcessRequest(ctx, req) + if err != nil { + return nil, err + } + req = modifiedReq + } + return req, nil +} +``` + +**Plugin Types & Performance:** + +| Plugin Type | Processing Time | Memory Impact | Failure Mode | +| --------------------- | --------------- | ------------- | ---------------------- | +| **Authentication** | ~1-5ΞΌs | Minimal | Reject request | +| **Rate Limiting** | ~500ns | Cache-based | Throttle/reject | +| **Request Transform** | ~2-10ΞΌs | Copy-on-write | Continue with original | +| **Monitoring** | ~200ns | Append-only | Continue silently | + +--- + +## Stage 4: MCP Tool Discovery & Integration + +### **Tool Discovery Process** + +```mermaid +flowchart TD + Request[Request with Model] --> MCPCheck{MCP Enabled?} + MCPCheck -->|No| SkipMCP[Skip MCP Processing] + MCPCheck -->|Yes| ClientLookup[MCP Client Lookup] + + ClientLookup --> ToolFilter[Tool Filtering] + ToolFilter --> ToolInject[Inject Tools into Request] + + ToolFilter --> IncludeCheck{Include Filter?} + ToolFilter --> ExcludeCheck{Exclude Filter?} + + IncludeCheck -->|Yes| IncludeTools[Include Specified Tools] + IncludeCheck -->|No| AllTools[Include All Tools] + + ExcludeCheck -->|Yes| RemoveTools[Remove Excluded Tools] + ExcludeCheck -->|No| KeepFiltered[Keep Filtered Tools] + + IncludeTools --> ToolInject + AllTools --> ToolInject + RemoveTools --> ToolInject + KeepFiltered --> ToolInject + + ToolInject --> EnhancedRequest[Request with Tools] + SkipMCP --> EnhancedRequest +``` + +**Tool Integration Algorithm:** + +```go +func (mcpm *MCPManager) EnhanceRequest( + ctx BifrostContext, + req *BifrostChatRequest, +) (*BifrostRequest, error) { + // Extract tool filtering from context + includeClients := ctx.GetStringSlice("mcp-include-clients") + includeTools := ctx.GetStringSlice("mcp-include-tools") + + // Get available tools + availableTools := mcpm.getAvailableTools(includeClients) + + // Filter tools + filteredTools := mcpm.filterTools(availableTools, includeTools) + + // Inject into request + if req.Params == nil { + req.Params = &ChatParameters{} + } + req.Params.Tools = append(req.Params.Tools, filteredTools...) + + return req, nil +} +``` + +**MCP Performance Impact:** + +- **Tool Discovery:** ~100-500ΞΌs (cached after first request) +- **Tool Filtering:** ~50-200ns per tool +- **Request Enhancement:** ~1-5ΞΌs depending on tool count + +--- + +## Stage 5: Memory Pool Management + +### **Object Pool Lifecycle** + +```mermaid +stateDiagram-v2 + [*] --> PoolInit: System Startup + PoolInit --> Available: Objects Pre-allocated + + Available --> Acquired: Request Processing + Acquired --> InUse: Object Populated + InUse --> Processing: Worker Processing + Processing --> Completed: Processing Done + Completed --> Reset: Object Cleanup + Reset --> Available: Return to Pool + + Available --> Expansion: Pool Exhaustion + Expansion --> Available: New Objects Created + + Reset --> GC: Pool Full + GC --> [*]: Garbage Collection +``` + +**Memory Pool Implementation:** + +```go +type MemoryPools struct { + channelPool sync.Pool + messagePool sync.Pool + responsePool sync.Pool + bufferPool sync.Pool +} + +func (mp *MemoryPools) GetChannel() *ProcessingChannel { + if ch := mp.channelPool.Get(); ch != nil { + return ch.(*ProcessingChannel) + } + return NewProcessingChannel() +} + +func (mp *MemoryPools) ReturnChannel(ch *ProcessingChannel) { + ch.Reset() // Clear previous data + mp.channelPool.Put(ch) +} +``` + +--- + +## Stage 6: Worker Pool Processing + +### **Worker Assignment & Execution** + +```mermaid +sequenceDiagram + participant Queue + participant WorkerPool + participant Worker + participant Provider + participant Circuit + + Queue->>WorkerPool: Enqueue Request + WorkerPool->>Worker: Assign Available Worker + Worker->>Circuit: Check Circuit Breaker + Circuit->>Provider: Forward Request + + Provider-->>Circuit: Response/Error + Circuit->>Circuit: Update Health Metrics + Circuit-->>Worker: Provider Response + Worker-->>WorkerPool: Release Worker + WorkerPool-->>Queue: Request Completed +``` + +**Worker Pool Architecture:** + +```go +type ProviderWorkerPool struct { + workers chan *Worker + queue chan *ProcessingJob + config WorkerPoolConfig + metrics *PoolMetrics +} + +func (pwp *ProviderWorkerPool) ProcessRequest(job *ProcessingJob) { + // Get worker from pool + worker := <-pwp.workers + + go func() { + defer func() { + // Return worker to pool + pwp.workers <- worker + }() + + // Process request + result := worker.Execute(job) + job.ResultChan <- result + }() +} +``` + +--- + +## Stage 7: Provider API Communication + +### **HTTP Request Execution** + +```mermaid +sequenceDiagram + participant Worker + participant HTTPClient + participant Provider + participant CircuitBreaker + participant Metrics + + Worker->>HTTPClient: PrepareRequest() + HTTPClient->>HTTPClient: Add Headers & Auth + HTTPClient->>CircuitBreaker: CheckHealth() + CircuitBreaker->>Provider: HTTP Request + + Provider-->>CircuitBreaker: HTTP Response + CircuitBreaker->>Metrics: Record Metrics + CircuitBreaker-->>HTTPClient: Response/Error + HTTPClient-->>Worker: Parsed Response +``` + +**Request Preparation Pipeline:** + +```go +func (w *ProviderWorker) ExecuteRequest(job *ProcessingJob) *ProviderResponse { + // Prepare HTTP request + httpReq := w.prepareHTTPRequest(job.Request) + + // Add authentication + w.addAuthentication(httpReq, job.APIKey) + + // Execute with timeout + ctx, cancel := context.WithTimeout(context.Background(), job.Timeout) + defer cancel() + + httpResp, err := w.httpClient.Do(httpReq.WithContext(ctx)) + if err != nil { + return w.handleError(err, job) + } + + // Parse response + return w.parseResponse(httpResp, job) +} +``` + +--- + +## Stage 8: Tool Execution & Response Processing + +### **MCP Tool Execution Flow** + +```mermaid +sequenceDiagram + participant Provider + participant MCPProcessor + participant MCPServer + participant ToolExecutor + participant ResponseBuilder + + Provider->>MCPProcessor: Response with Tool Calls + MCPProcessor->>MCPProcessor: Extract Tool Calls + + loop For each tool call + MCPProcessor->>MCPServer: Execute Tool + MCPServer->>ToolExecutor: Tool Invocation + ToolExecutor-->>MCPServer: Tool Result + MCPServer-->>MCPProcessor: Tool Response + end + + MCPProcessor->>ResponseBuilder: Combine Results + ResponseBuilder-->>Provider: Enhanced Response +``` + +**Tool Execution Pipeline:** + +```go +func (mcp *MCPProcessor) ProcessToolCalls( + response *ProviderResponse, +) (*ProviderResponse, error) { + toolCalls := mcp.extractToolCalls(response) + if len(toolCalls) == 0 { + return response, nil + } + + // Execute tools concurrently + results := make(chan ToolResult, len(toolCalls)) + for _, toolCall := range toolCalls { + go func(tc ToolCall) { + result := mcp.executeTool(tc) + results <- result + }(toolCall) + } + + // Collect results + toolResults := make([]ToolResult, 0, len(toolCalls)) + for i := 0; i < len(toolCalls); i++ { + toolResults = append(toolResults, <-results) + } + + // Enhance response + return mcp.enhanceResponse(response, toolResults), nil +} +``` + +--- + +## Stage 9: Post-Processing & Response Formation + +### **Plugin Post-Processing** + +```mermaid +sequenceDiagram + participant CoreResponse + participant LoggingPlugin + participant CachePlugin + participant MetricsPlugin + participant Transport + + CoreResponse->>LoggingPlugin: ProcessResponse() + LoggingPlugin->>LoggingPlugin: Log Request/Response + LoggingPlugin->>CachePlugin: Response + Logs + + CachePlugin->>CachePlugin: Cache Response + CachePlugin->>MetricsPlugin: Cached Response + + MetricsPlugin->>MetricsPlugin: Record Metrics + MetricsPlugin->>Transport: Final Response +``` + +**Response Enhancement Pipeline:** + +```go +func (pm *PluginManager) ExecutePostHooks( + ctx BifrostContext, + req *BifrostRequest, + resp *BifrostResponse, +) (*BifrostResponse, error) { + for _, plugin := range pm.plugins { + enhancedResp, err := plugin.ProcessResponse(ctx, req, resp) + if err != nil { + // Log error but continue processing + pm.logger.Warn("Plugin post-processing error", "plugin", plugin.Name(), "error", err) + continue + } + resp = enhancedResp + } + return resp, nil +} +``` + +### **Response Serialization** + +```mermaid +flowchart TD + Response[BifrostResponse] --> Format{Response Format} + Format -->|HTTP| JSONSerialize[JSON Serialization] + Format -->|SDK| DirectReturn[Direct Go Struct] + + JSONSerialize --> Compress[Compression] + DirectReturn --> TypeCheck[Type Validation] + + Compress --> Headers[Set Headers] + TypeCheck --> Return[Return Response] + + Headers --> HTTPResponse[HTTP Response] + HTTPResponse --> Client[Client Response] + Return --> Client +``` + +--- + +## Related Architecture Documentation + +- **[Concurrency Model](./concurrency)** - Worker pools and threading details +- **[Plugin System](./plugins)** - Plugin execution and lifecycle +- **[MCP System](./mcp)** - Tool discovery and execution internals +- **[Benchmarks](../../benchmarking/getting-started)** - Detailed performance analysis diff --git a/docs/architecture/framework/config-store.mdx b/docs/architecture/framework/config-store.mdx new file mode 100644 index 000000000..7c06411c8 --- /dev/null +++ b/docs/architecture/framework/config-store.mdx @@ -0,0 +1,146 @@ +--- +title: "Config Store" +description: "A persistent and flexible configuration management system for Bifrost, supporting multiple database backends." +icon: "gear" +--- + +The ConfigStore is a critical component of the Bifrost framework, providing a centralized and persistent storage solution for all gateway configurations. It abstracts the underlying database, offering a unified API for managing everything from provider settings and virtual keys to governance policies and plugin configurations. + +## Core Features + +- **Unified Configuration API**: A single interface (`ConfigStore`) for all configuration CRUD (Create, Read, Update, Delete) operations. +- **Multiple Backend Support**: Out-of-the-box support for SQLite and PostgreSQL, with an extensible architecture for adding new database backends. +- **Comprehensive Data Management**: Manages a wide range of configuration data, including: + - Provider and key settings + - Virtual keys and governance rules (budgets, rate limits) + - Customer and team information for multi-tenancy + - Plugin configurations + - Vector store and log store settings + - Model pricing information +- **Transactional Operations**: Ensures data consistency by supporting atomic transactions for complex configuration changes. +- **Database Migrations**: Integrated migration system to manage schema evolution across different versions of Bifrost. +- **Environment Variable Handling**: Securely manages sensitive data like API keys by storing references to environment variables instead of raw values. + +## Architecture + +The ConfigStore is designed around the `ConfigStore` interface, which defines all the methods for interacting with the configuration data. The primary implementation is `RDBConfigStore`, which uses [GORM](https://gorm.io/) as an ORM to communicate with relational databases. + +### Supported Backends + +- **SQLite**: The default, file-based database, perfect for local development, testing, and single-node deployments. It requires no external services. +- **PostgreSQL**: A robust, production-grade database suitable for large-scale, high-availability deployments. + +The backend is selected and configured in Bifrost's main configuration file. + +### Initialization + +The ConfigStore is initialized at startup based on the provided configuration. + +```go +import ( + "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/core/schemas" +) + +// Example: Initialize a SQLite-based ConfigStore +config := &configstore.Config{ + Enabled: true, + Type: configstore.ConfigStoreTypeSQLite, + Config: &configstore.SQLiteConfig{ + File: "/path/to/config.db", + }, +} + +var logger schemas.Logger // Assume logger is initialized +store, err := configstore.NewConfigStore(context.Background(), config, logger) +if err != nil { + // Handle error +} +``` + +Here is an example for initializing a PostgreSQL-based `ConfigStore`: +```go +// Example: Initialize a PostgreSQL-based ConfigStore +pgConfig := &configstore.Config{ + Enabled: true, + Type: configstore.ConfigStoreTypePostgres, + Config: &configstore.PostgresConfig{ + Host: "localhost", + Port: "5432", + User: "postgres", + Password: "secret", + DBName: "bifrost", + SSLMode: "disable", + }, +} + +store, err = configstore.NewConfigStore(context.Background(), pgConfig, logger) +if err != nil { + // Handle error +} +``` + +## Data Models + +The ConfigStore manages a variety of data models, which are defined as GORM tables in the `framework/configstore/tables` directory. Some of the key models include: + +- `TableVirtualKey`: Represents a virtual key with its associated governance rules, keys, and metadata. +- `TableProvider` & `TableKey`: Store provider-specific configurations and the physical API keys. +- `TableBudget` & `TableRateLimit`: Define spending limits and request rate limits for governance. +- `TableCustomer` & `TableTeam`: Enable multi-tenant configurations. +- `TableModelPricing`: Caches model pricing information for cost calculation. +- `TablePlugin`: Stores configuration for loaded plugins. + +## Usage + +The `ConfigStore` interface provides a rich set of methods for managing Bifrost's configuration. + +### Managing Virtual Keys + +```go +// Create a new virtual key +newKey := &tables.TableVirtualKey{ + ID: "vk-12345", + Name: "My Test Key", + // ... other fields +} +err := store.CreateVirtualKey(ctx, newKey) + +// Retrieve a virtual key +virtualKey, err := store.GetVirtualKey(ctx, "vk-12345") +``` + +### Managing Providers + +```go +// Get all provider configurations +providers, err := store.GetProvidersConfig(ctx) + +// Update a specific provider +providerConfig := providers[schemas.OpenAI] +providerConfig.NetworkConfig.TimeoutSeconds = 120 +err = store.UpdateProvider(ctx, schemas.OpenAI, providerConfig, envKeys) +``` + +### Executing Transactions + +For operations that require multiple database writes, you can use a transaction to ensure atomicity. + +```go +err := store.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Perform multiple operations within this transaction + if err := store.CreateBudget(ctx, budget1, tx); err != nil { + return err // Rollback + } + if err := store.UpdateRateLimit(ctx, limit1, tx); err != nil { + return err // Rollback + } + return nil // Commit +}) +``` + +## Migrations + +The ConfigStore includes a migration system to handle database schema changes between Bifrost versions. Migrations are automatically applied at startup, ensuring the database schema is always up-to-date. This process is managed by the `migrator` package and is transparent to the user. + +The ConfigStore is a powerful and flexible component that provides the backbone for Bifrost's dynamic configuration capabilities. Its support for multiple backends and transactional operations makes it suitable for both small-scale and large-scale, production environments. diff --git a/docs/architecture/framework/log-store.mdx b/docs/architecture/framework/log-store.mdx new file mode 100644 index 000000000..b7a8e78b3 --- /dev/null +++ b/docs/architecture/framework/log-store.mdx @@ -0,0 +1,161 @@ +--- +title: "Log Store" +description: "A robust and queryable system for persisting API request and response logs, with support for multiple database backends." +icon: "clipboard-list" +--- + +The LogStore is a core component of the Bifrost framework responsible for capturing, storing, and retrieving detailed logs of API requests and responses. It provides a persistent, queryable audit trail of all activity passing through the gateway, which is essential for debugging, monitoring, analytics, and compliance. + +## Core Features + +- **Persistent Logging**: Automatically saves detailed information about each API request, including input, output, status, latency, and cost. +- **Multiple Backend Support**: Comes with built-in support for SQLite and PostgreSQL, allowing you to choose the best storage solution for your deployment needs. +- **Rich Querying and Filtering**: A powerful search API allows you to filter and sort logs based on a wide range of criteria such as provider, model, status, latency, cost, and content. +- **Performance Analytics**: The search functionality also provides aggregated statistics, including total requests, success rate, average latency, total tokens, and total cost for the queried data. +- **Structured Data Model**: Logs are stored in a structured format, with complex objects like message history and tool calls serialized as JSON for efficient storage and retrieval. +- **Automatic Data Management**: Includes GORM hooks to automatically handle JSON serialization/deserialization and to build a searchable content summary. + +## Architecture + +The LogStore is built around the `LogStore` interface, which defines the standard methods for interacting with the log database. The primary implementation, `RDBLogStore`, uses GORM to provide an abstraction over relational databases. + +### Supported Backends + +- **SQLite**: The default, file-based database, ideal for local development and smaller, single-node deployments. +- **PostgreSQL**: A production-ready database for scalable and high-availability deployments. + +The backend is configured in Bifrost's main configuration file. + +### Initialization + +The LogStore is initialized at startup based on the provided configuration. + +```go +import ( + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/core/schemas" +) + +// Example: Initialize a SQLite-based LogStore +config := &logstore.Config{ + Enabled: true, + Type: logstore.LogStoreTypeSQLite, + Config: &logstore.SQLiteConfig{ + File: "/path/to/logs.db", + }, +} + +var logger schemas.Logger // Assume logger is initialized +store, err := logstore.NewLogStore(context.Background(), config, logger) +if err != nil { + // Handle error +} +``` + +Here is an example for initializing a PostgreSQL-based `LogStore`: +```go +// Example: Initialize a PostgreSQL-based LogStore +pgConfig := &logstore.Config{ + Enabled: true, + Type: logstore.LogStoreTypePostgres, + Config: &logstore.PostgresConfig{ + Host: "localhost", + Port: "5432", + User: "postgres", + Password: "secret", + DBName: "bifrost_logs", + SSLMode: "disable", + }, +} + +store, err = logstore.NewLogStore(context.Background(), pgConfig, logger) +if err != nil { + // Handle error +} +``` + +## Data Model + +The core of the LogStore is the `Log` struct, which represents a single log entry in the `logs` table. + +```go +// Log represents a complete log entry for a request/response cycle +type Log struct { + ID string `gorm:"primaryKey;type:varchar(255)"` + Timestamp time.Time `gorm:"index;not null"` + Object string `gorm:"type:varchar(255);index;not null;column:object_type"` + Provider string `gorm:"type:varchar(255);index;not null"` + Model string `gorm:"type:varchar(255);index;not null"` + Latency *float64 + Cost *float64 `gorm:"index"` + Status string `gorm:"type:varchar(50);index;not null"` // "processing", "success", or "error" + Stream bool `gorm:"default:false"` + + // Denormalized token fields for easier querying + PromptTokens int `gorm:"default:0"` + CompletionTokens int `gorm:"default:0"` + TotalTokens int `gorm:"default:0"` + + // JSON serialized fields + InputHistory string `gorm:"type:text"` + OutputMessage string `gorm:"type:text"` + TokenUsage string `gorm:"type:text"` + ErrorDetails string `gorm:"type:text"` + // ... and many more for different data types +} +``` +Complex data like message arrays and tool calls are serialized into JSON strings for storage and are automatically deserialized back into their struct forms when retrieved. + +## Usage + +### Creating Log Entries + +A log entry is created by populating a `Log` struct and passing it to the `Create` method. This is typically handled internally by Bifrost's logging plugins. + +```go +logEntry := &logstore.Log{ + ID: "req-xyz123", + Timestamp: time.Now(), + Provider: "openai", + Model: "gpt-4", + Status: "success", + // ... other fields +} +err := store.Create(ctx, logEntry) +``` + +### Searching and Filtering Logs + +The `SearchLogs` method provides a powerful way to query logs with fine-grained filters and pagination. + +```go +// Define search criteria +filters := logstore.SearchFilters{ + Providers: []string{"openai", "anthropic"}, + Status: []string{"error"}, + StartTime: &startTime, // time.Time pointer +} + +pagination := logstore.PaginationOptions{ + Limit: 50, + Offset: 0, + SortBy: "timestamp", + Order: "desc", +} + +// Execute the search +results, err := store.SearchLogs(ctx, filters, pagination) +if err != nil { + // Handle error +} + +// Process the results +for _, log := range results.Logs { + fmt.Printf("Found log: %s\n", log.ID) +} + +// Access aggregated stats +fmt.Printf("Total errors: %d\n", results.Stats.TotalRequests) +``` + +The LogStore is an indispensable tool for observability in Bifrost, providing the detailed audit trail needed to monitor, debug, and analyze AI application performance and behavior effectively. diff --git a/docs/architecture/framework/model-catalog.mdx b/docs/architecture/framework/model-catalog.mdx new file mode 100644 index 000000000..f7748289e --- /dev/null +++ b/docs/architecture/framework/model-catalog.mdx @@ -0,0 +1,286 @@ +--- +title: "Model Catalog" +description: "A centralized system for managing model information, pricing, and capabilities across all supported AI providers." +icon: "book-open" +--- + +The Model Catalog is a foundational component of Bifrost that provides a unified interface for managing AI models, including their pricing, capabilities, and availability. It serves as a centralized repository for all model-related information, enabling dynamic cost calculation, intelligent model routing, and efficient resource management. + +## Core Features + +### **1. Automatic Pricing Synchronization** +The Model Catalog manages pricing data through a two-phase approach: + +**Startup Behavior:** +- **With ConfigStore**: Downloads a pricing sheet from Maxim's datasheet, persists it to the config store, and then loads it into memory for fast lookups. +- **Without ConfigStore**: Downloads the pricing sheet directly into memory on every startup. + +**Ongoing Synchronization:** +- When ConfigStore is available, an automatic sync occurs every 24 hours to keep pricing data current. +- All pricing data is cached in memory for O(1) lookup performance during cost calculations. + +This ensures that cost calculations always use the latest pricing information from AI providers while maintaining optimal performance. + +### **2. Multi-Modal Cost Calculation** +It supports diverse pricing models across different AI operation types: +- **Text Operations**: Token-based and character-based pricing for chat completions, text completions, and embeddings. +- **Audio Processing**: Token-based and duration-based pricing for speech synthesis and transcription. +- **Image Processing**: Per-image costs with tiered pricing for high-token contexts. + +### **3. Model Information Management** +The Model Catalog maintains a pool of available models for each provider, populated from the pricing data. This allows for: +- Listing all available models for a given provider. +- Finding all providers that support a specific model. + +### **4. Intelligent Cache Cost Handling** +It integrates with semantic caching to provide accurate cost calculations: +- **Cache Hits**: Zero cost for direct cache hits, and embedding cost only for semantic matches. +- **Cache Misses**: Combined cost of the base model usage plus the embedding generation cost for cache storage. + +### **5. Tiered Pricing Support** +The system automatically applies different pricing rates for high-token contexts (e.g., above 128k tokens), reflecting real provider pricing models for various modalities. + +## Configuration + +The `ModelCatalog` can be configured during initialization by passing a `Config` struct. + +```go +type Config struct { + PricingURL *string `json:"pricing_url,omitempty"` + PricingSyncInterval *time.Duration `json:"pricing_sync_interval,omitempty"` +} +``` + +- **`PricingURL`**: Overrides the default URL (`https://getbifrost.ai/datasheet`) for downloading the pricing sheet. +- **`PricingSyncInterval`**: Customizes the interval for periodic pricing data synchronization. The default is 24 hours. + +This configuration is passed during the initialization of the `ModelCatalog`: + +```go +config := &modelcatalog.Config{ + PricingURL: "https://my-custom-url.com/pricing.json", +} +modelCatalog, err := modelcatalog.Init(context.Background(), config, configStore, logger) +``` + +## Architecture + +### ModelCatalog +The `ModelCatalog` is the central component that handles all model and pricing operations: + +```go +type ModelCatalog struct { + configStore configstore.ConfigStore + logger schemas.Logger + + pricingURL string + pricingSyncInterval time.Duration + + // In-memory cache for fast access + pricingData map[string]configstoreTables.TableModelPricing + mu sync.RWMutex + + modelPool map[schemas.ModelProvider][]string + + // Background sync worker + syncTicker *time.Ticker + done chan struct{} + wg sync.WaitGroup + syncCtx context.Context + syncCancel context.CancelFunc +} +``` + +### Pricing Data Structure +Each model's pricing information includes comprehensive cost metrics, supporting various modalities and tiered pricing: + +```go +// PricingEntry represents a single model's pricing information +type PricingEntry struct { + // Basic pricing + InputCostPerToken float64 `json:"input_cost_per_token"` + OutputCostPerToken float64 `json:"output_cost_per_token"` + Provider string `json:"provider"` + Mode string `json:"mode"` + + // Additional pricing for media + InputCostPerImage *float64 `json:"input_cost_per_image,omitempty"` + InputCostPerVideoPerSecond *float64 `json:"input_cost_per_video_per_second,omitempty"` + InputCostPerAudioPerSecond *float64 `json:"input_cost_per_audio_per_second,omitempty"` + + // Character-based pricing + InputCostPerCharacter *float64 `json:"input_cost_per_character,omitempty"` + OutputCostPerCharacter *float64 `json:"output_cost_per_character,omitempty"` + + // Pricing above 128k tokens + InputCostPerTokenAbove128kTokens *float64 `json:"input_cost_per_token_above_128k_tokens,omitempty"` + InputCostPerCharacterAbove128kTokens *float64 `json:"input_cost_per_character_above_128k_tokens,omitempty"` + InputCostPerImageAbove128kTokens *float64 `json:"input_cost_per_image_above_128k_tokens,omitempty"` + InputCostPerVideoPerSecondAbove128kTokens *float64 `json:"input_cost_per_video_per_second_above_128k_tokens,omitempty"` + InputCostPerAudioPerSecondAbove128kTokens *float64 `json:"input_cost_per_audio_per_second_above_128k_tokens,omitempty"` + OutputCostPerTokenAbove128kTokens *float64 `json:"output_cost_per_token_above_128k_tokens,omitempty"` + OutputCostPerCharacterAbove128kTokens *float64 `json:"output_cost_per_character_above_128k_tokens,omitempty"` + + // Cache and batch pricing + CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost,omitempty"` + InputCostPerTokenBatches *float64 `json:"input_cost_per_token_batches,omitempty"` + OutputCostPerTokenBatches *float64 `json:"output_cost_per_token_batches,omitempty"` +} +``` + +## Usage in Plugins + +### Initialization +In Bifrost's gateway, the `ModelCatalog` is initialized once at the start and shared across all plugins: + +```go +import "github.com/maximhq/bifrost/framework/modelcatalog" + +// Initialize model catalog with config store and logger +modelCatalog, err := modelcatalog.Init(context.Background(), &modelcatalog.Config{}, configStore, logger) +if err != nil { + return fmt.Errorf("failed to initialize model catalog: %w", err) +} +``` + +### Basic Cost Calculation +Calculate costs from a Bifrost response: + +```go +// Calculate cost for a completed request +cost := modelCatalog.CalculateCost( + result, // *schemas.BifrostResponse +) + +logger.Info("Request cost: $%.6f", cost) +``` + +### Advanced Cost Calculation with Usage Details +For more granular cost calculation with custom usage data: + +```go +// Custom usage calculation +usage := &schemas.BifrostLLMUsage{ + PromptTokens: 1500, + CompletionTokens: 800, + TotalTokens: 2300, +} + +cost := modelCatalog.CalculateCostFromUsage( + "openai", // provider + "gpt-4", // model + usage, // usage data + schemas.ChatCompletionRequest, // request type + false, // is cache read + false, // is batch + nil, // audio seconds (for audio models) + nil, // audio token details +) +``` + +### Cache Aware Cost Calculation +For workflows that implement semantic caching, use cache-aware cost calculation: + +```go +// This automatically handles cache hits/misses and embedding costs +cost := modelCatalog.CalculateCostWithCacheDebug( + result, // *schemas.BifrostResponse with cache debug info +) + +// Cache hits return 0 for direct hits, embedding cost for semantic matches +// Cache misses return base model cost + embedding generation cost +``` + +### Model Discovery +The `ModelCatalog` provides several methods to query for model and provider information. + +#### Get Models for a Provider +Retrieve a list of all models supported by a specific provider. +```go +openaiModels := modelCatalog.GetModelsForProvider(schemas.OpenAI) +for _, model := range openaiModels { + logger.Info("Found OpenAI model: %s", model) +} +``` + +#### Get Providers for a Model +Find all providers that offer a specific model. +```go +gpt4Providers := modelCatalog.GetProvidersForModel("gpt-4") +for _, provider := range gpt4Providers { + logger.Info("gpt-4 is available from: %s", provider) +} +``` + +#### Dynamically Add Models +You can dynamically add models to the catalog's pool from a `v1/models` compatible response structure. This is useful for providers that expose a model list endpoint. +```go +// response is *schemas.BifrostListModelsResponse +modelCatalog.AddModelDataToPool(response) +``` +This is automatically done in Bifrost gateway initialization for all providers that are supported by Bifrost. +### Reloading Configuration +You can reload the pricing configuration at runtime if you need to change the pricing URL or sync interval. +```go +newConfig := &modelcatalog.Config{ + PricingSyncInterval: 12 * time.Hour, +} +err := modelCatalog.ReloadPricing(ctx, newConfig) +``` + +## Error Handling and Fallbacks + +The Model Catalog handles missing pricing data gracefully with intelligent fallbacks: + +```go +// getPricing returns pricing information for a model (thread-safe) +func (mc *ModelCatalog) getPricing(model, provider string, requestType schemas.RequestType) (*configstoreTables.TableModelPricing, bool) { + mc.mu.RLock() + defer mc.mu.RUnlock() + + pricing, ok := mc.pricingData[makeKey(model, provider, normalizeRequestType(requestType))] + if !ok { + // Example fallback: if a gemini model is not found, try looking it up under the vertex provider + if provider == string(schemas.Gemini) { + mc.logger.Debug("primary lookup failed, trying vertex provider for the same model") + pricing, ok = mc.pricingData[makeKey(model, "vertex", normalizeRequestType(requestType))] + if ok { + return &pricing, true + } + } + return nil, false + } + return &pricing, true +} + +// When pricing is not found, CalculateCost returns 0.0 and logs a warning +// This ensures operations continue smoothly without billing failures +``` + + +## Cleanup and Lifecycle Management + +Properly clean up resources when shutting down: + +```go +// Cleanup model catalog resources +defer func() { + if err := modelCatalog.Cleanup(); err != nil { + logger.Error("Failed to cleanup model catalog: %v", err) + } +}() +``` + +## Thread Safety + +All `ModelCatalog` operations are thread-safe, making it suitable for concurrent usage across multiple plugins and goroutines. The internal pricing data cache uses read-write mutexes for optimal performance during frequent lookups. + +## Best Practices + +1. **Shared Instance**: Use a single `ModelCatalog` instance across all plugins to avoid redundant data synchronization. +2. **Error Handling**: Always handle the case where pricing returns 0.0 due to missing model data. +3. **Logging**: Monitor pricing sync failures and missing model warnings in production. +4. **Cache Awareness**: Use `CalculateCostWithCacheDebug` when implementing caching features. +5. **Resource Cleanup**: Always call `Cleanup()` during application shutdown to prevent resource leaks. + +The Model Catalog provides a robust, production-ready foundation for implementing billing, budgeting, and cost monitoring features in Bifrost plugins. diff --git a/docs/architecture/framework/streaming.mdx b/docs/architecture/framework/streaming.mdx new file mode 100644 index 000000000..10c434f59 --- /dev/null +++ b/docs/architecture/framework/streaming.mdx @@ -0,0 +1,130 @@ +--- +title: "Streaming" +description: "Framework utility for aggregating and processing real-time stream chunks from AI providers" +icon: "water" +--- + +## Overview + +The **Streaming** package (`framework/streaming`) is a core utility within Bifrost designed to handle real-time data streams from AI providers. It provides a robust and efficient mechanism for plugins like [Logging](/docs/features/observability/default), [OTel](/docs/features/observability/otel), and [Maxim](/docs/features/observability/maxim) to process, aggregate, and format streaming responses for chat completions, transcriptions, and other real-time AI interactions. + +```mermaid +sequenceDiagram + participant Plugin + participant BC as Bifrost Core + participant Accumulator + + BC->>Plugin: PreHook(StreamingRequest) + activate Plugin + Plugin->>Accumulator: CreateStreamAccumulator(requestID) + activate Accumulator + Accumulator-->>Plugin: ack + deactivate Accumulator + Plugin-->>BC: return + deactivate Plugin + + loop For each response chunk + BC->>Plugin: PostHook(StreamChunk) + activate Plugin + Plugin->>Accumulator: ProcessStreamingResponse(StreamChunk) + activate Accumulator + alt Is NOT Final Chunk + Accumulator-->>Plugin: return {Type: Delta} + else Is Final Chunk + Accumulator->>Accumulator: buildCompleteResponse() + Accumulator-->>Plugin: return {Type: Final, CompleteData} + end + deactivate Accumulator + Plugin-->>BC: return + deactivate Plugin + end + +``` + +Its primary purpose is to simplify the complexity of handling chunked data, ensuring that plugins can work with complete, well-structured responses without needing to implement their own aggregation logic. + + +## How It Works + +The streaming package uses an `Accumulator` to manage the lifecycle of a streaming operation. This process is designed to be highly efficient, using `sync.Pool` to reuse objects and minimize memory allocations. + +1. **Initialization**: When a plugin that needs to process streams (like `logging` or `otel`) is initialized, it creates a new `streaming.Accumulator`. + +2. **Stream Start**: In the `PreHook` phase of a request, if the request is identified as a streaming type, the plugin calls `accumulator.CreateStreamAccumulator(requestID, timestamp)` to prepare a dedicated buffer for the incoming chunks of that request. + +3. **Chunk Processing**: In the `PostHook` phase, as each chunk of the streaming response arrives, the plugin passes it to `accumulator.ProcessStreamingResponse()`. + * For each `delta` chunk, the accumulator appends it to the buffer associated with the request ID. + * The accumulator handles different types of streams, including chat, audio, and transcriptions, using specialized logic to correctly piece together the data. For example, it accumulates text deltas, tool call argument deltas, and other parts of the message. + +4. **Finalization**: When the final chunk of the stream is received (indicated by a `finish_reason` or other provider-specific signal), `ProcessStreamingResponse` performs the final assembly. + * It reconstructs the complete `ChatMessage` or other response object from all the stored chunks. + * It calculates total token usage, cost, and latency. + * It returns a `ProcessedStreamResponse` object with `StreamResponseTypeFinal` and the complete, structured `AccumulatedData`. + +5. **Cleanup**: Once the final response is processed, the accumulator cleans up all buffered chunks for that request ID, returning them to the `sync.Pool` for reuse. + +## Key Components + +### `Accumulator` + +The central component of the package. It is a thread-safe manager that: +- Tracks stream chunks for multiple concurrent requests using a `sync.Map`. +- Uses `sync.Pool` to recycle `*StreamChunk` objects, reducing garbage collection overhead. +- Provides methods to add chunks (`addChatStreamChunk`, `addAudioStreamChunk`, etc.). +- Includes a periodic cleanup worker to remove stale accumulators for incomplete or orphaned requests. + +### `ProcessStreamingResponse` + +This is the main entry point for plugins to process stream data. It inspects the response type and delegates to the appropriate handler: +- `processChatStreamingResponse` +- `processAudioStreamingResponse` +- `processTranscriptionStreamingResponse` +- `processResponsesStreamingResponse` + +It returns a `ProcessedStreamResponse`, which indicates whether the chunk is a `delta` or the `final` aggregated response. + +### Stream-Specific Builders + +The package includes internal logic to correctly build complete messages from chunks. For example, `buildCompleteMessageFromChatStreamChunks` iterates through the collected `ChatStreamChunk` objects, appending content deltas and assembling tool calls into a final, coherent `schemas.ChatMessage`. + +## Usage Example + +The following snippet from the `logging` plugin shows how the `streaming` package is used in practice within a plugin's `PostHook`. + +```go +// In plugins/logging/main.go + +func (p *LoggerPlugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + // ... setup, get requestID ... + + go func() { + // ... + if bifrost.IsStreamRequestType(requestType) { + p.logger.Debug("[logging] processing streaming response") + + // 1. Pass the response chunk to the accumulator + streamResponse, err := p.accumulator.ProcessStreamingResponse(ctx, result, bifrostErr) + if err != nil { + p.logger.Error("failed to process streaming response: %v", err) + // 2. Check if this is the final, aggregated response + } else if streamResponse != nil && streamResponse.Type == streaming.StreamResponseTypeFinal { + // Prepare final log data + logMsg.Operation = LogOperationStreamUpdate + logMsg.StreamResponse = streamResponse + + // 3. Update the log entry with the complete data + processingErr := retryOnNotFound(p.ctx, func() error { + return p.updateStreamingLogEntry(p.ctx, logMsg.RequestID, logMsg.SemanticCacheDebug, logMsg.StreamResponse, true) + }) + + // ... handle errors and callbacks ... + } + } + // ... handle non-streaming responses ... + }() + + return result, bifrostErr, nil +} +``` + +This demonstrates how a plugin can remain agnostic to the details of stream aggregation and simply react to the final, complete data returned by the `streaming` package. This greatly simplifies plugin development and ensures consistent data handling across the framework. diff --git a/docs/architecture/framework/vector-store.mdx b/docs/architecture/framework/vector-store.mdx new file mode 100644 index 000000000..c268e1487 --- /dev/null +++ b/docs/architecture/framework/vector-store.mdx @@ -0,0 +1,506 @@ +--- +title: "Vector Store" +description: "Vector database implementations for semantic search, embeddings storage, and AI-powered features in Bifrost." +icon: "diagram-project" +--- + +## Overview + +The VectorStore is a core component of Bifrost's framework package that provides a unified interface for vector database operations. It enables plugins to store embeddings, perform similarity searches, and build AI-powered features like semantic caching, content recommendations, and knowledge retrieval. + +**Key Capabilities:** +- **Vector Similarity Search**: Find semantically similar content using embeddings +- **Namespace Management**: Organize data into separate collections with custom schemas +- **Flexible Filtering**: Query data with complex filters and pagination +- **Multiple Backends**: Support for Weaviate and Redis vector stores +- **High Performance**: Optimized for production workloads +- **Scalable Storage**: Handle millions of vectors with efficient indexing + +## Supported Vector Stores + +Bifrost currently supports two vector store implementations: + +- **[Weaviate](#weaviate)**: Production-ready vector database with gRPC support and advanced querying +- **[Redis](#redis)**: High-performance in-memory vector store using RediSearch + +## VectorStore Interface Usage + +### Creating Namespaces +Create collections (namespaces) with custom schemas: + +```go +// Define properties for your data +properties := map[string]vectorstore.VectorStoreProperties{ + "content": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The main content text", + }, + "category": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "Content category", + }, + "tags": { + DataType: vectorstore.VectorStorePropertyTypeStringArray, + Description: "Content tags", + }, +} + +// Create namespace +err := store.CreateNamespace(ctx, "my_content", 1536, properties) +if err != nil { + log.Fatal("Failed to create namespace:", err) +} +``` + +### Storing Data with Embeddings +Add data with vector embeddings for similarity search: + +```go +// Your embedding data (typically from an embedding model) +embedding := []float32{0.1, 0.2, 0.3 } // example 3-dimensional vector + +// Metadata associated with this vector +metadata := map[string]interface{}{ + "content": "This is my content text", + "category": "documentation", + "tags": []string{"guide", "tutorial"}, +} + +// Store in vector database +err := store.Add(ctx, "my_content", "unique-id-123", embedding, metadata) +if err != nil { + log.Fatal("Failed to add data:", err) +} +``` + +### Similarity Search +Find similar content using vector similarity: + +```go +// Query embedding (from user query) +queryEmbedding := []float32{0.15, 0.25, 0.35, ...} + +// Optional filters +filters := []vectorstore.Query{ + { + Field: "category", + Operator: vectorstore.QueryOperatorEqual, + Value: "documentation", + }, +} + +// Perform similarity search +results, err := store.GetNearest( + ctx, + "my_content", // namespace + queryEmbedding, // query vector + filters, // optional filters + []string{"content", "category"}, // fields to return + 0.7, // similarity threshold (0-1) + 10, // limit +) + +for _, result := range results { + fmt.Printf("Score: %.3f, Content: %s\n", *result.Score, result.Properties["content"]) +} +``` + +### Data Retrieval and Management +Query and manage stored data: + +```go +// Get specific item by ID +item, err := store.GetChunk(ctx, "my_content", "unique-id-123") +if err != nil { + log.Fatal("Failed to get item:", err) +} + +// Get all items with filtering and pagination +allResults, cursor, err := store.GetAll( + ctx, + "my_content", + []vectorstore.Query{ + {Field: "category", Operator: vectorstore.QueryOperatorEqual, Value: "documentation"}, + }, + []string{"content", "tags"}, // select fields + nil, // cursor for pagination + 50, // limit +) + +// Delete items +err = store.Delete(ctx, "my_content", "unique-id-123") +``` + +## Weaviate + +Weaviate is a production-ready vector database solution that provides advanced querying capabilities, gRPC support for high performance, and flexible schema management for production deployments. + +### Key Features + +- **gRPC Support**: Enhanced performance with gRPC connections +- **Advanced Filtering**: Complex query operations with multiple conditions +- **Schema Management**: Flexible schema definition for different data types +- **Cloud & Self-Hosted**: Support for both Weaviate Cloud and self-hosted deployments +- **Scalable Storage**: Handle millions of vectors with efficient indexing + +### Setup & Installation + +**Weaviate Cloud:** +- Sign up at [cloud.weaviate.io](https://cloud.weaviate.io) +- Create a new cluster +- Get your API key and cluster URL + +**Local Weaviate:** +```bash +# Using Docker +docker run -d \ + --name weaviate \ + -p 8080:8080 \ + -e QUERY_DEFAULTS_LIMIT=25 \ + -e AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED='true' \ + -e PERSISTENCE_DATA_PATH='/var/lib/weaviate' \ + semitechnologies/weaviate:latest +``` + +### Configuration Options + + + + + +```go +// Configure Weaviate vector store +vectorConfig := &vectorstore.Config{ + Enabled: true, + Type: vectorstore.VectorStoreTypeWeaviate, + Config: vectorstore.WeaviateConfig{ + Scheme: "http", // "http" for local, "https" for cloud + Host: "localhost:8080", // Your Weaviate host + APIKey: "your-weaviate-api-key", // Required for Weaviate Cloud; optional for local/self-hosted + + // Enable gRPC for improved performance (optional) + GrpcConfig: &vectorstore.WeaviateGrpcConfig{ + Host: "localhost:50051", // gRPC port + Secured: false, // true for TLS + }, + }, +} + +// Create vector store +store, err := vectorstore.NewVectorStore(context.Background(), vectorConfig, logger) +if err != nil { + log.Fatal("Failed to create vector store:", err) +} +``` + + + + + +**Local Setup:** +```json +{ + "vector_store": { + "enabled": true, + "type": "weaviate", + "config": { + "scheme": "http", + "host": "localhost:8080" + } + } +} +``` + +**Cloud Setup with gRPC:** +```json +{ + "vector_store": { + "enabled": true, + "type": "weaviate", + "config": { + "scheme": "https", + "host": "your-weaviate-host", + "api_key": "your-weaviate-api-key", + "grpc_config": { + "host": "your-weaviate-grpc-host", + "secured": true + } + } + } +} +``` + + + + + + +gRPC host should include the port. If no port is specified, port 80 is used for insecured connections and port 443 for secured connections. + + +### Advanced Features + +**gRPC Performance Optimization:** +Enable gRPC for better performance in production: + +```go +vectorConfig := &vectorstore.Config{ + Type: vectorstore.VectorStoreTypeWeaviate, + Config: vectorstore.WeaviateConfig{ + Scheme: "https", + Host: "your-weaviate-host", + APIKey: "your-api-key", + + // Enable gRPC for better performance + GrpcConfig: &vectorstore.WeaviateGrpcConfig{ + Host: "your-weaviate-grpc-host:443", + Secured: true, + }, + }, +} +``` + +### Production Considerations + + +**Performance**: For production environments, consider using gRPC configuration for better performance and enable appropriate authentication mechanisms for your Weaviate deployment. + + + +**Authentication**: Always use API keys for Weaviate Cloud deployments and configure proper authentication for self-hosted instances in production. + + +--- + +## Redis + +Redis provides high-performance in-memory vector storage using RediSearch, ideal for applications requiring sub-millisecond response times and fast semantic search capabilities. + +### Key Features + +- **High Performance**: Sub-millisecond cache retrieval with Redis's in-memory storage +- **Cost Effective**: Open-source solution with no licensing costs +- **HNSW Algorithm**: Fast vector similarity search with excellent recall rates +- **Connection Pooling**: Advanced connection management for high-throughput applications +- **TTL Support**: Automatic expiration of cached entries +- **Streaming Support**: Full streaming response caching with proper chunk ordering +- **Flexible Filtering**: Advanced metadata filtering with exact string matching + +### Setup & Installation + +**Redis Cloud:** +- Sign up at [cloud.redis.io](https://cloud.redis.io) +- Create a new database with RediSearch module enabled +- Get your connection details + +**Local Redis with RediSearch:** +```bash +# Using Docker with Redis Stack (includes RediSearch) +docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest +``` + +### Configuration Options + + + + + +```go +// Configure Redis vector store +vectorConfig := &vectorstore.Config{ + Enabled: true, + Type: vectorstore.VectorStoreTypeRedis, + Config: vectorstore.RedisConfig{ + Addr: "localhost:6379", // Redis server address - REQUIRED + Username: "", // Optional: Redis username + Password: "", // Optional: Redis password + DB: 0, // Optional: Redis database number (default: 0) + + // Optional: Connection pool settings + PoolSize: 10, // Maximum socket connections + MaxActiveConns: 10, // Maximum active connections + MinIdleConns: 5, // Minimum idle connections + MaxIdleConns: 10, // Maximum idle connections + + // Optional: Timeout settings + DialTimeout: 5 * time.Second, // Connection timeout + ReadTimeout: 3 * time.Second, // Read timeout + WriteTimeout: 3 * time.Second, // Write timeout + ContextTimeout: 10 * time.Second, // Operation timeout + }, +} + +// Create vector store +store, err := vectorstore.NewVectorStore(context.Background(), vectorConfig, logger) +if err != nil { + log.Fatal("Failed to create vector store:", err) +} +``` + + + + + +```json +{ + "vector_store": { + "enabled": true, + "type": "redis", + "config": { + "addr": "localhost:6379", + "username": "", + "password": "", + "db": 0, + "pool_size": 10, + "max_active_conns": 10, + "min_idle_conns": 5, + "max_idle_conns": 10, + "dial_timeout": "5s", + "read_timeout": "3s", + "write_timeout": "3s", + "context_timeout": "10s" + } + } +} +``` + +**For Redis Cloud:** +```json +{ + "vector_store": { + "enabled": true, + "type": "redis", + "config": { + "addr": "your-redis-host:port", + "username": "your-username", + "password": "your-password", + "db": 0, + "context_timeout": "10s" + } + } +} +``` + + + + + +### Redis-Specific Features + +**Vector Search Algorithm:** +Redis uses the **HNSW (Hierarchical Navigable Small World)** algorithm for vector similarity search, which provides: + +- **Fast Search**: O(log N) search complexity +- **High Accuracy**: Excellent recall rates for similarity search +- **Memory Efficient**: Optimized for in-memory operations +- **Cosine Similarity**: Uses cosine distance metric for semantic similarity + +**Connection Pool Management:** +Redis provides extensive connection pool configuration: + +```go +config := vectorstore.RedisConfig{ + Addr: "localhost:6379", + PoolSize: 20, // Max socket connections + MaxActiveConns: 20, // Max active connections + MinIdleConns: 5, // Min idle connections + MaxIdleConns: 10, // Max idle connections + ConnMaxLifetime: 30 * time.Minute, // Connection lifetime + ConnMaxIdleTime: 5 * time.Minute, // Idle connection timeout + DialTimeout: 5 * time.Second, // Connection timeout + ReadTimeout: 3 * time.Second, // Read timeout + WriteTimeout: 3 * time.Second, // Write timeout + ContextTimeout: 10 * time.Second, // Operation timeout +} +``` + +### Performance Optimization + +**Connection Pool Tuning:** +For high-throughput applications, tune the connection pool settings: + +```json +{ + "vector_store": { + "config": { + "pool_size": 50, // Increase for high concurrency + "max_active_conns": 50, // Match pool_size + "min_idle_conns": 10, // Keep connections warm + "max_idle_conns": 20, // Allow some idle connections + "conn_max_lifetime": "1h", // Refresh connections periodically + "conn_max_idle_time": "10m" // Close idle connections + } + } +} +``` + +**Memory Optimization:** +- **TTL**: Use appropriate TTL values to prevent memory bloat +- **Namespace Cleanup**: Regularly clean up unused namespaces + +**Batch Operations:** +Redis supports efficient batch operations: + +```go +// Batch retrieval +results, err := store.GetChunks(ctx, namespace, []string{"id1", "id2", "id3"}) + +// Batch deletion +deleteResults, err := store.DeleteAll(ctx, namespace, queries) +``` + +### Production Considerations + + +**RediSearch Module Required**: Redis integration requires the RediSearch module to be enabled on your Redis instance. This module provides the vector search capabilities needed for semantic caching. + + + +**Production Considerations**: +- Use Redis AUTH for production deployments +- Configure appropriate connection timeouts +- Monitor memory usage and set appropriate TTL values + + +--- + +## Use Cases + +### [Semantic Caching](../../../features/semantic-caching) +Build intelligent caching systems that understand query intent rather than just exact matches. + +**Applications:** +- Customer support systems with FAQ matching +- Code completion and documentation search +- Content management with semantic deduplication + +### Knowledge Base & Search +Create intelligent search systems that understand user queries contextually. + +**Applications:** +- Document search and retrieval systems +- Product recommendation engines +- Research paper and knowledge discovery platforms + +### Content Classification +Automatically categorize and tag content based on semantic similarity. + +**Applications:** +- Email classification and routing +- Content moderation and filtering +- News article categorization and clustering + +### Recommendation Systems +Build personalized recommendation engines using vector similarity. + +**Applications:** +- Product recommendations based on user preferences +- Content suggestions for media platforms +- Similar document or article recommendations + +## Related Documentation + +| Topic | Documentation | Description | +|-------|---------------|-------------| +| **Framework Overview** | [What is Framework](../what-is-framework) | Understanding the framework package and VectorStore interface | +| **Semantic Caching** | [Semantic Caching](../../../features/semantic-caching) | Using VectorStore for AI response caching | diff --git a/docs/architecture/framework/what-is-framework.mdx b/docs/architecture/framework/what-is-framework.mdx new file mode 100644 index 000000000..2ba04a207 --- /dev/null +++ b/docs/architecture/framework/what-is-framework.mdx @@ -0,0 +1,49 @@ +--- +title: "What is framework?" +description: "Framework is Bifrost's shared storage and utilities SDK package that provides common database interfaces and logic for the plugin ecosystem." +icon: "play" +--- + +Framework serves as the foundation layer that enables plugins to implement consistent data management patterns without reinventing storage solutions. + +## Installation + +```bash +go get github.com/maximhq/bifrost/framework +``` + +## Purpose + +The framework package was designed to solve a fundamental challenge in plugin development: providing standardized, reliable storage and utility interfaces that plugins can depend on. Instead of each plugin implementing its own database logic, configuration management, or logging systems, framework offers battle-tested, shared implementations. + +## Core Components + +### ConfigStore +A unified configuration persistence layer that provides consistent storage patterns for plugin settings, provider configurations, and system state. Plugins can leverage `ConfigStore` to manage their configuration data with built-in CRUD operations, transaction support, and schema management. + +### LogStore +Standardized logging and audit trail capabilities that enable plugins to implement observability features. `LogStore` provides structured logging, search and filtering capabilities, pagination support, and automated data retention policies. + +### VectorStore +Vector database operations designed for AI-powered plugins that need semantic capabilities. `VectorStore` handles embeddings management, similarity search operations, and namespace isolation, making it easy for plugins to add features like semantic caching, content search, and AI-powered recommendations. + +### Pricing Module +Cost calculation and model pricing management tools that help plugins implement billing and usage tracking features. The pricing system supports multi-tier pricing models, real-time usage tracking, and dynamic pricing updates. + +## Benefits for Plugin Developers + +**Shared Logic**: Common patterns for configuration, logging, and data management are provided out-of-the-box, reducing development time and ensuring consistency across plugins. + +**Standardized Interfaces**: All framework components use consistent APIs, making it easier for developers to work across different plugins and maintain code quality. + +**Pluggable Architecture**: The interface-based design allows different storage backends to be used without changing plugin code, providing flexibility for different deployment scenarios. + +**Transaction Support**: Built-in transaction management and error handling ensure data integrity and provide reliable rollback capabilities. + +**Production Ready**: Framework components are battle-tested in production environments and include features like connection pooling, retry logic, and performance optimizations. + +## Integration with Bifrost + +Framework seamlessly integrates with the Bifrost ecosystem, providing the storage foundation that powers core features like provider management, request logging, semantic caching, and governance. When plugins use framework components, they automatically participate in Bifrost's unified data management strategy. + +The framework package enables plugin developers to focus on their core business logic while relying on robust, shared infrastructure for all storage and utility needs. \ No newline at end of file diff --git a/docs/architecture/plugins/governance.mdx b/docs/architecture/plugins/governance.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/plugins/jsonparser.mdx b/docs/architecture/plugins/jsonparser.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/plugins/logging.mdx b/docs/architecture/plugins/logging.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/plugins/maxim.mdx b/docs/architecture/plugins/maxim.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/plugins/mocker.mdx b/docs/architecture/plugins/mocker.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/plugins/semantic-cache.mdx b/docs/architecture/plugins/semantic-cache.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/plugins/telemetry.mdx b/docs/architecture/plugins/telemetry.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/architecture/transports/in-memory-store.mdx b/docs/architecture/transports/in-memory-store.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/benchmarking/getting-started.mdx b/docs/benchmarking/getting-started.mdx new file mode 100644 index 000000000..f1289b354 --- /dev/null +++ b/docs/benchmarking/getting-started.mdx @@ -0,0 +1,81 @@ +--- +title: "Getting Started" +description: "Introduction to Bifrost's performance capabilities and how to choose the right instance size for your workload." +icon: "rocket" +--- + +## Overview + +Bifrost has been rigorously tested under high load conditions to ensure optimal performance for production deployments. Our benchmark tests demonstrate exceptional performance characteristics at **5,000 requests per second (RPS)** across different AWS EC2 instance types. + +**Key Performance Highlights:** +- **Perfect Success Rate**: 100% request success rate under high load +- **Minimal Overhead**: Less than 15Β΅s added latency per request on average +- **Efficient Queue Management**: Sub-microsecond queue wait times on optimized instances +- **Fast Key Selection**: Near-instantaneous weighted API key selection (~10 ns) + +--- + +## Test Environment Summary + +Bifrost was benchmarked on two primary AWS EC2 instance configurations: + +### **t3.medium (2 vCPUs, 4GB RAM)** +- **Buffer Size**: 15,000 +- **Initial Pool Size**: 10,000 +- **Use Case**: Cost-effective option for moderate workloads + +### **t3.xlarge (4 vCPUs, 16GB RAM)** +- **Buffer Size**: 20,000 +- **Initial Pool Size**: 15,000 +- **Use Case**: High-performance option for demanding workloads + +--- + +## Performance Comparison at a Glance + +| Metric | t3.medium | t3.xlarge | Improvement | +|--------|-----------|-----------|-------------| +| **Success Rate @ 5k RPS** | 100% | 100% | No failed requests | +| **Bifrost Overhead** | 59 Β΅s | 11 Β΅s | **-81%** | +| **Average Latency** | 2.12s | 1.61s | **-24%** | +| **Queue Wait Time** | 47.13 Β΅s | 1.67 Β΅s | **-96%** | +| **JSON Marshaling** | 63.47 Β΅s | 26.80 Β΅s | **-58%** | +| **Response Parsing** | 11.30 ms | 2.11 ms | **-81%** | +| **Peak Memory Usage** | 1,312.79 MB | 3,340.44 MB | +155% | + +> **Note**: t3.xlarge tests used significantly larger response payloads (~10 KB vs ~1 KB), yet still achieved better performance metrics. + + +All benchmarks are on mocked OpenAI calls, whose latency and payload size are mentioned in the respective analysis pages. + + +--- + +## Configuration Flexibility + +One of Bifrost's key strengths is its **configuration flexibility**. You can fine-tune the speed ↔ memory trade-off based on your specific requirements: + +| Configuration Parameter | Effect | +|------------------------|--------| +| `initial_pool_size` | Higher values = faster performance, more memory usage | +| `buffer_size` & `concurrency` | Controls queue depth and max parallel workers (per provider) | +| `retry` & `timeout` | Tune aggressiveness for each provider to meet your SLOs | + +**Configuration Philosophy:** +- **Higher settings** (like t3.xlarge profile) prioritize raw speed +- **Lower settings** (like t3.medium profile) optimize for memory efficiency +- **Custom tuning** lets you find the sweet spot for your specific workload + +--- + +## Next Steps + +### **Detailed Performance Analysis** +- **[t3.medium Performance](./t3.medium)** - Deep dive into cost-effective performance +- **[t3.xlarge Performance](./t3.xl)** - High-performance configuration analysis + +### **Run Your Own Tests** +- **[Run Your Own Benchmarks](./run-your-own-benchmarks)** - Step-by-step guide to benchmark Bifrost in your environment + +Ready to dive deeper? Choose your instance type above or learn how to run your own performance tests. diff --git a/docs/benchmarking/run-your-own-benchmarks.mdx b/docs/benchmarking/run-your-own-benchmarks.mdx new file mode 100644 index 000000000..2e75d53d1 --- /dev/null +++ b/docs/benchmarking/run-your-own-benchmarks.mdx @@ -0,0 +1,355 @@ +--- +title: "Run Your Own Benchmarks" +description: "Step-by-step guide to benchmark Bifrost in your own environment using the official benchmarking tool." +icon: "stopwatch" +--- + +## Overview + +Want to see Bifrost's performance in your specific environment? The [**Bifrost Benchmarking Repository**](https://github.com/maximhq/bifrost-benchmarking) provides everything you need to conduct comprehensive performance tests tailored to your infrastructure and workload requirements. + +**What You Can Test:** +- **Custom Instance Sizes** - Test on your preferred AWS/GCP/Azure instances +- **Your Workload Patterns** - Use your actual request/response sizes +- **Different Configurations** - Compare various Bifrost settings +- **Provider Comparisons** - Benchmark against other AI gateways +- **Load Scenarios** - Test burst loads, sustained traffic, and endurance + +> **πŸ’‘ Open Source**: The benchmarking tool is completely open source! Feel free to submit pull requests if you think anything is missing or could be improved. + +--- + +## Prerequisites + +Before running benchmarks, ensure you have: + +- **Go 1.23+** installed on your testing machine +- **Bifrost instance** running and accessible +- **Target API providers** configured (OpenAI, Anthropic, etc.) +- **Network access** between benchmark tool and Bifrost +- **Sufficient resources** on the testing machine to generate load + +--- + +## Quick Start + +### **1. Clone the Repository** + +```bash +git clone https://github.com/maximhq/bifrost-benchmarking.git +cd bifrost-benchmarking +``` + +### **2. Build the Benchmark Tool** + +```bash +go build benchmark.go +``` + +This creates a `benchmark` executable (or `benchmark.exe` on Windows). + +### **3. Run Your First Benchmark** + +```bash +# Basic benchmark: 500 RPS for 10 seconds +./benchmark -provider bifrost -port 8080 + +# Custom benchmark: 1000 RPS for 30 seconds +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 30 -output my_results.json +``` + +--- + +## Configuration Options + +The benchmark tool offers extensive configuration through command-line flags: + +### **Basic Configuration** + +| Flag | Required | Description | Default | +|------|----------|-------------|---------| +| `-provider ` | βœ… | Provider name (e.g., `bifrost`, `litellm`) | None | +| `-port ` | βœ… | Port number of your Bifrost instance | None | +| `-endpoint ` | ❌ | API endpoint path | `v1/chat/completions` | +| `-rate ` | ❌ | Requests per second | `500` | +| `-duration ` | ❌ | Test duration in seconds | `10` | +| `-output ` | ❌ | Results output file | `results.json` | + +### **Advanced Configuration** + +| Flag | Description | Default | +|------|-------------|---------| +| `-include-provider-in-request` | Include provider name in request payload | `false` | +| `-big-payload` | Use larger, more complex request payloads | `false` | + +--- + +## Benchmark Scenarios + +### **1. Basic Performance Test** + +Test standard performance with typical request sizes: + +```bash +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 60 -output basic_test.json +``` + +**Use Case**: General performance validation + +### **2. High-Load Stress Test** + +Push your instance to its limits: + +```bash +./benchmark -provider bifrost -port 8080 -rate 5000 -duration 120 -output stress_test.json +``` + +**Use Case**: Capacity planning and SLA validation + +### **3. Large Payload Test** + +Test with bigger request/response sizes: + +```bash +./benchmark -provider bifrost -port 8080 -rate 500 -duration 60 -big-payload=true -output large_payload.json +``` + +**Use Case**: Document processing, code generation workloads + +### **4. Endurance Test** + +Long-running stability test: + +```bash +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 1800 -output endurance_test.json +``` + +**Use Case**: Production readiness validation (30-minute test) + +### **5. Comparative Benchmarking** + +Compare Bifrost against other providers: + +```bash +# Test Bifrost +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 60 -output bifrost_results.json + +# Test LiteLLM +./benchmark -provider litellm -port 8000 -rate 1000 -duration 60 -output litellm_results.json + +# Test direct OpenAI (if available) +./benchmark -provider openai -port 443 -endpoint chat/completions -rate 1000 -duration 60 -output openai_results.json +``` + +--- + +## Understanding Results + +The benchmark tool generates detailed JSON results with comprehensive metrics: + +### **Key Metrics Explained** + +```json +{ + "bifrost": { + "request_counts": { + "total_sent": 30000, + "successful": 30000, + "failed": 0 + }, + "success_rate": 100.0, + "latency_metrics": { + "mean_ms": 245.5, + "p50_ms": 230.2, + "p99_ms": 520.8, + "max_ms": 845.3 + }, + "throughput_rps": 5000.0, + "memory_usage": { + "before_mb": 512.5, + "after_mb": 1312.8, + "peak_mb": 1405.2, + "average_mb": 1156.7 + }, + "timestamp": "2025-01-14T10:30:00Z", + "status_codes": { + "200": 30000 + } + } +} +``` + +### **Critical Performance Indicators** + +**Success Rate:** +- **Target**: >99.9% for production readiness +- **Excellent**: 100% (perfect reliability) + +**Latency Metrics:** +- **P50 (Median)**: Typical user experience +- **P99**: Worst-case user experience +- **Mean**: Overall average performance + +**Memory Usage:** +- **Peak**: Maximum memory consumption +- **Average**: Sustained memory usage +- **After - Before**: Memory growth during test + +--- + +## Instance Sizing Recommendations + +Based on your benchmark results, use these guidelines for production sizing: + +### **Resource Planning Matrix** + +| Target RPS | Memory Usage | Recommended Instance | Notes | +|------------|--------------|---------------------|--------| +| **< 1,000** | < 1GB | t3.small | Cost-effective for light loads | +| **1,000 - 3,000** | 1-2GB | t3.medium | Balanced performance/cost | +| **3,000 - 5,000** | 2-4GB | t3.large | High-performance production | +| **5,000+** | 3-6GB | t3.xlarge+ | Enterprise/mission-critical | + +### **Configuration Tuning Based on Results** + +**If seeing high latency:** +- Increase `initial_pool_size` +- Increase `buffer_size` +- Consider larger instance + +**If memory usage is high:** +- Decrease `initial_pool_size` +- Optimize `buffer_size` +- Monitor for memory leaks + +**If success rate < 100%:** +- Reduce request rate +- Increase timeout settings +- Check provider limits + +--- + +## Advanced Testing Scenarios + +### **Burst Load Testing** + +Simulate traffic spikes: + +```bash +# Normal load +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 300 -output normal_load.json + +# Burst load (simulate 5x spike) +./benchmark -provider bifrost -port 8080 -rate 5000 -duration 60 -output burst_load.json +``` + +### **Multi-Instance Testing** + +Test horizontal scaling: + +```bash +# Instance 1 +./benchmark -provider bifrost-1 -port 8080 -rate 2500 -duration 120 -output instance_1.json & + +# Instance 2 +./benchmark -provider bifrost-2 -port 8081 -rate 2500 -duration 120 -output instance_2.json & + +# Wait for both to complete +wait +``` + +### **Different Payload Sizes** + +Compare performance across payload sizes: + +```bash +# Small payloads (default) +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 60 -output small_payload.json + +# Large payloads +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 60 -big-payload=true -output large_payload.json +``` + +--- + +## Continuous Benchmarking + +### **Automated Testing Pipeline** + +Set up regular performance regression testing: + +```bash +#!/bin/bash +# daily_benchmark.sh + +DATE=$(date +%Y%m%d_%H%M%S) +OUTPUT_DIR="benchmarks/$DATE" +mkdir -p $OUTPUT_DIR + +# Run standard benchmarks +./benchmark -provider bifrost -port 8080 -rate 1000 -duration 300 -output "$OUTPUT_DIR/standard.json" +./benchmark -provider bifrost -port 8080 -rate 3000 -duration 180 -output "$OUTPUT_DIR/high_load.json" +./benchmark -provider bifrost -port 8080 -rate 500 -duration 600 -big-payload=true -output "$OUTPUT_DIR/large_payload.json" + +echo "Benchmarks completed: $OUTPUT_DIR" +``` + +### **Performance Monitoring Integration** + +Monitor key metrics over time: +- **Success rate trends** +- **Latency percentile changes** +- **Memory usage patterns** +- **Throughput capacity** + +--- + +## Troubleshooting + +### **Common Issues** + +**Connection Refused:** +```bash +# Check if Bifrost is running +curl http://localhost:8080/health + +# Verify port configuration +netstat -an | grep 8080 +``` +- Check PORT is defined in `.env` file at root. + +**High Error Rates:** +- Check provider API key limits +- Verify Bifrost configuration +- Monitor upstream provider status +- Reduce request rate for baseline test + +**Memory Issues:** +- Monitor system resources during testing +- Check for memory leaks in long tests +- Adjust Bifrost pool sizes + +**Inconsistent Results:** +- Run multiple test iterations +- Account for network variability +- Use longer test durations (60+ seconds) +- Isolate testing environment +- Try hitting gateway requests to a Mock provider + +--- + +## Next Steps + +### **After Running Benchmarks** + +1. **Analyze Results**: Compare against [official benchmarks](./getting-started) +2. **Optimize Configuration**: Tune based on your specific results +3. **Plan Capacity**: Size instances based on measured performance +4. **Set Up Monitoring**: Track key metrics in production + +### **Compare Results** + +- **[t3.medium Performance](./t3.medium)** - Compare against medium instance results +- **[t3.xlarge Performance](./t3.xl)** - Compare against high-performance configuration + +**Ready to benchmark? Clone the [repository](https://github.com/maximhq/bifrost-benchmarking) and start testing!** diff --git a/docs/benchmarking/t3.medium.mdx b/docs/benchmarking/t3.medium.mdx new file mode 100644 index 000000000..a0371c1d4 --- /dev/null +++ b/docs/benchmarking/t3.medium.mdx @@ -0,0 +1,127 @@ +--- +title: "t3.medium" +description: "Detailed performance metrics and analysis for Bifrost running on AWS t3.medium instances (2 vCPUs, 4GB RAM)." +icon: "server" +--- + +## Instance Configuration + +**AWS t3.medium Specifications:** +- **vCPUs**: 2 +- **Memory**: 4GB RAM +- **Network Performance**: Up to 5 Gigabit + +**Bifrost Configuration:** +- **Buffer Size**: 15,000 +- **Initial Pool Size**: 10,000 +- **Test Load**: 5,000 requests per second (RPS) + +--- + +## Performance Results + +### **Overall Performance Metrics** + +| Metric | Value | Notes | +|--------|-------|--------| +| **Success Rate** | 100.00% | Perfect reliability under high load | +| **Average Request Size** | 0.13 KB | Lightweight request payload | +| **Average Response Size** | 1.37 KB | Standard response size for testing | +| **Average Latency** | 2.12s | Total end-to-end response time | +| **Peak Memory Usage** | 1,312.79 MB | ~33% of available 4GB RAM | + +### **Detailed Performance Breakdown** + +| Operation | Latency | Performance Notes | +|-----------|---------|-------------------| +| **Queue Wait Time** | 47.13 Β΅s | Time waiting in Bifrost's internal queue | +| **Key Selection Time** | 16 ns | Weighted API key selection | +| **Message Formatting** | 2.19 Β΅s | Request message preparation | +| **Params Preparation** | 436 ns | Parameter processing | +| **Request Body Preparation** | 2.65 Β΅s | HTTP request body assembly | +| **JSON Marshaling** | 63.47 Β΅s | JSON serialization time | +| **Request Setup** | 6.59 Β΅s | HTTP client configuration | +| **HTTP Request** | 1.56s | Actual provider API call time | +| **Error Handling** | 189 ns | Error processing overhead | +| **Response Parsing** | 11.30 ms | JSON response deserialization | + +**Bifrost's Total Overhead: 59 Β΅s*** + +*\*Excludes JSON marshalling and HTTP calls, which are required in any implementation* + +--- + +## Performance Analysis + +### **Strengths on t3.medium** + +1. **Perfect Reliability**: 100% success rate even at 5,000 RPS +2. **Memory Efficiency**: Uses only 33% of available RAM (1,312.79 MB / 4GB) +3. **Minimal Overhead**: Just 59 Β΅s of added latency per request +4. **Fast Operations**: Sub-microsecond performance for most internal operations + +### **Resource Utilization** + +- **Memory Usage**: Very efficient at 1,312.79 MB peak usage +- **CPU Performance**: Handles 5,000 RPS workload effectively +- **Queue Management**: 47.13 Β΅s average wait time indicates good throughput + +--- + +## Configuration Recommendations + +### **Optimal Settings for t3.medium** + +Based on test results, these configurations work well: + +```json +{ + "client": { + "initial_pool_size": 10000, + "buffer_size": 15000 + } +} +``` + +### **Tuning Opportunities** + +**For Lower Memory Usage:** +- Reduce `initial_pool_size` to 7,500-8,000 +- Decrease `buffer_size` to 12,000-13,000 +- Trade-off: Slightly higher latency + +**For Better Performance:** +- Increase `initial_pool_size` to 12,000-13,000 +- Increase `buffer_size` to 17,000-18,000 +- Trade-off: Higher memory usage (monitor RAM limits) + +--- + +## Comparison Context + +### **vs. t3.xlarge Performance** + +| Metric | t3.medium | t3.xlarge | Difference | +|--------|-----------|-----------|------------| +| **Bifrost Overhead** | 59 Β΅s | 11 Β΅s | +81% slower | +| **Queue Wait Time** | 47.13 Β΅s | 1.67 Β΅s | +96% slower | +| **JSON Marshaling** | 63.47 Β΅s | 26.80 Β΅s | +58% slower | +| **Response Parsing** | 11.30 ms | 2.11 ms | +81% slower | +| **Memory Usage** | 1,312.79 MB | 3,340.44 MB | -61% usage | + +**Key Insights:** +- t3.medium uses **61% less memory** than t3.xlarge +- Performance trade-offs are reasonable for cost savings +- Most operations still complete in microseconds + +--- + +## Next Steps + +**When to upgrade to t3.xlarge:** +- Sustained load approaches 4,000+ RPS +- Queue wait times consistently exceed 75 Β΅s +- Memory usage approaches 75% of available RAM + +- **[Run Your Own Benchmarks](./run-your-own-benchmarks)** to test with your specific workload +- **[Compare with t3.xlarge](./t3.xl)** for performance scaling analysis diff --git a/docs/benchmarking/t3.xl.mdx b/docs/benchmarking/t3.xl.mdx new file mode 100644 index 000000000..0c9c95210 --- /dev/null +++ b/docs/benchmarking/t3.xl.mdx @@ -0,0 +1,151 @@ +--- +title: "t3.xlarge" +description: "Detailed performance metrics and analysis for Bifrost running on AWS t3.xlarge instances (4 vCPUs, 16GB RAM)." +icon: "server" +--- + +## Instance Configuration + +**AWS t3.xlarge Specifications:** +- **vCPUs**: 4 +- **Memory**: 16GB RAM +- **Network Performance**: Up to 5 Gigabit + +**Bifrost Configuration:** +- **Buffer Size**: 20,000 +- **Initial Pool Size**: 15,000 +- **Test Load**: 5,000 requests per second (RPS) + +--- + +## Performance Results + +### **Overall Performance Metrics** + +| Metric | Value | Notes | +|--------|-------|--------| +| **Success Rate** | 100.00% | Perfect reliability under high load | +| **Average Request Size** | 0.13 KB | Lightweight request payload | +| **Average Response Size** | 10.32 KB | **Large response payload testing** | +| **Average Latency** | 1.61s | Total end-to-end response time | +| **Peak Memory Usage** | 3,340.44 MB | ~21% of available 16GB RAM | + +> **Note**: t3.xlarge tests used significantly larger response payloads (~10 KB vs ~1 KB on t3.medium) to stress-test performance with realistic production data sizes. + +### **Detailed Performance Breakdown** + +| Operation | Latency | Performance Notes | +|-----------|---------|-------------------| +| **Queue Wait Time** | 1.67 Β΅s | **96% faster** than t3.medium | +| **Key Selection Time** | 10 ns | **37% faster** weighted API key selection | +| **Message Formatting** | 2.11 Β΅s | Consistent with t3.medium performance | +| **Params Preparation** | 417 ns | Slight improvement over t3.medium | +| **Request Body Preparation** | 2.36 Β΅s | **11% faster** request assembly | +| **JSON Marshaling** | 26.80 Β΅s | **58% faster** serialization | +| **Request Setup** | 7.17 Β΅s | Comparable to t3.medium | +| **HTTP Request** | 1.50s | **4% faster** provider API calls | +| **Error Handling** | 162 ns | **14% faster** error processing | +| **Response Parsing** | 2.11 ms | **81% faster** despite 7.5x larger payloads | + +**Bifrost's Total Overhead: 11 Β΅s*** + +*\*Excludes JSON marshalling and HTTP calls, which are required in any implementation. 81% reduction compared to t3.medium (59 Β΅s β†’ 11 Β΅s)* + +--- + +## Performance Analysis + +### **Exceptional Performance Improvements** + +1. **Dramatic Overhead Reduction**: 81% lower Bifrost overhead (59 Β΅s β†’ 11 Β΅s) +2. **Superior Queue Management**: 96% faster queue wait times (47.13 Β΅s β†’ 1.67 Β΅s) +3. **Faster JSON Processing**: 58% improvement in marshaling despite larger payloads +4. **Efficient Response Parsing**: 81% faster parsing even with 7.5x larger responses +5. **Perfect Reliability**: 100% success rate maintained under high load + +### **Resource Utilization** + +- **Memory Efficiency**: Uses only 21% of available RAM (3,340.44 MB / 16GB) +- **CPU Performance**: Excellent multi-core utilization for 5,000 RPS +- **Headroom**: Substantial capacity for traffic spikes and growth + +--- + +## Scalability and Headroom + +### **Exceptional Scaling Characteristics** + +The t3.xlarge configuration demonstrates **excellent scaling potential**: + +**Current Utilization:** +- **Memory**: 21% used (13GB available headroom) +- **Queue Performance**: 1.67 Β΅s wait time (near-optimal) +- **Processing Speed**: Sub-microsecond for most operations + +**Scaling Potential:** +- **Traffic Spikes**: Can likely handle 15,000+ RPS bursts +- **Response Size Growth**: Efficiently handles 10 KB responses +- **Concurrent Users**: Supports thousands of simultaneous users + +--- + +## Advanced Configuration + +### **Optimal Settings for t3.xlarge** + +Based on test results, these configurations provide excellent performance: + +```json +{ + "client": { + "initial_pool_size": 15000, + "buffer_size": 20000 + } +} +``` + +### **Performance Tuning Opportunities** + +**For Maximum Performance:** +- Increase `initial_pool_size` to 18,000-20,000 +- Increase `buffer_size` to 25,000-30,000 +- Trade-off: Higher memory usage (still well within limits) + +**For Memory Optimization:** +- Current config already very efficient at 21% RAM usage +- Could reduce settings if needed, but performance gains would be lost + +**For Extreme Workloads:** +- Consider `initial_pool_size` up to 25,000 +- Increase `buffer_size` to 35,000+ +- Monitor memory usage approaching 50% of available RAM + +--- + +## Performance Comparison + +### **vs. t3.medium Performance** + +| Metric | t3.medium | t3.xlarge | Improvement | +|--------|-----------|-----------|-------------| +| **Bifrost Overhead** | 59 Β΅s | 11 Β΅s | **-81%** | +| **Average Latency** | 2.12s | 1.61s | **-24%** | +| **Queue Wait Time** | 47.13 Β΅s | 1.67 Β΅s | **-96%** | +| **JSON Marshaling** | 63.47 Β΅s | 26.80 Β΅s | **-58%** | +| **Response Parsing** | 11.30 ms | 2.11 ms | **-81%** | +| **Response Size Handled** | 1.37 KB | 10.32 KB | **+7.5x** | +| **Peak Memory Usage** | 1,312.79 MB | 3,340.44 MB | +155% | +| **Memory Utilization** | 33% | 21% | **-36%** | + +**Key Insights:** +- **81% overhead reduction** while handling 7.5x larger responses +- **Exceptional efficiency** with only 21% memory utilization +- **Dramatic queue performance** improvements +- **Substantial headroom** for growth and traffic spikes + +--- + +## Next Steps + +- **[Run Your Own Benchmarks](./run-your-own-benchmarks)** with your specific payload sizes +- **[Compare with t3.medium](./t3.medium)** for cost-optimization analysis diff --git a/docs/changelogs/v1.2.21.mdx b/docs/changelogs/v1.2.21.mdx new file mode 100644 index 000000000..8ac2cdaa3 --- /dev/null +++ b/docs/changelogs/v1.2.21.mdx @@ -0,0 +1,50 @@ +--- +title: "v1.2.21" +description: "v1.2.21 changelog" +--- + + +- Fixes pricing computation for nested model names i.e. groq/openai/gpt-oss-20b. + + + + +- Pricing module now accommodates nested model names i.e. groq/openai/gpt-oss-20b was getting skipped while computing costs. + + + + +- Upgrades framework to 1.0.23 + + + + +- Upgrades framework to 1.0.23 + + + + +- Upgrades framework to 1.0.23 +- Fixes pricing computation for nested model names. + + + + +- Upgrades framework to 1.0.23 + + + + +- Upgrades framework to 1.0.23 + + + + +- Upgrades framework to 1.0.23 + + + + +- Upgrades framework to 1.0.23 + + diff --git a/docs/changelogs/v1.2.22.mdx b/docs/changelogs/v1.2.22.mdx new file mode 100644 index 000000000..ddebea3ab --- /dev/null +++ b/docs/changelogs/v1.2.22.mdx @@ -0,0 +1,64 @@ +--- +title: "v1.2.22" +description: "v1.2.22 changelog" +--- + + +- Fix: Users can now delete custom providers from the UI +- Fix: Token count no longer displays as N/A in certain streaming response cases +- Fix: Streaming responses now properly display errors on the UI instead of getting stuck in processing state + + + + +- Fix: Updates token calculation for streaming responses. #520 + + + + +- upgrade: core upgrades to 1.1.38 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- fix: fixes error logging for streaming and non-streaming responses. +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + diff --git a/docs/changelogs/v1.2.23.mdx b/docs/changelogs/v1.2.23.mdx new file mode 100644 index 000000000..8ce5371df --- /dev/null +++ b/docs/changelogs/v1.2.23.mdx @@ -0,0 +1,62 @@ +--- +title: "v1.2.23" +description: "v1.2.23 changelog" +--- + + +- Fix: Fixes editing experience of weight for API keys. + + + + +- Fix: Updates token calculation for streaming responses. #520 + + + + +- upgrade: core upgrades to 1.1.38 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- fix: fixes error logging for streaming and non-streaming responses. +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + diff --git a/docs/changelogs/v1.2.24.mdx b/docs/changelogs/v1.2.24.mdx new file mode 100644 index 000000000..abf99d6e9 --- /dev/null +++ b/docs/changelogs/v1.2.24.mdx @@ -0,0 +1,63 @@ +--- +title: "v1.2.24" +description: "v1.2.24 changelog" +--- + + +- Fix: Adds `Base URL` input in custom provider creation dialog. +- Fix: Fixes `x` button getting hidden behind dialog header. + + + + +- Fix: Updates token calculation for streaming responses. #520 + + + + +- upgrade: core upgrades to 1.1.38 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- fix: fixes error logging for streaming and non-streaming responses. +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + + + +- upgrade: core to 1.1.38 +- upgrade: framework to 1.0.24 + + diff --git a/docs/changelogs/v1.3.0-prerelease1.mdx b/docs/changelogs/v1.3.0-prerelease1.mdx new file mode 100644 index 000000000..fd6b573be --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease1.mdx @@ -0,0 +1,81 @@ +--- +title: "v1.3.0-prerelease1" +description: "v1.3.0-prerelease1 changelog" +--- + + +- Fix: Token count no longer displays as N/A in certain streaming response cases +- Fix: Streaming responses now properly display errors on the UI instead of getting stuck in processing state +- Feat: UI for configuring external observability connectors +- Feat: OTLP collector +- Feat: UI-driven Maxim observability configuration +- Fix: Fixes Bifrost specific error logging in first party and third party logging plugins + + + + +- Feature: Adds dynamic reloads for plugins. This removes the requirement for restarts when updating plugins. +- Feature: Adds responses API support. +- This release contains multiple breaking changes for Bifrost Core. These were necessary to ensure we incorporate responses without compromising on speed or architecture. + + + + +- Chore: Adds ctx to each function to gracefully shutdown ongoing tasks and bring better concurrency management +- Fix: Fixes pricing sync to make sure latest updates are synced at every restart. +- Feat: Adds new accumulator for accumulating all streaming responses from LLMs. + + + + +- Feat: Now Bifrost supports provider level fallbacks +- Chore: Dependency upgrades + + + + +- Upgrade dependency: core to 1.2.0 + + + + +- Fix: Captures Bifrost-specific errors in logs (e.g. provider not configured) +- Fix: Fixes audio streaming captures +- Upgrade dependency: core to 1.2.0 +- Upgrade dependency: framework to 1.1.0 + + + + +- Fix: Maxim plugin now captures Bifrost gateway specific errors. +- Upgrade dependency: maxim-go to 0.1.11 +- Upgrade dependency: core to 1.2.0 +- Upgrade dependency: framework to 1.1.0 + + + + +- Upgrade dependency: core to 1.2.0 +- Upgrade dependency: framework to 1.1.0 + + + + +- First version cut πŸš€ +- Feature: Support OTLP collector over HTTP or gRPC protocol. + + + + +- Feat: Adds support for Responses and Text completions +- Upgrade dependency: core to 1.2.0 +- Upgrade dependency: framework to 1.1.0 + + + + +- Fix: Adds support for Responses and Text completions. +- Upgrade dependency: core to 1.2.0 +- Upgrade dependency: framework to 1.2.0 + + diff --git a/docs/changelogs/v1.3.0-prerelease2.mdx b/docs/changelogs/v1.3.0-prerelease2.mdx new file mode 100644 index 000000000..51670b6b5 --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease2.mdx @@ -0,0 +1,69 @@ +--- +title: "v1.3.0-prerelease2" +description: "v1.3.0-prerelease2 changelog" +--- + + +- Added specific error handling for timeout scenarios (context.Canceled, context.DeadlineExceeded, fasthttp.ErrTimeout) across all providers +- Created a dedicated error message for timeouts that guides users to adjust the timeout setting +- Fixed validation in HTTP handlers for embeddings, speech, and text completion requests +- Improved CORS wildcard pattern matching to support domain patterns like *.example.com +- Fixed issues in the logging plugin to properly handle text completion responses +- Enhanced UI form handling for network configuration with proper default values +- Feat: Adds Text Completion Streaming support + + + + +- Added specific error handling for timeout scenarios (context.Canceled, context.DeadlineExceeded, fasthttp.ErrTimeout) across all providers +- Created a dedicated error message for timeouts that guides users to adjust the timeout setting +- Added Text Completion Streaming support + + + + +- Feat: Adds Text Completion Streaming support + + + + +- Chore: using core 1.2.1 and framework 1.1.1 + + + + +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + + + +- Feat: Adds Text Completion Streaming support +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + + + +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + + + +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + + + +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + + + +- Feat: Adds Text Completion Streaming support +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + + + +- Upgrade dependency: core to 1.2.1 and framework to 1.1.1 + + diff --git a/docs/changelogs/v1.3.0-prerelease3.mdx b/docs/changelogs/v1.3.0-prerelease3.mdx new file mode 100644 index 000000000..1cc351ac8 --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease3.mdx @@ -0,0 +1,60 @@ +--- +title: "v1.3.0-prerelease3" +description: "v1.3.0-prerelease3 changelog" +--- + + +- Fix: Fixes string input support for responses requests. +- Feat: Adds responses endpoint to openai integration. + + + + +- Fix: String inputs tranformat added for responses requests. + + + + +- Chore: core upgrades to 1.2.2 + + + + +- Chore: using core 1.2.2 and framework 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + + + +- Upgrade dependency: core to 1.2.2 and framework to 1.1.2 + + diff --git a/docs/changelogs/v1.3.0-prerelease4.mdx b/docs/changelogs/v1.3.0-prerelease4.mdx new file mode 100644 index 000000000..a496a998a --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease4.mdx @@ -0,0 +1,59 @@ +--- +title: "v1.3.0-prerelease4" +description: "v1.3.0-prerelease4 changelog" +--- + + +- Feat: A new config called `Enable LiteLLM Fallback` that enables text_completion calls to fall back to chat_completions calls for the Groq provider. This is an anti-pattern, but we are adding this to help users migrate from LiteLLM easily. Reach out to us if you want us to enable any other quirky patterns LiteLLM has. + + + + +- Feat: Adds litellm-specific fallbacks for text completion for Groq. This enables users with codebases stuck in this antipattern out-of-the-box. + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + + + +- Chore: core upgrades to 1.2.3 + + diff --git a/docs/changelogs/v1.3.0-prerelease5.mdx b/docs/changelogs/v1.3.0-prerelease5.mdx new file mode 100644 index 000000000..2440809ba --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease5.mdx @@ -0,0 +1,62 @@ +--- +title: "v1.3.0-prerelease5" +description: "v1.3.0-prerelease5 changelog" +--- + + +- Fix: Anthropic tool results aggregation logic (core 1.2.4) +- Feat: Raw response saved in logs (framework 1.1.4) + + + + +- Fix: Anthropic tool results aggregation logic. + + + + +- Feat: Raw response saved in logs. +- Upgrade dependency: core to 1.2.4 + + + + +- Chore: using core 1.2.4 and framework 1.1.4 + + + + +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + + + +- Feat: Raw response saved in logs. +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + + + +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + + + +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + + + +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + + + +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + + + +- Upgrade dependency: core to 1.2.4 and framework to 1.1.4 + + diff --git a/docs/changelogs/v1.3.0-prerelease6.mdx b/docs/changelogs/v1.3.0-prerelease6.mdx new file mode 100644 index 000000000..84508b47c --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease6.mdx @@ -0,0 +1,73 @@ +--- +title: "v1.3.0-prerelease6" +description: "v1.3.0-prerelease6 changelog" +--- + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 +- Feat: Added Anthropic thinking parameter in responses API. +- Feat: Added Anthropic text completion integration support. +- Fix: Extra fields sent back in streaming responses. +- Feat: Latency for all request types (with inter token latency for streaming requests) sent back in Extra fields. +- Feat: UI websocket implementation generalized. +- Feat: TokenInterceptor interface added to plugins. +- Fix: Middlewares added to integrations route. + + + + +- Feat: Stream token latency sent back in extra fields. +- Feat: Plugin interface extended with TransportInterceptor method. +- Feat: Add Anthropic thinking parameter +- Feat: Add Custom key selector logic and send back request latency in extra fields. +- Bug: Fallbacks not working occasionally. + + + + +- Upgrade dependency: core to 1.2.5 +- Feat: User table added to config store. + + + + +- Chore: using core 1.2.5 and framework 1.1.5 +- Feat: Added provider routing TransportInterceptor. + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 +- Feat: Added First Token and Inter Token latency metrics for streaming requests. + + diff --git a/docs/changelogs/v1.3.0-prerelease7.mdx b/docs/changelogs/v1.3.0-prerelease7.mdx new file mode 100644 index 000000000..3d2cea43f --- /dev/null +++ b/docs/changelogs/v1.3.0-prerelease7.mdx @@ -0,0 +1,67 @@ +--- +title: "v1.3.0-prerelease7" +description: "v1.3.0-prerelease7 changelog" +--- + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 +- Added Responses streaming across all providers. +- Fixed bedrock chat streaming decoding issues. +- Added raw response support for all streaming requests. +- Removed last token's accumulated latency from inter token latency metric. + + + + +- Feat: Responses streaming added across all providers. +- Fix: Bedrock chat streaming decoding fixes. +- Feat: Added raw response support for all streaming requests. + + + + +- Upgrade dependency: core to 1.2.6 +- Feat: Moved the migrator package to a more general location and added database migrations for the logstore to standardize object type values. + + + + +- Chore: using core 1.2.6 and framework 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 +- Fix: Removed last token's accumulated latency from inter token latency metric. + + diff --git a/docs/changelogs/v1.3.0.mdx b/docs/changelogs/v1.3.0.mdx new file mode 100644 index 000000000..1c45c22e0 --- /dev/null +++ b/docs/changelogs/v1.3.0.mdx @@ -0,0 +1,105 @@ +--- +title: "v1.3.0" +description: "v1.3.0 changelog" +--- + + +We're excited to ship v1.3.0 with major quality, compatibility, and governance upgrades across OSS and Enterprise. + +🌟 Highlights +- OTel traces support (OSS): First-class support for OTLP collectors. +- Responses API (OSS): First-class support for the OpenAI-style Responses format, streaming + non-streaming. +- Drop-in for LiteLLM (OSS): Config-level fallbacks to ease migrations. +- Guardrails (Enterprise): Initial set with AWS Bedrock, Azure Content Moderator, and Patronus AI. +- Provisioning (Enterprise): Okta SCIM now supported alongside Microsoft Entra. +- Adaptive LB Dashboard (Enterprise, beta): Live traffic, weight shifts, and failover visibility. + +### Features +- Added Anthropic thinking parameter in Responses API. +- Added Anthropic text completion integration support. +- Latency metrics for all request types now returned in extra (includes inter-token latency for streaming). +- TokenInterceptor interface added to plugins. +- Raw provider response saved in logs (framework v1.1.4). + +### Fixes + +- Removed extra fields erroneously sent in streaming responses. +- Anthropic tool results aggregation corrected (core v1.2.4). +- String input support fixed for Responses requests. +- Specific timeout error handling across all providers for context.Canceled, context.DeadlineExceeded, and fasthttp.ErrTimeout. +- Pricing manager fixes. + +### Improvements + +- CORS wildcard matching improved to support domain patterns like *.example.com. + +## Closed tickets + +- [#605: [Bug]: UI Docker building errors](https://github.com/maximhq/bifrost/issues/605) +- [#597: [Bug Report] Bedrock streaming has many missing chunks](https://github.com/maximhq/bifrost/issues/597) +- [#567: Handling reasoning content](https://github.com/maximhq/bifrost/issues/567) +- [#565: The "pricing not found for model ..." message is repeated for each request processed, which is too noisy for the warn level.](https://github.com/maximhq/bifrost/issues/565) +- [#552: [Bug]: "index" not specified for tool calls in OpenAI chunks](https://github.com/maximhq/bifrost/issues/552) +- [#543: [Bug]: Indicate timeouts in error response while logging](https://github.com/maximhq/bifrost/issues/543) +- [#542: [Feature]: Logs should show timestamps in browser timezone](https://github.com/maximhq/bifrost/issues/542) +- [#520: [Bug]: tokens and cost for "Chat Stream" requests is missing in logs](https://github.com/maximhq/bifrost/issues/520) +- [#516: [Bug]: Can't delete custom provider from Web UI](https://github.com/maximhq/bifrost/issues/516) +- [#504: [Bug]: cannot use self-hosted SGLang instance with http:// URLs only](https://github.com/maximhq/bifrost/issues/504) +- [#497: [Feature]: Add full support for standard OpenTelemetry GenAI Observability](https://github.com/maximhq/bifrost/issues/497) +- [#479: [Feature]: Support for API Key Authentication in Bedrock](https://github.com/maximhq/bifrost/issues/479) +- [#463: [Feature]: Support for Thinking blocks](https://github.com/maximhq/bifrost/issues/463) +- [#456: [Docs]: Update API reference docs](https://github.com/maximhq/bifrost/issues/456) +- [#451: [Feature]: Offline usage](https://github.com/maximhq/bifrost/issues/451) + + + + +- Refactor: Bifrost Response structure seggragated. + + + + +- Upgrade dependency: core to 1.2.7 +- Fix: Added missing migration for `parent_request_id_column` in logs table. + + + + +- Chore: using core 1.2.7 and framework 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + diff --git a/docs/changelogs/v1.3.1.mdx b/docs/changelogs/v1.3.1.mdx new file mode 100644 index 000000000..fc740eb93 --- /dev/null +++ b/docs/changelogs/v1.3.1.mdx @@ -0,0 +1,60 @@ +--- +title: "v1.3.1" +description: "v1.3.1 changelog" +--- + + +- Bug: "x-bf-vk" missing error fixed. + + + + +- Refactor: Bifrost Response structure seggragated. + + + + +- Upgrade dependency: core to 1.2.7 +- Fix: Added missing migration for `parent_request_id_column` in logs table. + + + + +- Chore: taking context key from core package instead of governance package + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.6 and framework to 1.1.6 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + + + +- Upgrade dependency: core to 1.2.7 and framework to 1.1.7 + + diff --git a/docs/changelogs/v1.3.10.mdx b/docs/changelogs/v1.3.10.mdx new file mode 100644 index 000000000..bc1dc74cd --- /dev/null +++ b/docs/changelogs/v1.3.10.mdx @@ -0,0 +1,79 @@ +--- +title: "v1.3.10" +description: "v1.3.10 changelog" +--- + + +- chore: version update core to 1.2.13 and framework to 1.1.15 +- feat: added headers support for OTel configuration. Value prefixed with env will be fetched from environment variables (`env.ENV_VAR_NAME`) +- feat: emission of OTel resource spans is completely async - this brings down inference overhead to < 1Β΅second +- fix: added latency calculation for vertex native requests +- feat: added cached tokens and reasoning tokens to the usage in ui +- fix: cost calculation for vertex requests +- feat: added global region support for vertex API +- fix: added filter for extra fields in chat completions request for Mistral provider +- fix: added wildcard validation for allowed origins in UI security settings +- fix: fixed code field in pending_safety_checks for Responses API + + + + +- bug: fixed embedding request not being handled in `GetExtraFields()` method of `BifrostResponse` +- fix: added latency calculation for vertex native requests +- feat: added cached tokens and reasoning tokens to the usage metadata for chat completions +- feat: added global region support for vertex API +- fix: added filter for extra fields in chat completions request for Mistral provider +- fix: fixed ResponsesComputerToolCallPendingSafetyCheck code field + + + + +- chore: version update core to 1.2.13 +- feat: added support for vertex provider/model format in pricing lookup + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 +- feat: added support for responses request +- feat: added "skip-mocker" context key to skip mocker plugin per request + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 +- feat: added headers support for OTel configuration. Value prefixed with env will be fetched from environment variables (`env.ENV_VAR_NAME`) +- feat: emission of OTel resource spans is completely async - this brings down inference overhead to < 1Β΅second + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 +- tests: added mocker plugin to all chat/responses tests + + + + +- chore: version update core to 1.2.13 and framework to 1.1.15 + + diff --git a/docs/changelogs/v1.3.11.mdx b/docs/changelogs/v1.3.11.mdx new file mode 100644 index 000000000..268a19dbc --- /dev/null +++ b/docs/changelogs/v1.3.11.mdx @@ -0,0 +1,61 @@ +--- +title: "v1.3.11" +description: "v1.3.11 changelog" +--- + + +- chore: version update core to 1.2.14 and framework to 1.1.16 +- feat: added `/v1/models` endpoint to list models of configured providers + + + + +- feat: added ListModels method to Provider interface +- feat: enabled provider tracking in Bifrost core for API exposure + + + + +- chore: version update core to 1.2.14 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + + + +- chore: version update core to 1.2.14 and framework to 1.1.16 + + diff --git a/docs/changelogs/v1.3.12.mdx b/docs/changelogs/v1.3.12.mdx new file mode 100644 index 000000000..09b23bc45 --- /dev/null +++ b/docs/changelogs/v1.3.12.mdx @@ -0,0 +1,75 @@ +--- +title: "v1.3.12" +description: "v1.3.12 changelog" +--- + + +- chore: version update core to 1.2.15 and framework to 1.1.17 +- feat: add azure provider native responses API support +- chore: suppress irrelevant warnings in ListModels +- feat: refactored all plugin operations to completely async to prevent any blocking behavior +- feat: added provider level budget and rate limits using virtual keys +- feat: added streaming support in maxim plugin + + + + +- feat: add azure provider native responses API support +- feat: improve retry logic for rate limiting errors +- feat: add retries on list models request +- chore: suppress irrelevant warnings in ListModels + + + + +- chore: version update core to 1.2.15 +- [BREAKING] feat: renamed pricing module to modelcatalog and added list models population support for model pool +- feat: added chunk index based sorting for streaming responses in streaming package +- feat: added budget and rate limit to provider configs in virtual key table + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 +- feat: added provider level budget and rate limits + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 +- feat: creates deep copy of the response in PostHook to avoid modifying the original response pointer + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 +- feat: all operations moved async to prevent any blocking behavior + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 +- feat: added support for streaming responses + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 +- feat: all operations moved async to prevent any blocking behavior + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 + + + + +- chore: version update core to 1.2.15 and framework to 1.1.17 + + diff --git a/docs/changelogs/v1.3.13.mdx b/docs/changelogs/v1.3.13.mdx new file mode 100644 index 000000000..649991dfa --- /dev/null +++ b/docs/changelogs/v1.3.13.mdx @@ -0,0 +1,64 @@ +--- +title: "v1.3.13" +description: "v1.3.13 changelog" +--- + + +- chore: version update framework to 1.1.18 and core to 1.2.16 +- Adds env variable support for postgres config +- feat: standardize finish reason and single response handling across providers +- feat: provider config hot reloading added (no need to restart Bifrost after updating provider configs now) + + + + +- feat: standardize finish reason and single response handling across providers +- feat: provider config hot reloading added + + + + +- Adds env variable resolution for postgres config +- chore: Upgrades core to 1.2.16 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + + + +- chore: version update core to 1.2.16 and framework to 1.1.18 + + diff --git a/docs/changelogs/v1.3.14.mdx b/docs/changelogs/v1.3.14.mdx new file mode 100644 index 000000000..a27787cff --- /dev/null +++ b/docs/changelogs/v1.3.14.mdx @@ -0,0 +1,70 @@ +--- +title: "v1.3.14" +description: "v1.3.14 changelog" +--- + + +- chore: version update framework to 1.1.18 and core to 1.2.16 +- feat: Use all keys for list models request +- fix: handled panic when using gemini models with openai integration responses API requests +- chore: Added id, object, and model fields to Chat Completion responses from Bedrock and Cohere providers +- feat: Adds support for dynamic plugins. Note that dynamic plugins are in beta +- feat: Adds auth support for dashboard, inference APIs and dashboard APIs. + + + + +- feat: Use all keys for list models request +- refactor: Cohere provider to use completeRequest and response pooling for all requests +- chore: Added id, object, and model fields to Chat Completion responses from Bedrock and Cohere providers +- feat: Moved all streaming calls to use fasthttp client for efficiency +- feat: Adds support for auth + + + + +- chore: Upgrades core to 1.2.17 +- feat: Adds dynamic plugins support +- feat: Adds auth tables in config store + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + + + +- chore: version update core to 1.2.17 and framework to 1.1.19 + + diff --git a/docs/changelogs/v1.3.15.mdx b/docs/changelogs/v1.3.15.mdx new file mode 100644 index 000000000..7826a2381 --- /dev/null +++ b/docs/changelogs/v1.3.15.mdx @@ -0,0 +1,61 @@ +--- +title: "v1.3.15" +description: "v1.3.15 changelog" +--- + + +- chore: version update core to 1.2.18 and framework to 1.1.21 +- enhancement: provider lookup enhancements in modelcatelog + + + + +- refactor: minor until changes + + + + +- chore: Upgrades core to 1.2.18 +- enhancement: provider lookup enhancements + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + + + +- chore: version update core to 1.2.18 and framework to 1.1.21 + + diff --git a/docs/changelogs/v1.3.16.mdx b/docs/changelogs/v1.3.16.mdx new file mode 100644 index 000000000..8ac9f70e3 --- /dev/null +++ b/docs/changelogs/v1.3.16.mdx @@ -0,0 +1,65 @@ +--- +title: "v1.3.16" +description: "v1.3.16 changelog" +--- + + +- chore: version update core to 1.2.18 and framework to 1.1.21 +- feat: added Perplexity provider support +- chore: version update core to 1.2.19 and framework to 1.1.22 +- feat: support for mistralai publisher endpoint in vertex provider +- enhancement: Anthropic's computer tool in the Responses API stream handling, + + + + +- feat: support for mistralai publisher endpoint in vertex provider +- enhancement: Anthropic's computer tool in the Responses API stream handling, +- feat: added Perplexity provider support + + + + +- chore: Upgrades core to 1.2.19 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + + + +- chore: version update core to 1.2.19 and framework to 1.1.22 + + diff --git a/docs/changelogs/v1.3.17.mdx b/docs/changelogs/v1.3.17.mdx new file mode 100644 index 000000000..caa5cb09b --- /dev/null +++ b/docs/changelogs/v1.3.17.mdx @@ -0,0 +1,62 @@ +--- +title: "v1.3.17" +description: "v1.3.17 changelog" +--- + + +- chore: version update framework to 1.1.24 +- fix: resolve MCP client deletion when attached to a virtual key +- chore: allowed changing name when updating a virtual key +- fix: vk team/customer association issue when updating a vk + + + +❌ Changelog is empty + + + + +- fix: resolve MCP client deletion when attached to a virtual key +- fix: vk team/customer association issue when updating a vk + + + + +- chore: version update framework to 1.1.23 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + diff --git a/docs/changelogs/v1.3.18.mdx b/docs/changelogs/v1.3.18.mdx new file mode 100644 index 000000000..8913d4244 --- /dev/null +++ b/docs/changelogs/v1.3.18.mdx @@ -0,0 +1,55 @@ +--- +title: "v1.3.18" +description: "v1.3.18 changelog" +--- + + +- change: health endpoint is whitelisted from auth middleware + + + + +- fix: resolve MCP client deletion when attached to a virtual key +- fix: vk team/customer association issue when updating a vk + + + + +- chore: version update framework to 1.1.23 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + + + +- chore: version update framework to 1.1.24 + + diff --git a/docs/changelogs/v1.3.19.mdx b/docs/changelogs/v1.3.19.mdx new file mode 100644 index 000000000..8153770a5 --- /dev/null +++ b/docs/changelogs/v1.3.19.mdx @@ -0,0 +1,75 @@ +--- +title: "v1.3.19" +description: "v1.3.19 changelog" +--- + + +- chore: version update core to 1.2.20 and framework to 1.1.24 +- chore: allowed changing name when updating a virtual key +- feat: add numberOfRetries, fallbackIndex and selected key name and id to context to telemetry metrics +- feat: add used virtual key name and id to telemetry metrics +- feat: send model deployment back in response extra fields +- feat: add selected key and virtual key to logs filter +- feat: add headers to MCP client config +- feat: add `is_success` label to upstream latency metrics + + + + +- feat: add numberOfRetries, fallbackIndex and selected key name to context +[BREAKING] changed BifrostContextKeySelectedKey to BifrostContextKeySelectedKeyID +- feat: send model deployment back in response extra fields +- feat: add headers to MCP client config + + + + +- chore: Upgrades core to 1.2.20 +- feat: add selected key and virtual key to logs table +- feat: add headers to MCP client config + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 +- feat: add selected key and virtual key to logs + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 + + + + +- chore: version update core to 1.2.20 and framework to 1.1.24 +- feat: add numberOfRetries, fallbackIndex and selected key name and id to context to telemetry metrics +- feat: add used virtual key name and id to telemetry metrics +- feat: add `is_success` label to upstream latency metrics + + diff --git a/docs/changelogs/v1.3.2.mdx b/docs/changelogs/v1.3.2.mdx new file mode 100644 index 000000000..d8d99cc38 --- /dev/null +++ b/docs/changelogs/v1.3.2.mdx @@ -0,0 +1,70 @@ +--- +title: "v1.3.2" +description: "v1.3.2 changelog" +--- + + +- Refactor: Moves all context key types to schemas.BifrostContextKey +- Fix: Fixes Maxim plugin bug where external traceId were blocking new trace creations + + + + +- Chore: Now schema.BifrostContextKey is the only valid ctx key type throughout the project + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey +- Chore: Adds new logs table migration to avoid missing any required columns in the DB + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey +- Fix: Fixes a bug where external trace id was blocking new trace creation + + + + +- Upgrade dependency: core to 1.2.8 + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey + + + + +- Upgrade dependency: core to 1.2.8 +- Chore: Moves all context key types to schemas.BifrostContextKey + + diff --git a/docs/changelogs/v1.3.20.mdx b/docs/changelogs/v1.3.20.mdx new file mode 100644 index 000000000..e33cc5be2 --- /dev/null +++ b/docs/changelogs/v1.3.20.mdx @@ -0,0 +1,9 @@ +--- +title: "v1.3.20" +description: "v1.3.20 changelog" +--- + + +- fix: handle case when config store is nil in session and plugins handlers + + \ No newline at end of file diff --git a/docs/changelogs/v1.3.21.mdx b/docs/changelogs/v1.3.21.mdx new file mode 100644 index 000000000..4e97ee44c --- /dev/null +++ b/docs/changelogs/v1.3.21.mdx @@ -0,0 +1,10 @@ +--- +title: "v1.3.21" +description: "v1.3.21 changelog" +--- + + +- fix: handle case when config store is nil in session and plugins handlers +- chore: adds integration tests for different config combinations + + \ No newline at end of file diff --git a/docs/changelogs/v1.3.22.mdx b/docs/changelogs/v1.3.22.mdx new file mode 100644 index 000000000..a0e5dd112 --- /dev/null +++ b/docs/changelogs/v1.3.22.mdx @@ -0,0 +1,63 @@ +--- +title: "v1.3.22" +description: "v1.3.22 changelog - 2025-11-09" +--- + + +- feat: Adds option to disable authentication on inference calls +- chore: Adds dark image for new version infographic + + + + +- feat: add numberOfRetries, fallbackIndex and selected key name to context +[BREAKING] changed BifrostContextKeySelectedKey to BifrostContextKeySelectedKeyID +- feat: send model deployment back in response extra fields +- feat: add headers to MCP client config + + + + +- Adds DisableAuthOnInference to AuthConfig + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + + + +- chore: version update framework to 1.1.25 + + diff --git a/docs/changelogs/v1.3.23.mdx b/docs/changelogs/v1.3.23.mdx new file mode 100644 index 000000000..142648984 --- /dev/null +++ b/docs/changelogs/v1.3.23.mdx @@ -0,0 +1,67 @@ +--- +title: "v1.3.23" +description: "v1.3.23 changelog - 2025-11-10" +--- + +- chore: version update core to 1.2.21 and framework to 1.1.26 +- feat: add headers to MCP client config and provider config +- feat: adds support for custom path overrides for custom providers +- feat: adds support for key less authentication for custom providers +- feat: handles `response_schema` and `response_json_schema` parameter in gemini integration +- refactor: better mcp client management +- feat: option to disable content logging +- feat: key selection and retries info sent in genai traces +- feat: option to edit and reconnect mcp clients + + + + +- feat: add headers to MCP client config and provider config +- feat: adds support for custom path overrides for custom providers +- feat: adds support for key less authentication for custom providers +- feat: handles `response_schema` and `response_json_schema` parameter in gemini integration +- [BREAKING] MCP client Public API now takes mcp client ids instead of names +- refactor: better mcp client management + + + +- chore: version update core to 1.2.21 +- feat: add headers to MCP client config +- refactor: mcp clients to use ids instead of names +- feat: option to disable content logging + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 +- feat: option to disable content logging + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 +- feat: key selection and retries info sent in genai traces + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 + + + +- chore: version update core to 1.2.21 and framework to 1.1.26 + + diff --git a/docs/changelogs/v1.3.24.mdx b/docs/changelogs/v1.3.24.mdx new file mode 100644 index 000000000..080555f7c --- /dev/null +++ b/docs/changelogs/v1.3.24.mdx @@ -0,0 +1,50 @@ +--- +title: "v1.3.24" +description: "v1.3.24 changelog - 2025-11-11" +--- + +- chore: update core version to 1.2.22 and framework version to 1.1.27 +- feat: Adds input message in logs table for easier navigation + + + +- chore: Adds index to ChatAssistantMessageToolCall +- fix: responses text output standardization to content blocks + + + +- chore: update core version to 1.2.22 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + + +- chore: update core version to 1.2.22 and framework version to 1.1.27 + + diff --git a/docs/changelogs/v1.3.3.mdx b/docs/changelogs/v1.3.3.mdx new file mode 100644 index 000000000..d3ab8850f --- /dev/null +++ b/docs/changelogs/v1.3.3.mdx @@ -0,0 +1,61 @@ +--- +title: "v1.3.3" +description: "v1.3.3 changelog" +--- + + +- Upgrade dependency: core to 1.2.9 +- Fix: JSON serialization for error objects and tool function parameters + + + + +- Fix: Fixed JSON serialization for error objects and tool function parameters + + + + +- Upgrade dependency: core to 1.2.9 +- Fix: JSON serialization for error objects and tool function parameters + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + + + +- chore: version update core to 1.2.9 + + diff --git a/docs/changelogs/v1.3.4.mdx b/docs/changelogs/v1.3.4.mdx new file mode 100644 index 000000000..402dc639f --- /dev/null +++ b/docs/changelogs/v1.3.4.mdx @@ -0,0 +1,67 @@ +--- +title: "v1.3.4" +description: "v1.3.4 changelog" +--- + + +- Upgrade dependency: core to 1.2.10 and framework to 1.1.10 +- Feat: Added virtual key level support for MCP tools to execute +- Feat: Added names to keys +- Fix: provider selection from url params + + + + +- Feat: Added key name field to account schema for external key management +- Feat: Simplified MCP client management by removing toolsToSkip field, allowing wildcard (*) for all tools, and better tool filtering logic. + + + + +- Upgrade dependency: core to 1.2.10 +- Feat: Added key name column to config keys table +- Feat: Removed tools_to_skip field from MCP client config table +- Feat: Added virtual_key_mcp_config table to store MCP client configs for virtual keys along with its relationships + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 +- feat: added virtual key level support for MCP tools to execute + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 + + + + +- chore: version update core to 1.2.10 + + + + +- chore: version update core to 1.2.10 and framework to 1.1.10 + + diff --git a/docs/changelogs/v1.3.5.mdx b/docs/changelogs/v1.3.5.mdx new file mode 100644 index 000000000..34c595f72 --- /dev/null +++ b/docs/changelogs/v1.3.5.mdx @@ -0,0 +1,61 @@ +--- +title: "v1.3.5" +description: "v1.3.5 changelog" +--- + + +- chore: version update framework to 1.1.11 +- fix: added missing migration for `cost` and `cache_debug` columns in logs table for old databases. + + + + +- Feat: Added key name field to account schema for external key management +- Feat: Simplified MCP client management by removing toolsToSkip field, allowing wildcard (*) for all tools, and better tool filtering logic. + + + + +- Fix: Added missing migration for `cost` and `cache_debug` columns in logs table for old databases. + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + + + +- chore: version update framework to 1.1.11 + + diff --git a/docs/changelogs/v1.3.6.mdx b/docs/changelogs/v1.3.6.mdx new file mode 100644 index 000000000..a51c8c2fa --- /dev/null +++ b/docs/changelogs/v1.3.6.mdx @@ -0,0 +1,60 @@ +--- +title: "v1.3.6" +description: "v1.3.6 changelog" +--- + + +- chore: version update core to 1.2.11 and framework to 1.1.12 +- fix: responses tool message output struct overlapping fields fixed + + + + +- fix: responses tool message output struct overlapping fields fixed + + + + +- chore: version update core to 1.2.11 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + + + +- chore: version update core to 1.2.11 and framework to 1.1.12 + + diff --git a/docs/changelogs/v1.3.7.mdx b/docs/changelogs/v1.3.7.mdx new file mode 100644 index 000000000..a86e734ea --- /dev/null +++ b/docs/changelogs/v1.3.7.mdx @@ -0,0 +1,61 @@ +--- +title: "v1.3.7" +description: "v1.3.7 changelog" +--- + + +- chore: version update framework to 1.1.13 +- bug: fixed config store init issue when using postgres +- fix: allow http on pricing data url + + + + +- fix: responses tool message output struct overlapping fields fixed + + + + +- bug: fixed config store init issue when using postgres + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + + + +- chore: version update framework to 1.1.13 + + diff --git a/docs/changelogs/v1.3.8.mdx b/docs/changelogs/v1.3.8.mdx new file mode 100644 index 000000000..9e5be6d17 --- /dev/null +++ b/docs/changelogs/v1.3.8.mdx @@ -0,0 +1,63 @@ +--- +title: "v1.3.8" +description: "v1.3.8 changelog" +--- + + +- chore: version update core to 1.2.12 and framework to 1.1.14 +- fix: openai specific parameters filtered for openai compatibile providers +- fix: error response unmarshalling for gemini provider + + + + +- fix: openai specific parameters filtered for openai compatibile providers +- fix: error response unmarshalling for gemini provider +- BREAKING FIX: json_schema field correctly renamed to schema; ResponsesTextConfigFormatJSONSchema restructured + + + + +- chore: version update core to 1.2.12 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + diff --git a/docs/changelogs/v1.3.9.mdx b/docs/changelogs/v1.3.9.mdx new file mode 100644 index 000000000..663e5322c --- /dev/null +++ b/docs/changelogs/v1.3.9.mdx @@ -0,0 +1,61 @@ +--- +title: "v1.3.9" +description: "v1.3.9 changelog" +--- + + +- chore: Fixes form validation for Azure deployments. + + + + +- fix: openai specific parameters filtered for openai compatibile providers +- fix: error response unmarshalling for gemini provider +- BREAKING FIX: json_schema field correctly renamed to schema; ResponsesTextConfigFormatJSONSchema restructured + + + + +- chore: version update core to 1.2.12 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + + + +- chore: version update core to 1.2.12 and framework to 1.1.14 + + diff --git a/docs/contributing/code-conventions.mdx b/docs/contributing/code-conventions.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/contributing/raising-a-pr.mdx b/docs/contributing/raising-a-pr.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/contributing/running-tests.mdx b/docs/contributing/running-tests.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/contributing/setting-up-repo.mdx b/docs/contributing/setting-up-repo.mdx new file mode 100644 index 000000000..6729bd2f1 --- /dev/null +++ b/docs/contributing/setting-up-repo.mdx @@ -0,0 +1,401 @@ +--- +title: "Setting Up the Repository" +description: "Complete guide to setting up the Bifrost repository for local development." +icon: "hammer" +--- + +This guide walks you through setting up the Bifrost repository for local development, from prerequisites to running your first development server. + +## Prerequisites + +Before setting up the repository, ensure you have the following tools installed: + +### Go (Required) + +Bifrost requires **Go 1.21+** for development. + +```bash +# Check if Go is installed +go version + +# If not installed, download from https://golang.org/dl/ +# Or use package managers: + +# macOS (Homebrew) +brew install go + +# Ubuntu/Debian +sudo apt update && sudo apt install golang-go + +# Windows (Chocolatey) +choco install golang +``` + +### Node.js and npm (Required for UI development) + +The UI components require **Node.js 18+** and npm. + +```bash +# Check versions +node --version +npm --version + +# Install via package managers: + +# macOS (Homebrew) +brew install node + +# Ubuntu/Debian +curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Windows (Chocolatey) +choco install nodejs +``` + +### Make (Required) + +Required for running development commands via the Makefile. + +```bash +# Check if make is installed +make --version +``` + +If `make` is not installed, follow our [Install make command guide](/deployment-guides/how-to/install-make). + +### Docker (Optional) + +Only needed if you plan to build Docker images or test containerized deployments. + +```bash +# Check if Docker is installed +docker --version + +# Install from https://docs.docker.com/get-docker/ +``` + +### Air (Auto-installed) + +Air provides hot reloading during development. The Makefile will install it automatically when needed. + +## Clone the Repository + +```bash +# Clone the repository +git clone https://github.com/maximhq/bifrost.git +cd bifrost + +# Verify the repository structure +ls -la +``` + +You should see the main directories: `core/`, `framework/`, `transports/`, `ui/`, `plugins/`, `docs/`, etc. + +## Repository Structure + +Bifrost uses a modular architecture with the following structure: + +``` +bifrost/ +β”œβ”€β”€ core/ # Core functionality and shared components +β”‚ β”œβ”€β”€ providers/ # Provider-specific implementations (OpenAI, Anthropic, etc.) +β”‚ β”œβ”€β”€ schemas/ # Interfaces and structs used throughout Bifrost +β”‚ └── bifrost.go # Main Bifrost implementation +β”œβ”€β”€ framework/ # Framework components for common functionality +β”‚ β”œβ”€β”€ configstore/ # Configuration storages +β”‚ β”œβ”€β”€ logstore/ # Request logging storages +β”‚ └── vectorstore/ # Vector storages +β”œβ”€β”€ transports/ # HTTP gateway and other interface layers +β”‚ └── bifrost-http/ # HTTP transport implementation +β”œβ”€β”€ ui/ # Web interface for HTTP gateway +β”œβ”€β”€ plugins/ # First party plugins +β”œβ”€β”€ docs/ # Documentation and guides +└── tests/ # Comprehensive test suites +``` + +The system uses a provider-agnostic approach with well-defined interfaces in `core/schemas/` for easy extension to new AI providers. + +**Learn More About the Architecture:** +- **[Request Flow](/architecture/core/request-flow)** - Deep dive into how requests are processed from transport to provider +- **[Plugin System](/architecture/core/plugins)** - How plugins extend functionality +- **[Framework Components](/architecture/framework/what-is-framework)** - Shared storage and utilities +- **[MCP Integration](/architecture/core/mcp)** - Model Context Protocol implementation + +## Development Environment Setup + +### Quick Start (Recommended) + +The fastest way to get started is using the complete development environment: + +```bash +# Start complete development environment (UI + API with hot reload) +make dev +``` + +This command will: +1. Install UI dependencies automatically +2. Install Air for hot reloading +3. Set up the Go workspace with local modules +4. Start the Next.js development server (port 3000) +5. Start the API server with UI proxy (port 8080) + +**Access the application at:** http://localhost:8080 + +The `make dev` command handles all setup automatically. You can skip the manual setup steps below if this works for you. + +### Manual Setup (Alternative) + +If you prefer to set up components manually: + +#### 1. Install UI Dependencies + +```bash +# Install UI dependencies and tools +make install-ui +``` + +#### 2. Install Air for Hot Reloading + +```bash +# Install Air if not already installed +make install-air +``` + +#### 3. Set Up Go Workspace + +```bash +# Set up Go workspace with all local modules +make setup-workspace +``` + +This creates a `go.work` file that links all local modules for development. + +#### 4. Build the Application + +```bash +# Build UI and binary +make build +``` + +#### 5. Run the Application + +```bash +# Run without hot reload +make run + +# Or with hot reload (development) +make dev +``` + +## Available Make Commands + +The Makefile provides numerous commands for development: + +### Development Commands + +```bash +make dev # Start complete development environment (recommended) +make build # Build UI and bifrost-http binary +make run # Build and run (no hot reload) +make clean # Clean build artifacts +``` + +### Testing Commands + +```bash +make test # Run bifrost-http tests +make test-core # Run core tests +make test-plugins # Run plugin tests +make test-all # Run all tests +``` + +### Workspace Management + +```bash +make setup-workspace # Set up Go workspace for local development +make work-clean # Remove local go.work files +``` + +### UI Commands + +```bash +make install-ui # Install UI dependencies +make build-ui # Build UI for production +``` + +### Docker Commands + +```bash +make build-docker-image # Build Docker image +make docker-run # Run Docker container +``` + +### Documentation + +```bash +make docs # Start local documentation server +``` + +### Code Quality + +```bash +make lint # Run linter for Go code +make fmt # Format Go code +``` + +## Environment Variables + +You can customize the development environment with these variables: + +```bash +# Server configuration +HOST=localhost # Server host (default: localhost) +PORT=8080 # Server port (default: 8080) + +# Logging +LOG_STYLE=json # Logger format: json|pretty (default: json) +LOG_LEVEL=info # Logger level: debug|info|warn|error (default: info) + +# Prometheus +PROMETHEUS_LABELS="env=dev" # Labels for Prometheus metrics + +# App directory (for containers) +APP_DIR=/app/data # App data directory +``` + +Example with custom settings: + +```bash +PORT=3001 LOG_STYLE=pretty LOG_LEVEL=debug make dev +``` + +## Understanding Bifrost Architecture + +Before diving into development, it's helpful to understand how Bifrost works internally. The architecture documentation provides detailed insights into: + +### Core Components +- **[Request Flow](/architecture/core/request-flow)** - How requests flow through the system from transport to provider and back +- **[Concurrency](/architecture/core/concurrency)** - Worker pools and threading model +- **[MCP Integration](/architecture/core/mcp)** - Model Context Protocol implementation +- **[Plugin System](/architecture/core/plugins)** - How plugins extend core functionality + +### Framework Layer +- **[What is Framework](/architecture/framework/what-is-framework)** - Shared storage and utilities overview +- **[Config Store](/architecture/framework/config-store)** - Configuration persistence patterns +- **[Log Store](/architecture/framework/log-store)** - Request logging and analytics +- **[Vector Store](/architecture/framework/vector-store)** - Semantic search and caching + +### Plugins & Transports +- **[Plugin Architecture](/architecture/core/plugins)** - Plugin development patterns and execution model +- **[Transport Layer](/architecture/transports/in-memory-store)** - HTTP and other transport implementations + +Reading the architecture documentation will help you understand where to make changes and how different components interact. + +## Development Workflow + +### 1. Start Development Environment + +```bash +make dev +``` + +### 2. Make Your Changes + +- **Core changes**: Edit files in `core/` +- **API changes**: Edit files in `transports/bifrost-http/` +- **UI changes**: Edit files in `ui/` +- **Plugin changes**: Edit files in `plugins/` + +### 3. Test Your Changes + +```bash +# Run relevant tests +make test # HTTP transport tests +make test-core # Core functionality tests +make test-plugins # Plugin tests +make test-all # All tests +``` + +### 4. Verify Code Quality + +```bash +# Format code +make fmt + +# Run linter +make lint +``` + +### 5. Build for Production + +```bash +# Build everything +make build + +# Or build Docker image +make build-docker-image +``` + +## Troubleshooting + +### Common Issues + +**Go workspace issues:** +```bash +# Reset the workspace +make work-clean +make setup-workspace +``` + +**UI dependency issues:** +```bash +# Clean and reinstall UI dependencies +rm -rf ui/node_modules ui/.next +make install-ui +``` + +**Port conflicts:** +```bash +# Use different ports +PORT=9090 make dev +``` + +**Hot reload not working:** +```bash +# Ensure Air is installed +which air || go install github.com/air-verse/air@latest + +# Check if .air.toml exists in transports/bifrost-http/ +ls transports/bifrost-http/.air.toml +``` + +### Getting Help + +- **Check logs**: Development logs appear in your terminal +- **Verify prerequisites**: Ensure Go, Node.js, and make are properly installed +- **Clean build**: Run `make clean` and try again +- **Discord**: Join our [Discord community](https://discord.gg/bifrost) for real-time help + +## Next Steps + +Once your development environment is running: + +1. **Explore the UI**: Visit http://localhost:8080 to see the web interface +2. **Make API calls**: Test the API endpoints at http://localhost:8080/v1/ +3. **Understand the architecture**: Read our [request flow documentation](/architecture/core/request-flow) to understand how Bifrost works internally +4. **Read the documentation**: Check out our [complete documentation](https://docs.getbifrost.ai) +5. **Review contribution guidelines**: See our [code conventions](/contributing/code-conventions) and [PR guidelines](/contributing/raising-a-pr) + +## Quick Reference + +```bash +# Essential commands for daily development +make dev # Start development environment +make test-all # Run all tests +make fmt # Format code +make clean # Clean build artifacts +make help # Show all available commands +``` + +Happy coding! πŸš€ diff --git a/docs/deployment-guides/ecs.mdx b/docs/deployment-guides/ecs.mdx new file mode 100644 index 000000000..853afb00e --- /dev/null +++ b/docs/deployment-guides/ecs.mdx @@ -0,0 +1,1542 @@ +--- +title: "ECS" +description: "Deploy Bifrost as a service in ECS AWS clusters" +icon: "aws" +--- + +Deploy Bifrost on AWS ECS using either Makefile automation or direct AWS CLI commands. This guide covers both Fargate and EC2 launch types, with options for managing configuration secrets. + + +This guide assumes you already have: +- An ECS cluster +- VPC with subnets +- Security groups configured (must allow inbound traffic on port 8080 or your container port) +- (Optional) Application Load Balancer with target group + +**Security Group Requirements:** +- For direct access (no load balancer): Allow inbound traffic on port 8080 (or `CONTAINER_PORT`) from your IP or `0.0.0.0/0` +- For load balancer: Allow inbound traffic from the load balancer's security group + + +## Deployment Methods + +Choose your preferred deployment method: + + + + +## Quick Start with Makefile + +The easiest way to deploy Bifrost to ECS is using the provided Makefile. + + +**First-time deployment?** If you don't know your VPC ID or network configuration, run: +```bash +make list-ecs-network-resources +``` +This will list all available VPCs, subnets, and security groups in your AWS region. + + +```bash +# First, create your config.json file with your Bifrost configuration +cat > /tmp/bifrost-config.json < +**Network Configuration (*)**: +You must provide either `VPC_ID` OR `SUBNET_IDS`: +- **VPC_ID** (recommended): Automatically fetches all subnets in the VPC. Simpler and works across all availability zones. +- **SUBNET_IDS**: Specify exact subnet IDs if you want fine-grained control over subnet placement. + + +### Makefile Targets + +- `list-ecs-network-resources`: List available VPCs, subnets and security groups in your AWS region (helpful for first deployment) +- `deploy-ecs`: Complete deployment (creates secret if CONFIG_JSON_FILE provided, registers task definition, creates service, waits for stabilization, and shows deployment status) +- `create-ecs-secret`: Create/update configuration secret (requires CONFIG_JSON_FILE parameter) +- `register-ecs-task-definition`: Register new task definition (with or without secret) +- `create-ecs-service`: Create or update ECS service +- `update-ecs-service`: Force new deployment +- `tail-ecs-logs`: Continuously tail CloudWatch logs in real-time (Ctrl+C to exit) +- `ecs-status`: Show current service status, running tasks, and recent logs +- `get-ecs-url`: Get the public URL/IP to access the service (works with or without load balancer) +- `cleanup-ecs`: Remove service and deregister task definitions + + +**CONFIG_JSON_FILE Parameter**: This is optional. If provided, the Makefile will create a secret in AWS Secrets Manager or SSM Parameter Store and mount it in the ECS task. If omitted, the task will be deployed without a secret, and you can use other configuration methods (environment variables, mounted volumes, etc.). + +**How Configuration Secrets Work**: When `CONFIG_JSON_FILE` is provided, the deployment: +1. Stores your `config.json` in AWS Secrets Manager or SSM Parameter Store +2. Injects the secret as an environment variable `BIFROST_CONFIG` into the container +3. Uses a custom entrypoint that: + - Silently writes the secret content to `/app/data/config.json` + - Exits with error only if `BIFROST_CONFIG` is not set + - Then starts Bifrost normally +4. Bifrost reads the configuration from the file at startup + +This approach ensures your configuration is securely stored and properly mounted as a file, which is required by Bifrost. The entrypoint does not log any config data to keep logs clean and secure. + + + + + + +## Deployment with AWS CLI + +Deploy Bifrost to ECS using direct AWS CLI commands. This section provides step-by-step instructions for both Fargate and EC2 launch types. + + + + +### 1. Configuration Secret + +Choose between AWS Secrets Manager or SSM Parameter Store to store your Bifrost configuration. + + + + +Create a secret containing the Bifrost configuration with Postgres backend: + +```bash +# Create the configuration JSON +cat > /tmp/bifrost-config.json < + + +Create a parameter containing the Bifrost configuration: + +```bash +# Create the configuration JSON +cat > /tmp/bifrost-config.json < + + + +**Important**: The task definitions below include a custom `entryPoint` and `command` that: +1. Reads the `BIFROST_CONFIG` environment variable (injected from the secret) +2. Silently writes it to `/app/data/config.json` (where Bifrost expects the configuration file) +3. Exits with error if `BIFROST_CONFIG` is not set +4. Then starts the Bifrost application + +This is necessary because ECS injects secrets as environment variables, but Bifrost reads configuration from a file. The entrypoint does not log any config data to keep logs clean and secure. + + +### 2. Task Definition + +Create a task definition for Fargate with the configuration secret injected: + + + + +```bash +# Create task definition JSON +cat > /tmp/bifrost-task-definition.json < /app/data/config.json; else echo \"ERROR: BIFROST_CONFIG not set\" >&2 && exit 1; fi && exec /app/docker-entrypoint.sh /app/main"], + "portMappings": [ + { + "containerPort": 8080, + "protocol": "tcp" + } + ], + "secrets": [ + { + "name": "BIFROST_CONFIG", + "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:bifrost/config" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/bifrost-task", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "bifrost", + "awslogs-create-group": "true" + } + } + } + ] +} +EOF + +# Register the task definition +aws ecs register-task-definition \ + --cli-input-json file:///tmp/bifrost-task-definition.json \ + --region us-east-1 +``` + + +The `executionRoleArn` must have permissions to: +- Pull images from Docker Hub +- Read secrets from Secrets Manager +- Create CloudWatch log groups and streams + + + + + +```bash +# Create task definition JSON +cat > /tmp/bifrost-task-definition.json < /app/data/config.json; else echo \"ERROR: BIFROST_CONFIG not set\" >&2 && exit 1; fi && exec /app/docker-entrypoint.sh /app/main"], + "portMappings": [ + { + "containerPort": 8080, + "protocol": "tcp" + } + ], + "secrets": [ + { + "name": "BIFROST_CONFIG", + "valueFrom": "arn:aws:ssm:us-east-1:YOUR_ACCOUNT_ID:parameter/bifrost/config" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/bifrost-task", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "bifrost", + "awslogs-create-group": "true" + } + } + } + ] +} +EOF + +# Register the task definition +aws ecs register-task-definition \ + --cli-input-json file:///tmp/bifrost-task-definition.json \ + --region us-east-1 +``` + + +The `executionRoleArn` must have permissions to: +- Pull images from Docker Hub +- Read parameters from SSM Parameter Store +- Create CloudWatch log groups and streams + + + + + +### 3. Create ECS Service + + + + +```bash +aws ecs create-service \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --task-definition bifrost-task \ + --desired-count 1 \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx,subnet-yyy],securityGroups=[sg-xxx],assignPublicIp=ENABLED}" \ + --region us-east-1 +``` + + + + +```bash +aws ecs create-service \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --task-definition bifrost-task \ + --desired-count 1 \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx,subnet-yyy],securityGroups=[sg-xxx],assignPublicIp=ENABLED}" \ + --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:us-east-1:YOUR_ACCOUNT_ID:targetgroup/bifrost-tg/xxx,containerName=bifrost,containerPort=8080" \ + --health-check-grace-period-seconds 60 \ + --region us-east-1 +``` + + +When using an ALB: +- The security group must allow traffic from the ALB +- The target group health check should point to `/health` +- Set an appropriate health check grace period (60+ seconds) + + + + + +### 4. Update Service + +To deploy a new version or force a redeployment: + +```bash +aws ecs update-service \ + --cluster bifrost-cluster \ + --service bifrost-service \ + --force-new-deployment \ + --region us-east-1 +``` + + + + + +### 1. Configuration Secret + +Choose between AWS Secrets Manager or SSM Parameter Store to store your Bifrost configuration. + + + + +Create a secret containing the Bifrost configuration with Postgres backend: + +```bash +# Create the configuration JSON +cat > /tmp/bifrost-config.json < + + +Create a parameter containing the Bifrost configuration: + +```bash +# Create the configuration JSON +cat > /tmp/bifrost-config.json < + + + +**Important**: The task definitions below include a custom `entryPoint` and `command` that: +1. Reads the `BIFROST_CONFIG` environment variable (injected from the secret) +2. Silently writes it to `/app/data/config.json` (where Bifrost expects the configuration file) +3. Exits with error if `BIFROST_CONFIG` is not set +4. Then starts the Bifrost application + +This is necessary because ECS injects secrets as environment variables, but Bifrost reads configuration from a file. The entrypoint does not log any config data to keep logs clean and secure. + + +### 2. Task Definition + +Create a task definition for EC2 launch type with the configuration secret injected: + + + + +```bash +# Create task definition JSON +cat > /tmp/bifrost-task-definition.json < /app/data/config.json; else echo \"ERROR: BIFROST_CONFIG not set\" >&2 && exit 1; fi && exec /app/docker-entrypoint.sh /app/main"], + "portMappings": [ + { + "containerPort": 8080, + "protocol": "tcp" + } + ], + "secrets": [ + { + "name": "BIFROST_CONFIG", + "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:bifrost/config" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/bifrost-task", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "bifrost", + "awslogs-create-group": "true" + } + } + } + ] +} +EOF + +# Register the task definition +aws ecs register-task-definition \ + --cli-input-json file:///tmp/bifrost-task-definition.json \ + --region us-east-1 +``` + + +For EC2 launch type: +- CPU and memory are specified at the container level +- Ensure your EC2 instances have sufficient resources +- The ECS agent must be running on the instances + + + + + +```bash +# Create task definition JSON +cat > /tmp/bifrost-task-definition.json < /app/data/config.json; else echo \"ERROR: BIFROST_CONFIG not set\" >&2 && exit 1; fi && exec /app/docker-entrypoint.sh /app/main"], + "portMappings": [ + { + "containerPort": 8080, + "protocol": "tcp" + } + ], + "secrets": [ + { + "name": "BIFROST_CONFIG", + "valueFrom": "arn:aws:ssm:us-east-1:YOUR_ACCOUNT_ID:parameter/bifrost/config" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/bifrost-task", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "bifrost", + "awslogs-create-group": "true" + } + } + } + ] +} +EOF + +# Register the task definition +aws ecs register-task-definition \ + --cli-input-json file:///tmp/bifrost-task-definition.json \ + --region us-east-1 +``` + + + + +### 3. Create ECS Service + + + + +```bash +aws ecs create-service \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --task-definition bifrost-task \ + --desired-count 1 \ + --launch-type EC2 \ + --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx,subnet-yyy],securityGroups=[sg-xxx]}" \ + --region us-east-1 +``` + + + + +```bash +aws ecs create-service \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --task-definition bifrost-task \ + --desired-count 1 \ + --launch-type EC2 \ + --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx,subnet-yyy],securityGroups=[sg-xxx]}" \ + --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:us-east-1:YOUR_ACCOUNT_ID:targetgroup/bifrost-tg/xxx,containerName=bifrost,containerPort=8080" \ + --health-check-grace-period-seconds 60 \ + --region us-east-1 +``` + + + + +### 4. Update Service + +To deploy a new version or force a redeployment: + +```bash +aws ecs update-service \ + --cluster bifrost-cluster \ + --service bifrost-service \ + --force-new-deployment \ + --region us-east-1 +``` + + + + + + + + +## CloudFormation Deployment + +Deploy Bifrost to ECS using AWS CloudFormation for infrastructure as code management. + + +The CloudFormation template is available in the repository at `cloudformation/ecs-deployment.yaml`. +You can use it directly or customize it for your needs. + +**Configuration Secret Handling**: When you provide `ConfigSecretArn`, the template automatically: +1. Injects the secret as an environment variable `BIFROST_CONFIG` into the container +2. Uses a custom entrypoint that: + - Silently writes the secret content to `/app/data/config.json` + - Exits with error if secret is not set +3. This ensures Bifrost can read the configuration from the expected file location + +The entrypoint does not log any config data to keep logs clean and secure. + + +### CloudFormation Template + +The template (`cloudformation/ecs-deployment.yaml`): + +```yaml +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Deploy Bifrost service on ECS' + +Parameters: + ClusterName: + Type: String + Default: bifrost-cluster + Description: Name of the ECS cluster + + ServiceName: + Type: String + Default: bifrost-service + Description: Name of the ECS service + + TaskFamily: + Type: String + Default: bifrost-task + Description: Task definition family name + + ImageTag: + Type: String + Default: latest + Description: Bifrost Docker image tag + + LaunchType: + Type: String + Default: FARGATE + AllowedValues: + - FARGATE + - EC2 + Description: ECS launch type + + ContainerPort: + Type: Number + Default: 8080 + Description: Container port + + DesiredCount: + Type: Number + Default: 1 + Description: Desired number of tasks + + VpcId: + Type: AWS::EC2::VPC::Id + Description: VPC ID where the service will run + + SubnetIds: + Type: List + Description: Subnet IDs for the service (use public subnets for direct access) + + SecurityGroupIds: + Type: List + Description: Security group IDs (must allow inbound on ContainerPort) + + ConfigSecretArn: + Type: String + Default: '' + Description: (Optional) ARN of Secrets Manager secret or SSM parameter containing config.json + + ExecutionRoleArn: + Type: String + Default: '' + Description: (Optional) ECS task execution role ARN (will create default if not provided) + + TaskRoleArn: + Type: String + Default: '' + Description: (Optional) ECS task role ARN + + TargetGroupArn: + Type: String + Default: '' + Description: (Optional) ALB target group ARN for load balancing + + AssignPublicIp: + Type: String + Default: ENABLED + AllowedValues: + - ENABLED + - DISABLED + Description: Assign public IP to tasks (ENABLED for direct access without load balancer) + +Conditions: + IsFargate: !Equals [!Ref LaunchType, FARGATE] + HasSecret: !Not [!Equals [!Ref ConfigSecretArn, '']] + HasExecutionRole: !Not [!Equals [!Ref ExecutionRoleArn, '']] + HasTaskRole: !Not [!Equals [!Ref TaskRoleArn, '']] + HasTargetGroup: !Not [!Equals [!Ref TargetGroupArn, '']] + CreateExecutionRole: !And + - !Not [!Condition HasExecutionRole] + - !Condition IsFargate + +Resources: + # CloudWatch Log Group + LogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub '/ecs/${TaskFamily}' + RetentionInDays: 7 + + # ECS Task Execution Role (created only if not provided and using Fargate) + TaskExecutionRole: + Type: AWS::IAM::Role + Condition: CreateExecutionRole + Properties: + RoleName: !Sub '${ServiceName}-execution-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + Policies: + - PolicyName: SecretAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + - ssm:GetParameter + - ssm:GetParameters + Resource: + - !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:bifrost/*' + - !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/bifrost/*' + - Effect: Allow + Action: + - kms:Decrypt + Resource: '*' + + # ECS Task Definition + TaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: !Ref TaskFamily + NetworkMode: awsvpc + RequiresCompatibilities: + - !Ref LaunchType + Cpu: !If [IsFargate, '512', '256'] + Memory: !If [IsFargate, '1024', '512'] + ExecutionRoleArn: !If + - HasExecutionRole + - !Ref ExecutionRoleArn + - !If + - CreateExecutionRole + - !GetAtt TaskExecutionRole.Arn + - !Ref AWS::NoValue + TaskRoleArn: !If [HasTaskRole, !Ref TaskRoleArn, !Ref AWS::NoValue] + ContainerDefinitions: + - Name: bifrost + Image: !Sub 'maximhq/bifrost:${ImageTag}' + Essential: true + EntryPoint: !If + - HasSecret + - - /bin/sh + - -c + - !Ref AWS::NoValue + Command: !If + - HasSecret + - - 'if [ -n "$BIFROST_CONFIG" ]; then echo "$BIFROST_CONFIG" > /app/data/config.json; else echo "ERROR: BIFROST_CONFIG not set" >&2 && exit 1; fi && exec /app/docker-entrypoint.sh /app/main' + - !Ref AWS::NoValue + PortMappings: + - ContainerPort: !Ref ContainerPort + Protocol: tcp + Environment: [] + Secrets: !If + - HasSecret + - - Name: BIFROST_CONFIG + ValueFrom: !Ref ConfigSecretArn + - !Ref AWS::NoValue + HealthCheck: + Command: + - CMD-SHELL + - !Sub 'wget --no-verbose --tries=1 --spider http://localhost:${ContainerPort}/health || exit 1' + Interval: 30 + Timeout: 5 + Retries: 3 + StartPeriod: 60 + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: bifrost + + # ECS Service + Service: + Type: AWS::ECS::Service + Properties: + ServiceName: !Ref ServiceName + Cluster: !Ref ClusterName + TaskDefinition: !Ref TaskDefinition + DesiredCount: !Ref DesiredCount + LaunchType: !Ref LaunchType + NetworkConfiguration: + AwsvpcConfiguration: + Subnets: !Ref SubnetIds + SecurityGroups: !Ref SecurityGroupIds + AssignPublicIp: !Ref AssignPublicIp + LoadBalancers: !If + - HasTargetGroup + - - ContainerName: bifrost + ContainerPort: !Ref ContainerPort + TargetGroupArn: !Ref TargetGroupArn + - !Ref AWS::NoValue + HealthCheckGracePeriodSeconds: !If [HasTargetGroup, 60, !Ref AWS::NoValue] + +Outputs: + ServiceName: + Description: ECS Service Name + Value: !Ref Service + Export: + Name: !Sub '${AWS::StackName}-ServiceName' + + TaskDefinitionArn: + Description: Task Definition ARN + Value: !Ref TaskDefinition + Export: + Name: !Sub '${AWS::StackName}-TaskDefinitionArn' + + LogGroupName: + Description: CloudWatch Log Group + Value: !Ref LogGroup + Export: + Name: !Sub '${AWS::StackName}-LogGroupName' + + ExecutionRoleArn: + Condition: CreateExecutionRole + Description: Created Task Execution Role ARN + Value: !GetAtt TaskExecutionRole.Arn + Export: + Name: !Sub '${AWS::StackName}-ExecutionRoleArn' +``` + +### Deploy with CloudFormation + + + + +**Deploy without configuration secret:** + +```bash +aws cloudformation create-stack \ + --stack-name bifrost-ecs-stack \ + --template-body file://cloudformation/ecs-deployment.yaml \ + --parameters \ + ParameterKey=VpcId,ParameterValue=vpc-xxx \ + ParameterKey=SubnetIds,ParameterValue="subnet-xxx\,subnet-yyy" \ + ParameterKey=SecurityGroupIds,ParameterValue="sg-xxx" \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 + +# Wait for stack creation +aws cloudformation wait stack-create-complete \ + --stack-name bifrost-ecs-stack \ + --region us-east-1 + +# Get service details +aws cloudformation describe-stacks \ + --stack-name bifrost-ecs-stack \ + --region us-east-1 \ + --query 'Stacks[0].Outputs' +``` + +**Deploy with Secrets Manager:** + +First, create the secret: + +```bash +aws secretsmanager create-secret \ + --name bifrost/config \ + --secret-string file://config.json \ + --region us-east-1 + +# Get the secret ARN +SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id bifrost/config \ + --region us-east-1 \ + --query 'ARN' \ + --output text) +``` + +Then deploy with the secret: + +```bash +aws cloudformation create-stack \ + --stack-name bifrost-ecs-stack \ + --template-body file://cloudformation/ecs-deployment.yaml \ + --parameters \ + ParameterKey=VpcId,ParameterValue=vpc-xxx \ + ParameterKey=SubnetIds,ParameterValue="subnet-xxx\,subnet-yyy" \ + ParameterKey=SecurityGroupIds,ParameterValue="sg-xxx" \ + ParameterKey=ConfigSecretArn,ParameterValue=$SECRET_ARN \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 +``` + + + + +```bash +aws cloudformation create-stack \ + --stack-name bifrost-ecs-stack \ + --template-body file://cloudformation/ecs-deployment.yaml \ + --parameters \ + ParameterKey=VpcId,ParameterValue=vpc-xxx \ + ParameterKey=SubnetIds,ParameterValue="subnet-xxx\,subnet-yyy" \ + ParameterKey=SecurityGroupIds,ParameterValue="sg-xxx" \ + ParameterKey=TargetGroupArn,ParameterValue=arn:aws:elasticloadbalancing:... \ + ParameterKey=AssignPublicIp,ParameterValue=DISABLED \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 +``` + + +When using a load balancer, you can set `AssignPublicIp=DISABLED` if your tasks don't need direct internet access (they'll use NAT Gateway via the load balancer). + + + + + +```bash +aws cloudformation create-stack \ + --stack-name bifrost-ecs-stack \ + --template-body file://cloudformation/ecs-deployment.yaml \ + --parameters \ + ParameterKey=VpcId,ParameterValue=vpc-xxx \ + ParameterKey=SubnetIds,ParameterValue="subnet-xxx\,subnet-yyy" \ + ParameterKey=SecurityGroupIds,ParameterValue="sg-xxx" \ + ParameterKey=LaunchType,ParameterValue=EC2 \ + ParameterKey=ExecutionRoleArn,ParameterValue=arn:aws:iam::ACCOUNT:role/ecsTaskExecutionRole \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 +``` + + +For EC2 launch type, you must provide an existing `ExecutionRoleArn` as the template only auto-creates roles for Fargate. + + + + + +### Update Stack + +To update your deployment (e.g., change image tag or configuration): + +```bash +# Update the stack +aws cloudformation update-stack \ + --stack-name bifrost-ecs-stack \ + --template-body file://cloudformation/ecs-deployment.yaml \ + --parameters \ + ParameterKey=VpcId,UsePreviousValue=true \ + ParameterKey=SubnetIds,UsePreviousValue=true \ + ParameterKey=SecurityGroupIds,UsePreviousValue=true \ + ParameterKey=ImageTag,ParameterValue=v1.2.0 \ + --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 + +# Wait for update to complete +aws cloudformation wait stack-update-complete \ + --stack-name bifrost-ecs-stack \ + --region us-east-1 +``` + +### Get Service URL + +After deployment, get your service URL: + +```bash +# Get the task public IP (without load balancer) +TASK_ARN=$(aws ecs list-tasks \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --region us-east-1 \ + --query 'taskArns[0]' \ + --output text) + +ENI_ID=$(aws ecs describe-tasks \ + --cluster bifrost-cluster \ + --tasks $TASK_ARN \ + --region us-east-1 \ + --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' \ + --output text) + +PUBLIC_IP=$(aws ec2 describe-network-interfaces \ + --network-interface-ids $ENI_ID \ + --region us-east-1 \ + --query 'NetworkInterfaces[0].Association.PublicIp' \ + --output text) + +echo "Service URL: http://$PUBLIC_IP:8080" +echo "Health check: http://$PUBLIC_IP:8080/health" + +# Test the service +curl http://$PUBLIC_IP:8080/health +``` + +### Monitor Logs + +```bash +# Tail logs +aws logs tail /ecs/bifrost-task --follow --region us-east-1 + +# View recent logs +LOG_STREAM=$(aws logs describe-log-streams \ + --log-group-name /ecs/bifrost-task \ + --order-by LastEventTime \ + --descending \ + --max-items 1 \ + --region us-east-1 \ + --query 'logStreams[0].logStreamName' \ + --output text) + +aws logs get-log-events \ + --log-group-name /ecs/bifrost-task \ + --log-stream-name $LOG_STREAM \ + --region us-east-1 +``` + +### Delete Stack + +To remove all resources: + +```bash +aws cloudformation delete-stack \ + --stack-name bifrost-ecs-stack \ + --region us-east-1 + +# Wait for deletion +aws cloudformation wait stack-delete-complete \ + --stack-name bifrost-ecs-stack \ + --region us-east-1 +``` + +### CloudFormation Parameters Reference + +| Parameter | Default | Required | Description | +|-----------|---------|----------|-------------| +| `ClusterName` | `bifrost-cluster` | No | ECS cluster name (must exist) | +| `ServiceName` | `bifrost-service` | No | ECS service name | +| `TaskFamily` | `bifrost-task` | No | Task definition family | +| `ImageTag` | `latest` | No | Docker image tag | +| `LaunchType` | `FARGATE` | No | `FARGATE` or `EC2` | +| `ContainerPort` | `8080` | No | Container port | +| `DesiredCount` | `1` | No | Number of tasks | +| `VpcId` | - | **Yes** | VPC ID | +| `SubnetIds` | - | **Yes** | Comma-separated subnet IDs | +| `SecurityGroupIds` | - | **Yes** | Comma-separated security group IDs | +| `ConfigSecretArn` | (empty) | No | Secret/parameter ARN | +| `ExecutionRoleArn` | (empty) | No | Task execution role ARN | +| `TaskRoleArn` | (empty) | No | Task role ARN | +| `TargetGroupArn` | (empty) | No | ALB target group ARN | +| `AssignPublicIp` | `ENABLED` | No | Assign public IP to tasks | + + + + + +## IAM Permissions + +### Task Execution Role + +The task execution role (`ecsTaskExecutionRole`) needs the following permissions: + + +The Makefile automatically creates the CloudWatch log group `/ecs/bifrost-task`, so the execution role only needs `CreateLogStream` and `PutLogEvents` permissions, not `CreateLogGroup`. + + + + + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:*:*:log-group:/ecs/bifrost-task:*" + }, + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:bifrost/config*" + } + ] +} +``` + + + + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:*:*:log-group:/ecs/bifrost-task:*" + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameters", + "ssm:GetParameter" + ], + "Resource": "arn:aws:ssm:us-east-1:YOUR_ACCOUNT_ID:parameter/bifrost/config" + }, + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "arn:aws:kms:us-east-1:YOUR_ACCOUNT_ID:key/YOUR_KMS_KEY_ID" + } + ] +} +``` + + + + +## Accessing Your Service + +### Without Load Balancer + +When deployed without a load balancer, the ECS task gets a public IP address. You can find it using AWS CLI: + +```bash +# Get the public IP address of your running task +aws ec2 describe-network-interfaces \ + --network-interface-ids $(aws ecs describe-tasks \ + --cluster bifrost-cluster \ + --tasks $(aws ecs list-tasks \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --region us-east-1 \ + --query 'taskArns[0]' \ + --output text) \ + --region us-east-1 \ + --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' \ + --output text) \ + --region us-east-1 \ + --query 'NetworkInterfaces[0].Association.PublicIp' \ + --output text +``` + + +**Important Notes:** +- The public IP changes every time the task is restarted +- You must allow inbound traffic on port 8080 (or your `CONTAINER_PORT`) in your security group +- For production, consider using an Application Load Balancer for a stable endpoint + + +**Testing your deployment:** + +```bash +# Test health endpoint (replace YOUR_PUBLIC_IP with the IP from above) +curl http://YOUR_PUBLIC_IP:8080/health + +# Expected response +{"status":"ok"} +``` + +### With Load Balancer + +If you deployed with `TARGET_GROUP_ARN`, your service is accessible via the load balancer's DNS name: + +```bash +# Get the load balancer DNS name (replace YOUR_TARGET_GROUP_ARN with your actual ARN) +aws elbv2 describe-load-balancers \ + --load-balancer-arns $(aws elbv2 describe-target-groups \ + --target-group-arns YOUR_TARGET_GROUP_ARN \ + --region us-east-1 \ + --query 'TargetGroups[0].LoadBalancerArns[0]' \ + --output text) \ + --region us-east-1 \ + --query 'LoadBalancers[0].DNSName' \ + --output text + +# Test via load balancer (replace YOUR_ALB_DNS with the DNS from above) +curl http://YOUR_ALB_DNS/health +``` + +The load balancer provides: +- βœ… Stable DNS endpoint +- βœ… SSL/TLS termination (if configured) +- βœ… Health checks with automatic failover +- βœ… Multiple task load balancing + +## Monitoring and Logs + +### Tail Logs (Makefile) + +The easiest way to monitor your deployment logs: + +```bash +# Tail logs in real-time (press Ctrl+C to exit) +make tail-ecs-logs + +# Check service status and recent logs +make ecs-status +``` + + +The `deploy-ecs` command automatically waits for the deployment to stabilize and shows you: +- Deployment status (running/desired count) +- Task details (ARN, status, health) +- Recent logs (last 20 events) + +After deployment completes, use `make tail-ecs-logs` to continuously monitor your application. + + +### View Logs (AWS CLI) + +```bash +# Tail logs using AWS CLI v2 (recommended) +aws logs tail /ecs/bifrost-task --follow --region us-east-1 + +# Get log stream names +aws logs describe-log-streams \ + --log-group-name /ecs/bifrost-task \ + --order-by LastEventTime \ + --descending \ + --max-items 5 \ + --region us-east-1 + +# View logs from a specific stream +aws logs get-log-events \ + --log-group-name /ecs/bifrost-task \ + --log-stream-name bifrost/bifrost/TASK_ID \ + --region us-east-1 +``` + +### Check Service Status + +```bash +# Describe service +aws ecs describe-services \ + --cluster bifrost-cluster \ + --services bifrost-service \ + --region us-east-1 + +# List tasks +aws ecs list-tasks \ + --cluster bifrost-cluster \ + --service-name bifrost-service \ + --region us-east-1 + +# Describe task +aws ecs describe-tasks \ + --cluster bifrost-cluster \ + --tasks TASK_ARN \ + --region us-east-1 +``` + +## Cleanup + +To remove all ECS resources: + +```bash +# Using Makefile +make cleanup-ecs + +# Or manually +# Delete service +aws ecs update-service \ + --cluster bifrost-cluster \ + --service bifrost-service \ + --desired-count 0 \ + --region us-east-1 + +aws ecs delete-service \ + --cluster bifrost-cluster \ + --service bifrost-service \ + --region us-east-1 + +# Deregister task definitions +aws ecs list-task-definitions \ + --family-prefix bifrost-task \ + --region us-east-1 \ + --query 'taskDefinitionArns[]' \ + --output text | \ + xargs -n 1 aws ecs deregister-task-definition --task-definition --region us-east-1 + +# Delete secret (optional) +aws secretsmanager delete-secret \ + --secret-id bifrost/config \ + --force-delete-without-recovery \ + --region us-east-1 + +# Or delete SSM parameter (optional) +aws ssm delete-parameter \ + --name /bifrost/config \ + --region us-east-1 +``` \ No newline at end of file diff --git a/docs/deployment-guides/fly.mdx b/docs/deployment-guides/fly.mdx new file mode 100644 index 000000000..32a5ceb02 --- /dev/null +++ b/docs/deployment-guides/fly.mdx @@ -0,0 +1,34 @@ +--- +title: fly.io +description: "This guide explains how to deploy Bifrost on fly.io" +icon: "fly" +--- + +As `Bifrost` uses multiple sub-modules (`core`, `framework`, etc.) and also embeds the front-end into a single binary (embed.FS), we use a custom Docker build step before we hand over the deployment to flyctl. + +There are two ways to deploy Bifrost on Fly.io: + +1. By cloning the repo +2. Using flyctl + Docker Hub image + +## By cloning the repo + +1. Clone https://github.com/maximhq/bifrost +2. Ensure [Make](/deployment-guides/how-to/install-make) is installed. +3. Run `make deploy-to-fly-io APP_NAME=` + + +## Using flyctl + Docker Hub image + +1. Update your `fly.toml` to specify the Bifrost Docker Hub image. + +```toml +[build] +image = "maximhq/bifrost:latest" +``` + +2. Or you can specify the Docker Hub image path in the command: + +``` +fly deploy --app --image docker.io/maximhq/bifrost:latest +``` \ No newline at end of file diff --git a/docs/deployment-guides/how-to/install-make.mdx b/docs/deployment-guides/how-to/install-make.mdx new file mode 100644 index 000000000..4c2211d92 --- /dev/null +++ b/docs/deployment-guides/how-to/install-make.mdx @@ -0,0 +1,77 @@ +--- +title: "Install make command" +description: "This guide explains how to install make command." +icon: "compact-disc" +--- + + +## Windows + +### Option A: Chocolatey (easy) + +``` +# Run in an elevated PowerShell (Run as Administrator) +choco install make +# verify +make --version +``` + +### Option B: Scoop (no admin needed) +``` +# In a normal PowerShell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +iwr get.scoop.sh -useb | iex +scoop install make +make --version +``` + +### Option C: MSYS2 (full Unix-like env) + +``` +# 1) Install MSYS2 from https://www.msys2.org/ +# 2) In "MSYS2 MSYS" terminal: +pacman -Syu # then reopen terminal if asked +pacman -S make +make --version +``` + + Visual Studio’s nmake is a different tool (not GNU make). + +## Ubuntu / Debian + +``` +sudo apt update +# Pulls in compilers and common build tools, including make +sudo apt install build-essential +# (or just) sudo apt install make +make --version +``` + +## macOS + +### Option A: Xcode Command Line Tools (most common) + +``` +xcode-select --install # follow the prompt +make --version +``` + +This provides Apple’s/BSD-flavored make, which is fine for most projects. + +### Option B: Homebrew (get GNU make β‰₯ 4.x as gmake) + +``` +# Install Homebrew if needed: https://brew.sh +brew install make +gmake --version +``` + +If a project specifically requires GNU make as make, you can use: + +echo 'alias make="gmake"' >> ~/.zshrc && source ~/.zshrc + +## Troubleshooting tips + +- If make isn’t found, restart your terminal (or on Windows, open a new PowerShell) so your PATH updates. +- Run which make (where make on Windows) to confirm which binary you’re using. +- For Windows builds that depend on Unix tools (sed, grep, etc.), prefer MSYS2 or WSL for a smoother experience. \ No newline at end of file diff --git a/docs/deployment-guides/k8s.mdx b/docs/deployment-guides/k8s.mdx new file mode 100644 index 000000000..2cdcf7268 --- /dev/null +++ b/docs/deployment-guides/k8s.mdx @@ -0,0 +1,1672 @@ +--- +title: "Terraform + k8s" +description: "Deploy Bifrost as a service in Kubernetes clusters across AWS, Azure, and GCP using Terraform" +icon: "cloud" +--- + +Deploy Bifrost on Kubernetes using Terraform. This guide breaks down the deployment into individual components for better understanding. + + +If you are using Postgres/MySQL for config and log store, you can skip the Volume configuration and permission changes sections. + + + + + +## 1. Volume Configuration + +Create an EBS volume, persistent volume, and persistent volume claim for Bifrost data storage. + +```terraform +locals { + service_name = "bifrost-service" +} + +resource "aws_ebs_volume" "bifrost_disk" { + availability_zone = "${var.region}${var.main_zone}" + size = var.volume_size_gb + type = "gp3" + encrypted = true + + tags = { + Name = "bifrost-disk" + } + + lifecycle { + ignore_changes = [tags] + } +} + +resource "kubernetes_persistent_volume" "bifrost_volume" { + metadata { + name = "bifrost-volume" + } + spec { + capacity = { + storage = "${var.volume_size_gb}Gi" + } + access_modes = ["ReadWriteOnce"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "gp3" + persistent_volume_source { + aws_elastic_block_store { + volume_id = aws_ebs_volume.bifrost_disk.id + fs_type = "ext4" + } + } + } + depends_on = [aws_ebs_volume.bifrost_disk] + + lifecycle { + prevent_destroy = false + } +} + +resource "kubernetes_persistent_volume_claim" "bifrost_volume_claim" { + metadata { + name = "bifrost-volume-claim" + namespace = var.namespace + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${var.volume_size_gb}Gi" + } + } + storage_class_name = "gp3" + volume_name = "bifrost-volume" + } + depends_on = [kubernetes_persistent_volume.bifrost_volume] +} +``` + +## 2. Configuration Secret + +Create a Kubernetes secret to store Bifrost configuration with Postgres backend. + + +This configuration uses Postgres for both config store and logs store. The secret is mounted as a file at `/app/data/config.json` in the container. + + +```terraform +resource "kubernetes_secret" "bifrost_config" { + metadata { + name = "bifrost-config" + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + } + + data = { + "config.json" = jsonencode({ + "config_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + }, + "logs_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + } + }) + } + + type = "Opaque" + depends_on = [kubernetes_namespace.bifrost_namespace] +} +``` + +## 3. Deployment Configuration + +Create the Bifrost deployment with proper security contexts and volume mounts. + + +**Volume Permissions**: The deployment includes an init container that sets proper ownership (1000:1000) and permissions (755) on the mounted volume. This ensures the Bifrost container can read/write to the volume. +- `fs_group: 1000` sets the volume's group ownership +- `run_as_user: 1000` runs the container as non-root user +- Init container runs as root to fix permissions before the main container starts + + +```terraform +resource "kubernetes_deployment" "bifrost_deployment" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + env = var.env + } + } + + spec { + replicas = var.replica_count + + selector { + match_labels = { + app = local.service_name + } + } + + template { + metadata { + labels = { + app = local.service_name + env = var.env + } + } + + spec { + security_context { + fs_group = 1000 + fs_group_change_policy = "OnRootMismatch" + } + + init_container { + name = "fix-permissions" + image = "busybox:latest" + command = ["sh", "-c", "chown -R 1000:1000 /app/data && chmod -R 755 /app/data"] + + security_context { + run_as_user = 0 + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + } + + container { + name = "bifrost-service" + image = "maximhq/bifrost:${var.image_tag}" + + port { + container_port = 8080 + name = "http" + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + run_as_non_root = true + allow_privilege_escalation = false + } + + resources { + requests = { + cpu = "250m" + memory = "512Mi" + } + limits = { + cpu = "500m" + memory = "1Gi" + } + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + + volume_mount { + name = "config-volume" + mount_path = "/app/data/config.json" + sub_path = "config.json" + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + + volume { + name = "bifrost-volume" + persistent_volume_claim { + claim_name = "bifrost-volume-claim" + } + } + + volume { + name = "config-volume" + secret { + secret_name = kubernetes_secret.bifrost_config.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_secret.bifrost_config, kubernetes_persistent_volume_claim.bifrost_volume_claim] +} +``` + +## 4. Service Configuration + +Create a Kubernetes service to expose the Bifrost deployment. + +```terraform +resource "kubernetes_service" "bifrost_service" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + } + } + + spec { + selector = { + app = local.service_name + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} +``` + +## Complete Configuration + +Here's the complete Terraform configuration combining all components: + +```terraform +locals { + service_name = "bifrost-service" +} + +# Volume Configuration +resource "aws_ebs_volume" "bifrost_disk" { + availability_zone = "${var.region}${var.main_zone}" + size = var.volume_size_gb + type = "gp3" + encrypted = true + + tags = { + Name = "bifrost-disk" + } + + lifecycle { + ignore_changes = [tags] + } +} + +resource "kubernetes_persistent_volume" "bifrost_volume" { + metadata { + name = "bifrost-volume" + } + spec { + capacity = { + storage = "${var.volume_size_gb}Gi" + } + access_modes = ["ReadWriteOnce"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "gp3" + persistent_volume_source { + aws_elastic_block_store { + volume_id = aws_ebs_volume.bifrost_disk.id + fs_type = "ext4" + } + } + } + depends_on = [aws_ebs_volume.bifrost_disk] + + lifecycle { + prevent_destroy = false + } +} + +resource "kubernetes_persistent_volume_claim" "bifrost_volume_claim" { + metadata { + name = "bifrost-volume-claim" + namespace = var.namespace + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${var.volume_size_gb}Gi" + } + } + storage_class_name = "gp3" + volume_name = "bifrost-volume" + } + depends_on = [kubernetes_persistent_volume.bifrost_volume] +} + +# Configuration Secret +resource "kubernetes_secret" "bifrost_config" { + metadata { + name = "bifrost-config" + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + } + + data = { + "config.json" = jsonencode({ + "config_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + }, + "logs_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + } + }) + } + + type = "Opaque" + depends_on = [kubernetes_namespace.bifrost_namespace] +} + +# Deployment Configuration +resource "kubernetes_deployment" "bifrost_deployment" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + env = var.env + } + } + + spec { + replicas = var.replica_count + + selector { + match_labels = { + app = local.service_name + } + } + + template { + metadata { + labels = { + app = local.service_name + env = var.env + } + } + + spec { + security_context { + fs_group = 1000 + fs_group_change_policy = "OnRootMismatch" + } + + init_container { + name = "fix-permissions" + image = "busybox:latest" + command = ["sh", "-c", "chown -R 1000:1000 /app/data && chmod -R 755 /app/data"] + + security_context { + run_as_user = 0 + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + } + + container { + name = "bifrost-service" + image = "maximhq/bifrost:${var.image_tag}" + + port { + container_port = 8080 + name = "http" + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + run_as_non_root = true + allow_privilege_escalation = false + } + + resources { + requests = { + cpu = "250m" + memory = "512Mi" + } + limits = { + cpu = "500m" + memory = "1Gi" + } + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + + volume_mount { + name = "config-volume" + mount_path = "/app/data/config.json" + sub_path = "config.json" + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + + volume { + name = "bifrost-volume" + persistent_volume_claim { + claim_name = "bifrost-volume-claim" + } + } + + volume { + name = "config-volume" + secret { + secret_name = kubernetes_secret.bifrost_config.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_secret.bifrost_config, kubernetes_persistent_volume_claim.bifrost_volume_claim] +} + +# Service Configuration +resource "kubernetes_service" "bifrost_service" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + } + } + + spec { + selector = { + app = local.service_name + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} +``` + + + + + +## 1. Volume Configuration + +Create an Azure managed disk, persistent volume, and persistent volume claim for Bifrost data storage. + +```terraform +locals { + service_name = "bifrost-service" +} + +resource "azurerm_managed_disk" "bifrost_disk" { + name = "bifrost-disk" + location = var.region + resource_group_name = var.resource_group_name + storage_account_type = "Premium_LRS" + create_option = "Empty" + disk_size_gb = var.volume_size_gb + + lifecycle { + ignore_changes = [tags] + } +} + +resource "kubernetes_persistent_volume" "bifrost_volume" { + metadata { + name = "bifrost-volume" + } + spec { + capacity = { + storage = "${var.volume_size_gb}Gi" + } + access_modes = ["ReadWriteOnce"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "managed-premium" + persistent_volume_source { + azure_disk { + disk_name = azurerm_managed_disk.bifrost_disk.name + data_disk_uri = azurerm_managed_disk.bifrost_disk.id + kind = "Managed" + } + } + } + depends_on = [azurerm_managed_disk.bifrost_disk] + + lifecycle { + prevent_destroy = false + } +} + +resource "kubernetes_persistent_volume_claim" "bifrost_volume_claim" { + metadata { + name = "bifrost-volume-claim" + namespace = var.namespace + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${var.volume_size_gb}Gi" + } + } + storage_class_name = "managed-premium" + volume_name = "bifrost-volume" + } + depends_on = [kubernetes_persistent_volume.bifrost_volume] +} +``` + +## 2. Configuration Secret + +Create a Kubernetes secret to store Bifrost configuration with Postgres backend. + + +This configuration uses Postgres for both config store and logs store. The secret is mounted as a file at `/app/data/config.json` in the container. + + +```terraform +resource "kubernetes_secret" "bifrost_config" { + metadata { + name = "bifrost-config" + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + } + + data = { + "config.json" = jsonencode({ + "config_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + }, + "logs_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + } + }) + } + + type = "Opaque" + depends_on = [kubernetes_namespace.bifrost_namespace] +} +``` + +## 3. Deployment Configuration + +Create the Bifrost deployment with proper security contexts and volume mounts. + + +**Volume Permissions**: The deployment includes an init container that sets proper ownership (1000:1000) and permissions (755) on the mounted volume. This ensures the Bifrost container can read/write to the volume. +- `fs_group: 1000` sets the volume's group ownership +- `run_as_user: 1000` runs the container as non-root user +- Init container runs as root to fix permissions before the main container starts + + +```terraform +resource "kubernetes_deployment" "bifrost_deployment" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + env = var.env + } + } + + spec { + replicas = var.replica_count + + selector { + match_labels = { + app = local.service_name + } + } + + template { + metadata { + labels = { + app = local.service_name + env = var.env + } + } + + spec { + security_context { + fs_group = 1000 + fs_group_change_policy = "OnRootMismatch" + } + + init_container { + name = "fix-permissions" + image = "busybox:latest" + command = ["sh", "-c", "chown -R 1000:1000 /app/data && chmod -R 755 /app/data"] + + security_context { + run_as_user = 0 + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + } + + container { + name = "bifrost-service" + image = "maximhq/bifrost:${var.image_tag}" + + port { + container_port = 8080 + name = "http" + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + run_as_non_root = true + allow_privilege_escalation = false + } + + resources { + requests = { + cpu = "250m" + memory = "512Mi" + } + limits = { + cpu = "500m" + memory = "1Gi" + } + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + + volume_mount { + name = "config-volume" + mount_path = "/app/data/config.json" + sub_path = "config.json" + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + + volume { + name = "bifrost-volume" + persistent_volume_claim { + claim_name = "bifrost-volume-claim" + } + } + + volume { + name = "config-volume" + secret { + secret_name = kubernetes_secret.bifrost_config.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_secret.bifrost_config, kubernetes_persistent_volume_claim.bifrost_volume_claim] +} +``` + +## 4. Service Configuration + +Create a Kubernetes service to expose the Bifrost deployment. + +```terraform +resource "kubernetes_service" "bifrost_service" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + } + } + + spec { + selector = { + app = local.service_name + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} +``` + +## Complete Configuration + +Here's the complete Terraform configuration combining all components: + +```terraform +locals { + service_name = "bifrost-service" +} + +# Volume Configuration +resource "azurerm_managed_disk" "bifrost_disk" { + name = "bifrost-disk" + location = var.region + resource_group_name = var.resource_group_name + storage_account_type = "Premium_LRS" + create_option = "Empty" + disk_size_gb = var.volume_size_gb + + lifecycle { + ignore_changes = [tags] + } +} + +resource "kubernetes_persistent_volume" "bifrost_volume" { + metadata { + name = "bifrost-volume" + } + spec { + capacity = { + storage = "${var.volume_size_gb}Gi" + } + access_modes = ["ReadWriteOnce"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "managed-premium" + persistent_volume_source { + azure_disk { + disk_name = azurerm_managed_disk.bifrost_disk.name + data_disk_uri = azurerm_managed_disk.bifrost_disk.id + kind = "Managed" + } + } + } + depends_on = [azurerm_managed_disk.bifrost_disk] + + lifecycle { + prevent_destroy = false + } +} + +resource "kubernetes_persistent_volume_claim" "bifrost_volume_claim" { + metadata { + name = "bifrost-volume-claim" + namespace = var.namespace + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${var.volume_size_gb}Gi" + } + } + storage_class_name = "managed-premium" + volume_name = "bifrost-volume" + } + depends_on = [kubernetes_persistent_volume.bifrost_volume] +} + +# Configuration Secret +resource "kubernetes_secret" "bifrost_config" { + metadata { + name = "bifrost-config" + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + } + + data = { + "config.json" = jsonencode({ + "config_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + }, + "logs_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + } + }) + } + + type = "Opaque" + depends_on = [kubernetes_namespace.bifrost_namespace] +} + +# Deployment Configuration +resource "kubernetes_deployment" "bifrost_deployment" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + env = var.env + } + } + + spec { + replicas = var.replica_count + + selector { + match_labels = { + app = local.service_name + } + } + + template { + metadata { + labels = { + app = local.service_name + env = var.env + } + } + + spec { + security_context { + fs_group = 1000 + fs_group_change_policy = "OnRootMismatch" + } + + init_container { + name = "fix-permissions" + image = "busybox:latest" + command = ["sh", "-c", "chown -R 1000:1000 /app/data && chmod -R 755 /app/data"] + + security_context { + run_as_user = 0 + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + } + + container { + name = "bifrost-service" + image = "maximhq/bifrost:${var.image_tag}" + + port { + container_port = 8080 + name = "http" + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + run_as_non_root = true + allow_privilege_escalation = false + } + + resources { + requests = { + cpu = "250m" + memory = "512Mi" + } + limits = { + cpu = "500m" + memory = "1Gi" + } + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + + volume_mount { + name = "config-volume" + mount_path = "/app/data/config.json" + sub_path = "config.json" + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + + volume { + name = "bifrost-volume" + persistent_volume_claim { + claim_name = "bifrost-volume-claim" + } + } + + volume { + name = "config-volume" + secret { + secret_name = kubernetes_secret.bifrost_config.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_secret.bifrost_config, kubernetes_persistent_volume_claim.bifrost_volume_claim] +} + +# Service Configuration +resource "kubernetes_service" "bifrost_service" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + } + } + + spec { + selector = { + app = local.service_name + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} +``` + + + + + +## 1. Volume Configuration + +Create a GCP persistent disk, persistent volume, and persistent volume claim for Bifrost data storage. + +```terraform +locals { + service_name = "bifrost-service" +} + +resource "google_compute_disk" "bifrost_disk" { + name = "bifrost-disk" + size = var.volume_size_gb + type = "pd-ssd" + zone = "${var.region}-${var.main_zone}" + + lifecycle { + ignore_changes = [labels] + } +} + +resource "kubernetes_persistent_volume" "bifrost_volume" { + metadata { + name = "bifrost-volume" + } + spec { + capacity = { + storage = "${var.volume_size_gb}Gi" + } + access_modes = ["ReadWriteOnce"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "premium-rwo" + persistent_volume_source { + gce_persistent_disk { + pd_name = "bifrost-disk" + } + } + } + depends_on = [google_compute_disk.bifrost_disk] + + lifecycle { + prevent_destroy = false + } +} + +resource "kubernetes_persistent_volume_claim" "bifrost_volume_claim" { + metadata { + name = "bifrost-volume-claim" + namespace = var.namespace + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${var.volume_size_gb}Gi" + } + } + storage_class_name = "premium-rwo" + volume_name = "bifrost-volume" + } + depends_on = [kubernetes_persistent_volume.bifrost_volume] +} +``` + +## 2. Configuration Secret + +Create a Kubernetes secret to store Bifrost configuration with Postgres backend. + + +This configuration uses Postgres for both config store and logs store. The secret is mounted as a file at `/app/data/config.json` in the container. + + +```terraform +resource "kubernetes_secret" "bifrost_config" { + metadata { + name = "bifrost-config" + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + } + + data = { + "config.json" = jsonencode({ + "config_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + }, + "logs_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + } + }) + } + + type = "Opaque" + depends_on = [kubernetes_namespace.bifrost_namespace] +} +``` + +## 3. Deployment Configuration + +Create the Bifrost deployment with proper security contexts and volume mounts. + + +**Volume Permissions**: The deployment includes an init container that sets proper ownership (1000:1000) and permissions (755) on the mounted volume. This ensures the Bifrost container can read/write to the volume. +- `fs_group: 1000` sets the volume's group ownership +- `run_as_user: 1000` runs the container as non-root user +- Init container runs as root to fix permissions before the main container starts + + +```terraform +resource "kubernetes_deployment" "bifrost_deployment" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + env = var.env + } + } + + spec { + replicas = var.replica_count + + selector { + match_labels = { + app = local.service_name + } + } + + template { + metadata { + labels = { + app = local.service_name + env = var.env + } + } + + spec { + security_context { + fs_group = 1000 + fs_group_change_policy = "OnRootMismatch" + } + + init_container { + name = "fix-permissions" + image = "busybox:latest" + command = ["sh", "-c", "chown -R 1000:1000 /app/data && chmod -R 755 /app/data"] + + security_context { + run_as_user = 0 + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + } + + container { + name = "bifrost-service" + image = "maximhq/bifrost:${var.image_tag}" + + port { + container_port = 8080 + name = "http" + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + run_as_non_root = true + allow_privilege_escalation = false + } + + resources { + requests = { + cpu = "250m" + memory = "512Mi" + } + limits = { + cpu = "500m" + memory = "1Gi" + } + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + + volume_mount { + name = "config-volume" + mount_path = "/app/data/config.json" + sub_path = "config.json" + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + + volume { + name = "bifrost-volume" + persistent_volume_claim { + claim_name = "bifrost-volume-claim" + } + } + + volume { + name = "config-volume" + secret { + secret_name = kubernetes_secret.bifrost_config.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_secret.bifrost_config, kubernetes_persistent_volume_claim.bifrost_volume_claim] +} +``` + +## 4. Service Configuration + +Create a Kubernetes service to expose the Bifrost deployment. + +```terraform +resource "kubernetes_service" "bifrost_service" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + } + } + + spec { + selector = { + app = local.service_name + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} +``` + +## Complete Configuration + +Here's the complete Terraform configuration combining all components: + +```terraform +locals { + service_name = "bifrost-service" +} + +# Volume Configuration +resource "google_compute_disk" "bifrost_disk" { + name = "bifrost-disk" + size = var.volume_size_gb + type = "pd-ssd" + zone = "${var.region}-${var.main_zone}" + + lifecycle { + ignore_changes = [labels] + } +} + +resource "kubernetes_persistent_volume" "bifrost_volume" { + metadata { + name = "bifrost-volume" + } + spec { + capacity = { + storage = "${var.volume_size_gb}Gi" + } + access_modes = ["ReadWriteOnce"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "premium-rwo" + persistent_volume_source { + gce_persistent_disk { + pd_name = "bifrost-disk" + } + } + } + depends_on = [google_compute_disk.bifrost_disk] + + lifecycle { + prevent_destroy = false + } +} + +resource "kubernetes_persistent_volume_claim" "bifrost_volume_claim" { + metadata { + name = "bifrost-volume-claim" + namespace = var.namespace + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${var.volume_size_gb}Gi" + } + } + storage_class_name = "premium-rwo" + volume_name = "bifrost-volume" + } + depends_on = [kubernetes_persistent_volume.bifrost_volume] +} + +# Configuration Secret +resource "kubernetes_secret" "bifrost_config" { + metadata { + name = "bifrost-config" + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + } + + data = { + "config.json" = jsonencode({ + "config_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + }, + "logs_store" : { + "enabled" : true, + "type" : "postgres", + "config" : { + "host" : "${var.pg_host}", + "port" : "${var.pg_port}", + "user" : "${var.pg_user}", + "password" : "${var.pg_password}", + "db_name" : "${var.pg_database}", + "ssl_mode": "disable" + } + } + }) + } + + type = "Opaque" + depends_on = [kubernetes_namespace.bifrost_namespace] +} + +# Deployment Configuration +resource "kubernetes_deployment" "bifrost_deployment" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + env = var.env + } + } + + spec { + replicas = var.replica_count + + selector { + match_labels = { + app = local.service_name + } + } + + template { + metadata { + labels = { + app = local.service_name + env = var.env + } + } + + spec { + security_context { + fs_group = 1000 + fs_group_change_policy = "OnRootMismatch" + } + + init_container { + name = "fix-permissions" + image = "busybox:latest" + command = ["sh", "-c", "chown -R 1000:1000 /app/data && chmod -R 755 /app/data"] + + security_context { + run_as_user = 0 + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + } + + container { + name = "bifrost-service" + image = "maximhq/bifrost:${var.image_tag}" + + port { + container_port = 8080 + name = "http" + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + run_as_non_root = true + allow_privilege_escalation = false + } + + resources { + requests = { + cpu = "250m" + memory = "512Mi" + } + limits = { + cpu = "500m" + memory = "1Gi" + } + } + + volume_mount { + name = "bifrost-volume" + mount_path = "/app/data" + } + + volume_mount { + name = "config-volume" + mount_path = "/app/data/config.json" + sub_path = "config.json" + } + + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/health" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + + volume { + name = "bifrost-volume" + persistent_volume_claim { + claim_name = "bifrost-volume-claim" + } + } + + volume { + name = "config-volume" + secret { + secret_name = kubernetes_secret.bifrost_config.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_secret.bifrost_config, kubernetes_persistent_volume_claim.bifrost_volume_claim] +} + +# Service Configuration +resource "kubernetes_service" "bifrost_service" { + metadata { + name = local.service_name + namespace = kubernetes_namespace.bifrost_namespace.metadata[0].name + labels = { + app = local.service_name + } + } + + spec { + selector = { + app = local.service_name + } + + port { + name = "http" + port = 80 + target_port = 8080 + protocol = "TCP" + } + + type = "ClusterIP" + } +} +``` + + + \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 000000000..ff9d1f6fb --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,299 @@ +{ + "$schema": "https://mintlify.com/schema.json", + "name": "Bifrost", + "logo": { + "dark": "/media/bifrost-logo-dark.png", + "light": "/media/bifrost-logo.png" + }, + "theme": "mint", + "colors": { + "primary": "#0C3B43", + "light": "#07C983" + }, + "favicon":"favicon.ico", + "topbarLinks": [ + { + "name": "Support", + "url": "mailto:akshay@getmaxim.ai" + } + ], + "topbarCtaButton": { + "name": "Dashboard", + "url": "https://www.getbifrost.ai" + }, + "anchors": [ + { + "name": "Community", + "icon": "discord", + "url": "https://getmax.im/bifrost-discord" + }, + { + "name": "Blog", + "icon": "newspaper", + "url": "https://getmaxim.ai/blog" + } + ], + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "icon": "book-open-cover", + "groups": [ + { + "group": "Quick Start", + "icon": "rocket", + "pages": [ + { + "group": "Gateway", + "icon": "server", + "pages": [ + "quickstart/gateway/setting-up", + "quickstart/gateway/setting-up-auth", + "quickstart/gateway/provider-configuration", + "quickstart/gateway/streaming", + "quickstart/gateway/tool-calling", + "quickstart/gateway/multimodal", + "quickstart/gateway/integrations", + "quickstart/gateway/cli-agents" + ] + }, + { + "group": "Use as Go SDK", + "icon": "code", + "pages": [ + "quickstart/go-sdk/setting-up", + "quickstart/go-sdk/provider-configuration", + "quickstart/go-sdk/streaming", + "quickstart/go-sdk/tool-calling", + "quickstart/go-sdk/multimodal" + ] + } + ] + }, + { + "group": "Models Catalog", + "icon": "box", + "pages": [ + "models-catalog/list" + ] + }, + { + "group": "Provider Integrations", + "icon": "plug", + "pages": [ + "integrations/what-is-an-integration", + "integrations/openai-sdk", + "integrations/anthropic-sdk", + "integrations/genai-sdk", + "integrations/litellm-sdk", + "integrations/langchain-sdk" + ] + }, + { + "group": "Custom plugins", + "icon": "puzzle-piece", + "pages": [ + "plugins/getting-started", + "plugins/writing-plugin" + ] + }, + { + "group": "Open Source Features", + "icon": "bolt", + "pages": [ + "features/unified-interface", + "features/drop-in-replacement", + "features/fallbacks", + "features/keys-management", + "features/mcp", + { + "group": "Governance", + "icon": "user-lock", + "pages": [ + "features/governance/virtual-keys", + "features/governance/routing", + "features/governance/budget-and-limits", + "features/governance/mcp-tools" + ] + }, + { + "group": "Observability", + "icon": "binoculars", + "pages": [ + "features/observability/default", + { + "group": "Connectors", + "icon": "arrows-left-right-to-line", + "pages": [ + "features/observability/maxim", + "features/observability/otel" + ] + } + ] + }, + "features/telemetry", + "features/semantic-caching", + "features/custom-providers", + { + "group": "Plugins", + "icon": "puzzle-piece", + "pages": [ + "features/plugins/mocker", + "features/plugins/jsonparser" + ] + } + ] + }, + { + "group": "Enterprise Features", + "icon": "building", + "pages": [ + "enterprise/guardrails", + "enterprise/clustering", + "enterprise/advanced-governance", + "enterprise/mcp-with-fa", + "enterprise/vault-support", + "enterprise/invpc-deployments", + "enterprise/intelligent-load-balancing", + "enterprise/custom-plugins", + "enterprise/audit-logs", + "enterprise/log-exports" + ] + } + ] + }, + { + "tab": "Developer Guides", + "icon": "wrench", + "groups": [ + { + "group": "Contributing", + "icon": "code", + "pages": [ + "contributing/setting-up-repo" + ] + } + ] + }, + { + "tab": "Deployment Guides", + "icon": "server", + "pages": [ + { + "group": "Platform specific guides", + "icon": "swatchbook", + "pages": [ + "deployment-guides/k8s", + "deployment-guides/ecs", + "deployment-guides/fly" + ] + }, + { + "group": "Common setup instructions", + "icon": "book", + "pages": [ + "deployment-guides/how-to/install-make" + ] + } + ] + }, + { + "tab": "API Reference", + "icon": "code", + "groups": [ + { + "group": "API Reference", + "openapi": "apis/openapi.json" + } + ] + }, + { + "tab": "Architecture", + "icon": "codepen", + "pages": [ + { + "group": "Core Architecture", + "icon": "sitemap", + "pages": [ + "architecture/core/concurrency", + "architecture/core/request-flow", + "architecture/core/mcp", + "architecture/core/plugins" + ] + }, + { + "group": "Framework", + "icon": "screwdriver-wrench", + "pages": [ + "architecture/framework/what-is-framework", + "architecture/framework/model-catalog", + "architecture/framework/config-store", + "architecture/framework/log-store", + "architecture/framework/vector-store", + "architecture/framework/streaming" + ] + } + ] + }, + { + "tab": "Benchmarks", + "icon": "chart-line", + "pages": [ + "benchmarking/getting-started", + "benchmarking/t3.medium", + "benchmarking/t3.xl", + "benchmarking/run-your-own-benchmarks" + ] + }, + { + "tab": "Changelogs", + "icon": "bolt", + "pages": [ + "changelogs/v1.3.24", + "changelogs/v1.3.23", + "changelogs/v1.3.22", + "changelogs/v1.3.21", + "changelogs/v1.3.20", + "changelogs/v1.3.19", + "changelogs/v1.3.18", + "changelogs/v1.3.17", + "changelogs/v1.3.16", + "changelogs/v1.3.15", + "changelogs/v1.3.14", + "changelogs/v1.3.13", + "changelogs/v1.3.12", + "changelogs/v1.3.11", + "changelogs/v1.3.10", + "changelogs/v1.3.9", + "changelogs/v1.3.8", + "changelogs/v1.3.7", + "changelogs/v1.3.6", + "changelogs/v1.3.5", + "changelogs/v1.3.4", + "changelogs/v1.3.3", + "changelogs/v1.3.2", + "changelogs/v1.3.1", + "changelogs/v1.3.0", + "changelogs/v1.3.0-prerelease7", + "changelogs/v1.3.0-prerelease6", + "changelogs/v1.3.0-prerelease5", + "changelogs/v1.3.0-prerelease4", + "changelogs/v1.3.0-prerelease3", + "changelogs/v1.3.0-prerelease2", + "changelogs/v1.3.0-prerelease1", + "changelogs/v1.2.24", + "changelogs/v1.2.23", + "changelogs/v1.2.22", + "changelogs/v1.2.21" + ] + } + ] + }, + "footer": { + "socials": { + "x": "https://x.com/getmaximai", + "github": "https://github.com/maximhq/bifrost", + "linkedin": "https://linkedin.com/company/maxim-ai" + } + } +} diff --git a/docs/enterprise/advanced-governance.mdx b/docs/enterprise/advanced-governance.mdx new file mode 100644 index 000000000..52113ca19 --- /dev/null +++ b/docs/enterprise/advanced-governance.mdx @@ -0,0 +1,797 @@ +--- +title: "Advanced Governance" +description: "Advanced governance features with enhanced security, compliance reporting, audit trails, and enterprise-grade access controls for large-scale deployments." +icon: "shield-check" +--- + +## Overview + +Enterprise Governance extends Bifrost's [core governance capabilities](../features/governance) with advanced security, compliance, and user management features designed for large-scale enterprise deployments. This module provides comprehensive identity management, regulatory compliance, and detailed audit capabilities. + +**Enterprise Extensions:** +- **Identity & Access Management** - SAML 2.0 and OpenID Connect integration +- **Directory Services** - Active Directory and LDAP user synchronization +- **User-Level Governance** - Individual user authentication and budget allocation +- **Compliance Framework** - SOC 2 Type II, GDPR, ISO 27001, and HIPAA compliance +- **Advanced Auditing** - Comprehensive audit reports and compliance dashboards + +**Builds Upon Core Governance:** +- All standard [Virtual Keys, Teams, and Customers](../features/governance) functionality +- Hierarchical budget management and rate limiting +- Model and provider access controls +- Usage tracking and cost management + +--- + +## SAML & OpenID Connect Integration + +Enterprise Governance provides seamless integration with corporate identity providers through industry-standard authentication protocols. + +### SAML 2.0 Configuration + +**Supported Identity Providers:** +- Microsoft Azure AD / Entra ID +- Okta +- Google Workspace +- Ping Identity (Coming soon) +- Auth0 + + + + +1. **Navigate to Enterprise Settings** + - Open Bifrost UI at `http://localhost:8080` + - Go to **Enterprise** β†’ **Identity Providers** + +2. **Configure SAML Provider** + +**Required Fields:** +- **Provider Name**: Identity provider identifier +- **SSO URL**: SAML SSO endpoint +- **Entity ID**: SAML entity identifier +- **X.509 Certificate**: Identity provider signing certificate + +**Attribute Mapping:** +- **Email Attribute**: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` +- **Name Attribute**: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` +- **Groups Attribute**: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/groups` +- **Department Attribute**: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department` + +**User Provisioning:** +- **Auto-Create Users**: Automatically create users on first login +- **Default Customer**: Assign new users to default customer +- **Default Team**: Assign new users to default team +- **Default Budget**: Initial budget allocation per user + +3. **Save Configuration** + - Click **Configure SAML Provider** + - Test SSO integration + - Enable for production use + + + + +**Configure SAML Provider:** +```bash +curl -X POST http://localhost:8080/api/enterprise/identity-providers \ + -H "Content-Type: application/json" \ + -d '{ + "type": "saml", + "name": "Azure AD Corporate", + "config": { + "sso_url": "https://login.microsoftonline.com/tenant-id/saml2", + "entity_id": "https://sts.windows.net/tenant-id/", + "x509_certificate": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "attribute_mapping": { + "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + "groups": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/groups", + "department": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department" + }, + "user_provisioning": { + "auto_create": true, + "default_customer_id": "customer-corp", + "default_team_id": "team-general", + "default_budget": { + "max_limit": 50.00, + "reset_duration": "1M" + } + } + }, + "is_active": true + }' +``` + +**Test SAML Configuration:** +```bash +curl -X POST http://localhost:8080/api/enterprise/identity-providers/{provider_id}/test \ + -H "Content-Type: application/json" \ + -d '{ + "test_user_email": "test@company.com" + }' +``` + + + + +```json +{ + "enterprise": { + "identity_providers": [ + { + "id": "saml-azure-ad", + "type": "saml", + "name": "Azure AD Corporate", + "config": { + "sso_url": "https://login.microsoftonline.com/tenant-id/saml2", + "entity_id": "https://sts.windows.net/tenant-id/", + "x509_certificate": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "attribute_mapping": { + "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + "groups": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/groups", + "department": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department" + }, + "user_provisioning": { + "auto_create": true, + "default_customer_id": "customer-corp", + "default_team_id": "team-general", + "default_budget": { + "max_limit": 50.00, + "reset_duration": "1M" + } + } + }, + "is_active": true + } + ] + } +} +``` + + + + +### OpenID Connect Configuration + +**Supported Providers:** +- Google Workspace +- Microsoft Azure AD +- Okta +- Auth0 +- Keycloak +- Generic OIDC providers + + + + +1. **Navigate to Identity Providers** + - Go to **Enterprise** β†’ **Identity Providers** + - Click **Add OpenID Connect Provider** + +2. **Configure OIDC Provider** + +**Required Fields:** +- **Provider Name**: OIDC provider identifier +- **Client ID**: Application client identifier +- **Client Secret**: Application client secret +- **Discovery URL**: OIDC discovery endpoint +- **Scopes**: Required OAuth scopes + +**Advanced Settings:** +- **Token Validation**: JWT signature verification +- **Group Claims**: Map OIDC groups to Bifrost teams +- **Role Claims**: Map OIDC roles to permissions + + + + +**Configure OIDC Provider:** +```bash +curl -X POST http://localhost:8080/api/enterprise/identity-providers \ + -H "Content-Type: application/json" \ + -d '{ + "type": "oidc", + "name": "Google Workspace", + "config": { + "client_id": "client-id.apps.googleusercontent.com", + "client_secret": "client-secret", + "discovery_url": "https://accounts.google.com/.well-known/openid_configuration", + "scopes": ["openid", "email", "profile", "groups"], + "claims_mapping": { + "email": "email", + "name": "name", + "groups": "groups", + "department": "department" + }, + "user_provisioning": { + "auto_create": true, + "group_team_mapping": { + "engineering@company.com": "team-eng-001", + "sales@company.com": "team-sales-001" + } + } + }, + "is_active": true + }' +``` + + + + +--- + +## Active Directory Integration + +Enterprise Governance provides native integration with Microsoft Active Directory and LDAP directories for automated user provisioning and group synchronization. + +### Active Directory Configuration + +**Features:** +- **User Synchronization** - Automatic user import and updates +- **Group Mapping** - AD groups to Bifrost teams/customers +- **Attribute Mapping** - Custom user attribute synchronization +- **Scheduled Sync** - Automated periodic synchronization + + + + +1. **Navigate to Directory Services** + - Go to **Enterprise** β†’ **Directory Services** + - Click **Configure Active Directory** + +2. **Connection Settings** + +**Required Fields:** +- **Domain Controller**: AD server hostname/IP +- **Base DN**: Directory search base +- **Bind DN**: Service account distinguished name +- **Bind Password**: Service account password +- **Port**: LDAP port (389 or 636 for SSL) + +**Sync Settings:** +- **User Filter**: LDAP filter for user objects +- **Group Filter**: LDAP filter for group objects +- **Sync Schedule**: Automated sync frequency +- **Sync Scope**: Full or incremental synchronization + +3. **Attribute Mapping** + +**User Attributes:** +- **Email**: `mail` or `userPrincipalName` +- **Display Name**: `displayName` +- **Department**: `department` +- **Manager**: `manager` +- **Employee ID**: `employeeID` + +**Group Mapping:** +- Map AD groups to Bifrost teams +- Set default customer assignments +- Configure budget inheritance + + + + +**Configure Active Directory:** +```bash +curl -X POST http://localhost:8080/api/enterprise/directory-services \ + -H "Content-Type: application/json" \ + -d '{ + "type": "active_directory", + "name": "Corporate AD", + "config": { + "connection": { + "host": "dc.company.com", + "port": 389, + "use_ssl": false, + "base_dn": "DC=company,DC=com", + "bind_dn": "CN=bifrost-service,OU=Service Accounts,DC=company,DC=com", + "bind_password": "service-password" + }, + "sync_settings": { + "user_filter": "(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))", + "group_filter": "(objectClass=group)", + "sync_schedule": "0 2 * * *", + "sync_scope": "incremental" + }, + "attribute_mapping": { + "email": "userPrincipalName", + "name": "displayName", + "department": "department", + "manager": "manager", + "employee_id": "employeeID" + }, + "group_mapping": { + "CN=Engineering,OU=Groups,DC=company,DC=com": { + "team_id": "team-eng-001", + "customer_id": "customer-corp" + }, + "CN=Sales,OU=Groups,DC=company,DC=com": { + "team_id": "team-sales-001", + "customer_id": "customer-corp" + } + } + }, + "is_active": true + }' +``` + +**Trigger Manual Sync:** +```bash +curl -X POST http://localhost:8080/api/enterprise/directory-services/{service_id}/sync \ + -H "Content-Type: application/json" \ + -d '{ + "sync_type": "full" + }' +``` + + + + +### LDAP Configuration + +**Supported LDAP Servers:** +- Microsoft Active Directory +- OpenLDAP +- Apache Directory Server +- Oracle Directory Server +- IBM Security Directory Server + +**Configuration Example:** +```bash +curl -X POST http://localhost:8080/api/enterprise/directory-services \ + -H "Content-Type: application/json" \ + -d '{ + "type": "ldap", + "name": "OpenLDAP Corporate", + "config": { + "connection": { + "host": "ldap.company.com", + "port": 636, + "use_ssl": true, + "base_dn": "ou=people,dc=company,dc=com", + "bind_dn": "cn=bifrost,ou=service,dc=company,dc=com", + "bind_password": "service-password" + }, + "user_mapping": { + "email": "mail", + "name": "cn", + "department": "ou", + "groups": "memberOf" + } + } + }' +``` + +--- + +## User-Level Authentication & Budgeting + +Enterprise Governance extends the hierarchical governance model to include individual user-level controls, providing granular access management and personalized budget allocation. + +### User Management + +**Enhanced Hierarchy:** +``` +Customer (organization-level budget) + ↓ +Team (department-level budget) + ↓ +User (individual-level budget + authentication) + ↓ +Virtual Key (API-level budget + rate limits) +``` + +**User Features:** +- **Individual Authentication** - Personal login credentials +- **Personal Budgets** - User-specific cost allocation +- **Access Controls** - Per-user model and provider restrictions +- **Usage Tracking** - Individual consumption monitoring +- **Audit Trails** - User-specific activity logging + +### User Configuration + + + + +1. **Navigate to Users** + - Go to **Enterprise** β†’ **Users** + - Click **Create User** or import from directory + +2. **User Details** + +**Basic Information:** +- **Email**: Primary identifier +- **Display Name**: Full name +- **Department**: Organizational unit +- **Manager**: Reporting structure +- **Employee ID**: HR system identifier + +**Authentication:** +- **SSO Integration**: Link to identity provider +- **Multi-Factor Auth**: Require MFA for access +- **Session Management**: Control session duration + +**Budget Allocation:** +- **Personal Budget**: Individual spending limit +- **Budget Period**: Reset frequency +- **Inheritance**: Inherit team/customer budgets + +**Access Controls:** +- **Allowed Models**: Restrict model access +- **Allowed Providers**: Restrict provider access +- **Team Assignment**: Primary team membership +- **Customer Assignment**: Organization membership + + + + +**Create User:** +```bash +curl -X POST http://localhost:8080/api/enterprise/users \ + -H "Content-Type: application/json" \ + -d '{ + "email": "alice@company.com", + "display_name": "Alice Johnson", + "department": "Engineering", + "employee_id": "EMP001", + "team_id": "team-eng-001", + "customer_id": "customer-corp", + "authentication": { + "sso_provider_id": "saml-azure-ad", + "require_mfa": true, + "session_duration": "8h" + }, + "budget": { + "max_limit": 25.00, + "reset_duration": "1M", + "inherit_team_budget": true, + "inherit_customer_budget": true + }, + "access_control": { + "allowed_models": ["gpt-4o-mini", "claude-3-haiku-20240307"], + "allowed_providers": ["openai", "anthropic"], + "max_virtual_keys": 3 + }, + "is_active": true + }' +``` + +**Update User:** +```bash +curl -X PUT http://localhost:8080/api/enterprise/users/{user_id} \ + -H "Content-Type: application/json" \ + -d '{ + "budget": { + "max_limit": 50.00, + "reset_duration": "1M" + }, + "access_control": { + "allowed_models": ["gpt-4o", "claude-3-sonnet-20240229"] + } + }' +``` + + + + +### User Authentication Flow + +**SSO Authentication:** +```bash +# 1. Initiate SSO login +curl -X GET http://localhost:8080/api/enterprise/auth/saml/login?provider=azure-ad + +# 2. After SSO callback, get user token +curl -X POST http://localhost:8080/api/enterprise/auth/token \ + -H "Content-Type: application/json" \ + -d '{ + "saml_response": "base64-encoded-saml-response" + }' + +# 3. Use token for API requests +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer user-jwt-token" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +**Virtual Key with User Context:** +```bash +# Create user-specific virtual key +curl -X POST http://localhost:8080/api/governance/virtual-keys \ + -H "Authorization: Bearer user-jwt-token" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Alice Personal API Key", + "user_id": "user-alice-001", + "budget": { + "max_limit": 10.00, + "reset_duration": "1w" + } + }' + +# Use virtual key with user tracking +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-alice-personal" \ + -H "x-bf-user-id: user-alice-001" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +--- + +## Compliance Framework + +Enterprise Governance includes built-in compliance capabilities for major regulatory frameworks including **SOC 2 Type II**, **GDPR**, **ISO 27001**, and **HIPAA** compliance. These features provide automated compliance monitoring, policy enforcement, and audit trail generation to meet enterprise security and regulatory requirements. + +--- + +## Audit Reports & Compliance Dashboards + +Enterprise Governance provides comprehensive audit reporting and compliance dashboards for regulatory requirements and internal governance. + +### Audit Report Types + +**1. Access Audit Reports** +- User login/logout activities +- Failed authentication attempts +- Privilege escalation events +- Unusual access patterns + +**2. Usage Audit Reports** +- API request tracking +- Model and provider usage +- Budget consumption patterns +- Rate limit violations + +**3. Data Audit Reports** +- Data access and modification +- Data export activities +- Data deletion requests +- Consent management tracking + +**4. Compliance Reports** +- SOC 2 Type II control evidence +- GDPR compliance status +- ISO 27001 risk assessments +- HIPAA safeguard compliance + +### Report Generation + + + + +1. **Navigate to Audit Reports** + - Go to **Enterprise** β†’ **Audit & Compliance** + - Select **Generate Report** + +2. **Report Configuration** + +**Report Type:** +- **Access Report**: Authentication and authorization events +- **Usage Report**: API consumption and cost analysis +- **Compliance Report**: Regulatory compliance status +- **Security Report**: Security events and incidents + +**Date Range:** +- **Last 24 Hours**: Recent activity +- **Last 7 Days**: Weekly summary +- **Last 30 Days**: Monthly analysis +- **Custom Range**: Specific date range + +**Filters:** +- **Users**: Specific users or all users +- **Teams**: Specific teams or all teams +- **Customers**: Specific customers or all customers +- **Event Types**: Filter by event categories + +**Export Options:** +- **PDF**: Formatted compliance report +- **CSV**: Raw data for analysis +- **JSON**: Structured data export + + + + +**Generate Access Audit Report:** +```bash +curl -X POST http://localhost:8080/api/enterprise/audit/reports \ + -H "Content-Type: application/json" \ + -d '{ + "report_type": "access_audit", + "date_range": { + "start_date": "2024-01-01T00:00:00Z", + "end_date": "2024-01-31T23:59:59Z" + }, + "filters": { + "users": ["user-alice-001", "user-bob-002"], + "event_types": ["login", "logout", "failed_login", "privilege_escalation"] + }, + "format": "pdf", + "include_summary": true + }' +``` + +**Generate Usage Audit Report:** +```bash +curl -X POST http://localhost:8080/api/enterprise/audit/reports \ + -H "Content-Type: application/json" \ + -d '{ + "report_type": "usage_audit", + "date_range": { + "start_date": "2024-01-01T00:00:00Z", + "end_date": "2024-01-31T23:59:59Z" + }, + "filters": { + "customers": ["customer-corp"], + "models": ["gpt-4o", "claude-3-sonnet-20240229"], + "providers": ["openai", "anthropic"] + }, + "format": "csv", + "include_cost_analysis": true + }' +``` + +**Generate Compliance Report:** +```bash +curl -X POST http://localhost:8080/api/enterprise/audit/reports \ + -H "Content-Type: application/json" \ + -d '{ + "report_type": "compliance", + "compliance_framework": "soc2_type2", + "date_range": { + "start_date": "2024-01-01T00:00:00Z", + "end_date": "2024-01-31T23:59:59Z" + }, + "control_objectives": ["security", "availability", "confidentiality"], + "format": "pdf", + "include_evidence": true + }' +``` + + + + +### Compliance Dashboards + +**Real-Time Monitoring:** +- **Security Posture**: Current security status and alerts +- **Compliance Status**: Regulatory compliance health check +- **Risk Assessment**: Identified risks and mitigation status +- **Audit Trail**: Recent audit events and activities + +**Dashboard Widgets:** +```bash +curl -X GET http://localhost:8080/api/enterprise/dashboard/compliance \ + -H "Authorization: Bearer admin-token" + +# Response includes: +{ + "security_posture": { + "overall_score": 95, + "active_alerts": 2, + "failed_logins_24h": 5, + "privilege_escalations": 0 + }, + "compliance_status": { + "soc2_type2_compliance": "compliant", + "gdpr_compliance": "compliant", + "iso27001_compliance": "in_progress", + "hipaa_compliance": "not_applicable" + }, + "risk_assessment": { + "high_risk_items": 0, + "medium_risk_items": 3, + "low_risk_items": 12, + "mitigation_progress": "85%" + }, + "recent_activities": [ + { + "timestamp": "2024-01-15T10:30:00Z", + "type": "user_login", + "user": "alice@company.com", + "status": "success" + } + ] +} +``` + +### Automated Compliance Monitoring + +**Continuous Monitoring:** +```bash +curl -X POST http://localhost:8080/api/enterprise/compliance/monitoring \ + -H "Content-Type: application/json" \ + -d '{ + "monitoring_rules": [ + { + "name": "Failed Login Monitoring", + "type": "security_event", + "condition": "failed_logins > 10 in 1h", + "action": "alert_security_team", + "severity": "high" + }, + { + "name": "Data Export Monitoring", + "type": "data_access", + "condition": "data_export_size > 1GB", + "action": "require_approval", + "severity": "medium" + }, + { + "name": "Budget Threshold Alert", + "type": "budget_usage", + "condition": "usage > 80% of budget", + "action": "notify_manager", + "severity": "low" + } + ], + "notification_channels": { + "email": ["security@company.com", "compliance@company.com"], + "slack": "#security-alerts", + "webhook": "https://company.com/security-webhook" + } + }' +``` + +--- + +## Error Responses + +Enterprise Governance extends standard governance errors with additional authentication and compliance-related responses: + +**Authentication Errors:** +```json +{ + "error": { + "type": "authentication_required", + "message": "SSO authentication required" + } +} +``` + +```json +{ + "error": { + "type": "mfa_required", + "message": "Multi-factor authentication required" + } +} +``` + +**Authorization Errors:** +```json +{ + "error": { + "type": "user_not_authorized", + "message": "User does not have permission to access this model" + } +} +``` + +**Compliance Errors:** +```json +{ + "error": { + "type": "compliance_violation", + "message": "Request violates GDPR data minimization requirements" + } +} +``` + +--- + +## Next Steps + +- **[Core Governance](../features/governance)** - Understand base governance concepts +- **[Clustering](./clustering)** - Deploy enterprise governance across multiple nodes +{/* - **[SSO Integration](./sso-saml-openid-connect)** - Detailed SSO configuration guide */} +- **[Vault Support](./vault-support)** - Secure credential management +- **[Custom Plugins](./custom-plugins)** - Extend enterprise governance capabilities diff --git a/docs/enterprise/audit-logs.mdx b/docs/enterprise/audit-logs.mdx new file mode 100644 index 000000000..66e3de9c0 --- /dev/null +++ b/docs/enterprise/audit-logs.mdx @@ -0,0 +1,408 @@ +--- +title: "Audit Logs" +description: "Comprehensive security and compliance audit logging with detailed tracking of authentication, authorization, configuration changes, and data access for enterprise governance and regulatory requirements." +icon: "scroll" +--- + +## Overview + +**Audit Logs** in Bifrost provide complete visibility into security-critical events, user activities, configuration changes, and data access patterns. Enterprise audit logging ensures compliance with regulatory requirements including SOC 2, GDPR, HIPAA, and ISO 27001 through comprehensive, immutable audit trails. + + +### Key Features + +| Feature | Description | +|---------|-------------| +| **Immutable Logs** | Tamper-proof audit trails with cryptographic verification | +| **Real-Time Capture** | Instant logging of all security-relevant events | +| **Granular Filtering** | Query by user, action, resource, or time range | +| **Long-Term Retention** | Configurable retention policies for compliance | +| **SIEM Integration** | Export to Splunk, Datadog, Elastic, and more | +| **Alert Triggers** | Automated alerts on suspicious activities | + +--- + +## What Gets Logged + +### Authentication Events +- User login (successful/failed) +- User logout +- Session creation/expiration +- MFA verification +- Password changes +- Failed authentication attempts +- Account lockouts +- SSO redirects + +### Authorization Events +- Model access attempts +- Provider access checks +- Virtual key usage +- Budget limit checks +- Rate limit violations +- Permission denials + +### Configuration Changes +- Virtual key creation/modification/deletion +- Team/customer creation/updates +- User provisioning/deprovisioning +- Budget adjustments +- Rate limit changes +- Provider key updates +- Guardrail configuration changes +- SAML/OIDC settings updates + +### Data Access Events +- PII detection and handling +- Data export operations +- Log access and queries +- Sensitive configuration access +- API key exposure attempts + +### Security Events +- Prompt injection attempts +- Jailbreak attempts +- Unusual access patterns +- Multiple failed authentication attempts +- API key abuse +- Rate limit violations +- Suspicious IP addresses +- Guardrail violations + +--- + +## Configuration + +### Basic Audit Logging Setup + + + + +```json +{ + "enterprise": { + "audit_logs": { + "enabled": true, + "retention": { + "duration": "365d", + "archive_after": "90d" + }, + "capture": { + "authentication": true, + "authorization": true, + "configuration_changes": true, + "data_access": true, + "security_events": true + }, + "immutability": { + "enabled": true, + "verification_method": "cryptographic_hash" + } + } + } +} +``` + + + + +```bash +# Enable audit logging +BIFROST_AUDIT_LOGS_ENABLED=true + +# Retention settings +BIFROST_AUDIT_RETENTION_DAYS=365 +BIFROST_AUDIT_ARCHIVE_DAYS=90 + +# Event capture +BIFROST_AUDIT_AUTH_EVENTS=true +BIFROST_AUDIT_CONFIG_CHANGES=true +BIFROST_AUDIT_SECURITY_EVENTS=true + +# Immutability +BIFROST_AUDIT_IMMUTABLE=true +``` + + + + +### Advanced Configuration + +```json +{ + "audit_logs": { + "enabled": true, + "backup": { + "type": "s3", + "bucket": "bifrost-audit-logs", + "region": "us-west-2", + "encryption": "AES256" + } + }, + "retention": { + "duration": "365d", + "archive_after": "90d", + "delete_after": "2555d", + "hot_storage_days": 30 + }, + "capture": { + "authentication": { + "enabled": true, + "include_failed_attempts": true, + "track_session_duration": true + }, + "authorization": { + "enabled": true, + "log_allowed_access": false, + "log_denied_access": true + }, + "configuration_changes": { + "enabled": true, + "track_before_after": true, + "exclude_fields": ["password", "api_key"] + }, + "data_access": { + "enabled": true, + "log_pii_detection": true, + "log_sensitive_operations": true + }, + "security_events": { + "enabled": true, + "severity_threshold": "medium" + } + }, + "enrichment": { + "geo_location": true, + "user_agent_parsing": true, + "ip_reputation": true + }, + "immutability": { + "enabled": true, + "verification_method": "cryptographic_hash", + "signing_key": "${AUDIT_LOG_SIGNING_KEY}" + } + } +} +``` + +--- + +## Querying Audit Logs + +### API-Based Queries + +**Query Authentication Events:** +```bash +curl -X GET "http://localhost:8080/api/audit-logs?event_type=authentication&start_date=2024-01-01&end_date=2024-01-31" \ + -H "Authorization: Bearer admin-token" +``` + +**Query by User:** +```bash +curl -X GET "http://localhost:8080/api/audit-logs?user_id=user-alice-001&limit=100" \ + -H "Authorization: Bearer admin-token" +``` + +**Query Failed Access Attempts:** +```bash +curl -X GET "http://localhost:8080/api/audit-logs?action=access_denied&severity=high" \ + -H "Authorization: Bearer admin-token" +``` + +**Query Configuration Changes:** +```bash +curl -X GET "http://localhost:8080/api/audit-logs?event_type=configuration_change&resource_type=virtual_key" \ + -H "Authorization: Bearer admin-token" +``` + +### Advanced Filtering + +```bash +curl -X POST http://localhost:8080/api/audit-logs/query \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer admin-token" \ + -d '{ + "filters": { + "event_types": ["authentication", "authorization"], + "date_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-01-31T23:59:59Z" + }, + "actors": { + "user_ids": ["user-alice-001", "user-bob-002"], + "ip_addresses": ["203.0.113.0/24"] + }, + "status": ["failed", "blocked"], + "severity": ["medium", "high", "critical"] + }, + "sort": { + "field": "timestamp", + "order": "desc" + }, + "limit": 1000, + "include_details": true + }' +``` + +### Response Format + +```json +{ + "total_count": 347, + "returned_count": 100, + "page": 1, + "audit_logs": [ + { + "event_id": "evt_001", + "timestamp": "2024-01-15T10:30:00.123Z", + "event_type": "authentication", + "action": "user_login", + "status": "failed", + "severity": "medium", + "actor": { + "user_id": "user-alice-001", + "email": "alice@company.com", + "ip_address": "203.0.113.42" + }, + "details": { + "auth_method": "password", + "failure_reason": "invalid_password", + "attempts_count": 3 + }, + "verification": { + "hash": "sha256:abc123...", + "verified": true + } + } + ], + "next_page": "/api/enterprise/audit-logs?page=2" +} +``` + +--- + +## SIEM Integration + +### Splunk Integration + +```json +{ + "audit_logs": { + "siem_integration": { + "splunk": { + "enabled": true, + "hec_endpoint": "https://splunk.company.com:8088/services/collector", + "hec_token": "${SPLUNK_HEC_TOKEN}", + "source_type": "bifrost:audit", + "index": "security", + "batch_size": 100, + "flush_interval": "10s" + } + } + } +} +``` + +### Datadog Integration + +```json +{ + "audit_logs": { + "siem_integration": { + "datadog": { + "enabled": true, + "api_key": "${DATADOG_API_KEY}", + "site": "datadoghq.com", + "service": "bifrost", + "tags": ["env:production", "team:security"] + } + } + } +} +``` + +### Elastic Security Integration + +```json +{ + "audit_logs": { + "siem_integration": { + "elastic": { + "enabled": true, + "endpoint": "https://elastic.company.com:9200", + "api_key": "${ELASTIC_API_KEY}", + "index": "bifrost-audit-logs", + "pipeline": "security-enrichment" + } + } + } +} +``` + +### Webhook Integration + +```json +{ + "audit_logs": { + "webhooks": { + "enabled": true, + "endpoints": [ + { + "name": "security_incidents", + "url": "https://security.company.com/webhooks/audit", + "auth": { + "type": "bearer", + "token": "${WEBHOOK_AUTH_TOKEN}" + }, + "filters": { + "event_types": ["security_incident"], + "severity": ["high", "critical"] + }, + "retry": { + "max_attempts": 3, + "backoff": "exponential" + } + } + ] + } + } +} +``` + +--- + +## Compliance Reporting + +### Generate Audit Reports + +```bash +curl -X POST http://localhost:8080/api/enterprise/audit-logs/reports \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer admin-token" \ + -d '{ + "report_type": "compliance_audit", + "compliance_framework": "soc2_type2", + "date_range": { + "start_date": "2024-01-01T00:00:00Z", + "end_date": "2024-03-31T23:59:59Z" + }, + "include_sections": [ + "authentication_events", + "authorization_events", + "configuration_changes", + "security_incidents" + ], + "format": "pdf", + "include_evidence": true + }' +``` + +### Report Types + +| Report Type | Description | Use Case | +|------------|-------------|----------| +| **Access Audit** | All user authentication and access events | SOC 2, ISO 27001 | +| **Change Audit** | Configuration and permission changes | Change management | +| **Security Audit** | Security incidents and violations | Security reviews | +| **Compliance Report** | Framework-specific compliance evidence | Regulatory audits | +| **User Activity** | Individual user activity summary | HR investigations | \ No newline at end of file diff --git a/docs/enterprise/clustering.mdx b/docs/enterprise/clustering.mdx new file mode 100644 index 000000000..3d76006cc --- /dev/null +++ b/docs/enterprise/clustering.mdx @@ -0,0 +1,417 @@ +--- +title: "Clustering" +description: "High-availability peer-to-peer clustering with intelligent traffic distribution, automatic failover, and gossip-based state synchronization for enterprise-scale deployments." +icon: "circle-nodes" +--- + +## Overview + +**Bifrost Clustering** provides enterprise-grade high availability through a peer-to-peer network architecture that ensures continuous service availability, intelligent traffic distribution, and automatic failover capabilities. The clustering system uses gossip protocols to maintain consistent state across all nodes while providing seamless scaling and fault tolerance. + +### Why Clustering is Required + +Modern AI gateway deployments face several critical challenges that clustering addresses: + +| Challenge | Impact | Clustering Solution | +|-----------|--------|-------------------| +| **Single Point of Failure** | Complete service outage if gateway fails | Distributed architecture with automatic failover | +| **Traffic Spikes** | Performance degradation under high load | Dynamic load distribution across multiple nodes | +| **Provider Rate Limits** | Request throttling and service interruption | Distributed rate limit tracking and intelligent routing | +| **Regional Latency** | Poor user experience in distant regions | Geographic distribution with local processing | +| **Maintenance Windows** | Service downtime during updates | Rolling updates with zero-downtime deployment | +| **Capacity Planning** | Over/under-provisioning resources | Elastic scaling based on real-time demand | + +### Key Benefits + +| Feature | Description | +|---------|-------------| +| **Peer-to-Peer Architecture** | No single point of failure with equal node participation | +| **Gossip-Based State Sync** | Real-time synchronization of traffic patterns and limits | +| **Automatic Failover** | Seamless traffic redistribution when nodes fail | +| **Request Migration** | Ongoing requests continue on healthy nodes | +| **Zero-Downtime Updates** | Rolling deployments without service interruption | +| **Intelligent Load Distribution** | AI-driven traffic routing based on node capacity | + +--- + +## Architecture + +### Peer-to-Peer Network Design + +Bifrost clustering uses a **peer-to-peer (P2P) network** where all nodes are equal participants. This design eliminates single points of failure and provides superior fault tolerance compared to master-slave architectures. + +![Clustering diagram](../../media/clustering-diagram.png) + +### Minimum Node Requirements + +**Recommended: 3+ nodes minimum** for optimal fault tolerance and consensus. + +| Cluster Size | Fault Tolerance | Use Case | +|--------------|-----------------|----------| +| **3 nodes** | 1 node failure | Small production deployments | +| **5 nodes** | 2 node failures | Medium production deployments | +| **7+ nodes** | 3+ node failures | Large enterprise deployments | + +--- + +## Gossip Protocol Implementation + +### State Synchronization + +The gossip protocol ensures all nodes maintain consistent views of: + +- **Traffic Patterns**: Request volume, latency metrics, error rates per model-key-id +- **Rate Limit States**: Current usage counters for each provider/model combination +- **Node Health**: CPU, memory, network status of all peers +- **Configuration Changes**: Provider updates, routing rules, policies +- **Model Performance**: Real-time metrics for intelligent load balancing +- **Provider Weights**: Dynamic weight adjustments based on performance + + +### Convergence Guarantees + +- **Eventually Consistent**: All nodes converge to the same state within seconds +- **Partition Tolerance**: Nodes continue operating during network splits +- **Conflict Resolution**: Timestamp-based ordering for conflicting updates + +--- + +## Automatic Failover & Request Migration + +### Node Failure Detection + +Bifrost uses multiple failure detection mechanisms: + +1. **Heartbeat Monitoring**: Regular ping/pong between all nodes +2. **Request Timeout Tracking**: Failed API calls indicate node issues +3. **Gossip Silence Detection**: Missing gossip messages trigger health checks +4. **Load Balancer Health Checks**: External monitoring integration + +### Traffic Redistribution + +When a node fails, traffic is automatically redistributed: + +![Traffic distribution](../../media/traffic-redistribution.png) + +### Request Migration Strategies + +Based on configuration, ongoing requests can be handled in multiple ways: + +| Strategy | Description | Use Case | +|----------|-------------|----------| +| **Complete on Origin** | Requests finish on the original node | Stateful operations | +| **Migrate to Healthy Node** | Transfer to available nodes | Stateless operations | +| **Retry with Backoff** | Restart request on healthy node | Idempotent operations | +| **Circuit Breaker** | Fail fast and return error | Time-sensitive operations | + +--- + +## Configuration + +### Basic Cluster Setup + +```json +{ + "cluster": { + "enabled": true, + "node_id": "bifrost-node-1", + "bind_address": "0.0.0.0:8080", + "peers": [ + "bifrost-node-2:8080", + "bifrost-node-3:8080" + ], + "gossip": { + "port": 7946, + "interval": "1s", + "timeout": "5s" + } + } +} +``` + +### Advanced Clustering Options + +```json +{ + "cluster": { + "enabled": true, + "node_id": "bifrost-node-1", + "bind_address": "0.0.0.0:8080", + "peers": [ + "bifrost-node-2:8080", + "bifrost-node-3:8080" + ], + "gossip": { + "port": 7946, + "interval": "1s", + "timeout": "5s", + "max_packet_size": 1400, + "compression": true + }, + "failover": { + "detection_threshold": 3, + "recovery_timeout": "30s", + "request_migration": "migrate_to_healthy" + }, + "load_balancing": { + "algorithm": "weighted_round_robin", + "health_check_interval": "10s", + "weight_adjustment": "auto" + } + } +} +``` + +### Request Migration Configuration + +```json +{ + "cluster": { + "failover": { + "request_migration": "migrate_to_healthy", + "migration_strategies": { + "chat_completions": "migrate_to_healthy", + "embeddings": "complete_on_origin", + "streaming": "circuit_breaker" + }, + "timeout_behavior": { + "short_timeout": "retry_with_backoff", + "long_timeout": "migrate_to_healthy" + } + } + } +} +``` + +--- + +## Deployment Patterns + +### Docker Compose Cluster + +```yaml +version: '3.8' +services: + bifrost-node-1: + image: bifrost:latest + environment: + - CLUSTER_ENABLED=true + - NODE_ID=bifrost-node-1 + - PEERS=bifrost-node-2:8080,bifrost-node-3:8080 + ports: + - "8080:8080" + - "7946:7946" + + bifrost-node-2: + image: bifrost:latest + environment: + - CLUSTER_ENABLED=true + - NODE_ID=bifrost-node-2 + - PEERS=bifrost-node-1:8080,bifrost-node-3:8080 + ports: + - "8081:8080" + - "7947:7946" + + bifrost-node-3: + image: bifrost:latest + environment: + - CLUSTER_ENABLED=true + - NODE_ID=bifrost-node-3 + - PEERS=bifrost-node-1:8080,bifrost-node-2:8080 + ports: + - "8082:8080" + - "7948:7946" +``` + +### Kubernetes Deployment + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: bifrost-cluster +spec: + serviceName: bifrost-cluster + replicas: 3 + selector: + matchLabels: + app: bifrost + template: + metadata: + labels: + app: bifrost + spec: + containers: + - name: bifrost + image: bifrost:latest + env: + - name: CLUSTER_ENABLED + value: "true" + - name: NODE_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: PEERS + value: "bifrost-cluster-0.bifrost-cluster:8080,bifrost-cluster-1.bifrost-cluster:8080,bifrost-cluster-2.bifrost-cluster:8080" + ports: + - containerPort: 8080 + name: api + - containerPort: 7946 + name: gossip +``` + +--- + +## Monitoring & Observability + +### Cluster Health Metrics + +Monitor these key metrics for cluster health: + +```json +{ + "cluster_metrics": { + "nodes_total": 3, + "nodes_healthy": 3, + "nodes_failed": 0, + "gossip_messages_per_second": 45, + "state_convergence_time_ms": 250, + "request_migration_rate": 0.001, + "load_distribution": { + "node-1": 0.33, + "node-2": 0.34, + "node-3": 0.33 + }, + "provider_performance": { + "openai": { + "total_traffic_percentage": 64.0, + "model_keys": { + "gpt-4-key-1": { + "avg_latency_ms": 1200, + "current_weight": 0.8, + "error_rate": 0.01, + "traffic_percentage": 45.2, + "health_status": "healthy" + }, + "gpt-4-key-2": { + "avg_latency_ms": 1450, + "current_weight": 0.6, + "error_rate": 0.03, + "traffic_percentage": 18.8, + "health_status": "degraded" + } + } + }, + "anthropic": { + "total_traffic_percentage": 36.0, + "model_keys": { + "claude-3-key-1": { + "avg_latency_ms": 980, + "current_weight": 1.0, + "error_rate": 0.005, + "traffic_percentage": 28.5, + "health_status": "healthy" + }, + "claude-3-key-2": { + "avg_latency_ms": 1100, + "current_weight": 0.9, + "error_rate": 0.008, + "traffic_percentage": 7.5, + "health_status": "healthy" + } + } + } + } + } +} +``` + +### Alerting Rules + +Set up alerts for critical cluster events: + +**Cluster-Level Alerts:** +- Node failure detection +- High request migration rates +- Gossip convergence delays +- Uneven load distribution +- Network partition events + +**Model-Key-ID Performance Alerts:** +- High error rates per model-key-id (> 2.5%) +- Latency spikes per model-key-id (> 150% of baseline) +- Weight adjustments frequency (> 10 per minute) +- Traffic imbalance across model keys (> 80% on single key) +- Provider-level performance degradation + +**Example Alert Configuration:** +```yaml +alerts: + - name: "High Error Rate - Model Key" + condition: "error_rate > 0.025" + scope: "model_key_id" + action: "reduce_weight" + + - name: "Latency Spike - Model Key" + condition: "avg_latency_ms > baseline * 1.5" + scope: "model_key_id" + action: "temporary_circuit_break" + + - name: "Traffic Imbalance - Provider" + condition: "single_key_traffic_percentage > 0.8" + scope: "provider" + action: "rebalance_weights" +``` + +--- + +## Best Practices + +### Deployment Guidelines + +1. **Use Odd Number of Nodes**: Prevents split-brain scenarios +2. **Geographic Distribution**: Deploy across availability zones +3. **Resource Sizing**: Ensure nodes can handle redistributed load +4. **Network Security**: Secure gossip communication with encryption +5. **Monitoring Setup**: Implement comprehensive cluster monitoring + +### Performance Optimization + +1. **Gossip Tuning**: Adjust interval based on cluster size and network latency +2. **Load Balancer Configuration**: Use health checks and proper timeouts +3. **Request Routing**: Optimize based on provider latency and capacity +4. **State Compression**: Enable gossip compression for large clusters +5. **Connection Pooling**: Maintain persistent connections between nodes + +### Troubleshooting + +Common issues and solutions: + +| Issue | Symptoms | Solution | +|-------|----------|----------| +| **Split Brain** | Inconsistent responses | Ensure odd number of nodes | +| **Gossip Storms** | High network usage | Tune gossip interval and packet size | +| **Uneven Load** | Some nodes overloaded | Check load balancing configuration | +| **Migration Loops** | Requests bouncing between nodes | Review migration strategies | + +--- + +## Security Considerations + +### Network Security + +- **Gossip Encryption**: Enable TLS for gossip protocol communication +- **API Authentication**: Secure inter-node API calls with mutual TLS +- **Network Segmentation**: Isolate cluster traffic in private networks +- **Firewall Rules**: Restrict gossip ports to cluster nodes only + +### Access Control + +- **Node Authentication**: Verify node identity before joining cluster +- **Configuration Signing**: Cryptographically sign configuration updates +- **Audit Logging**: Track all cluster membership and configuration changes +- **Secret Management**: Secure storage and rotation of cluster secrets + +--- + +This clustering architecture ensures Bifrost can handle enterprise-scale deployments with high availability, automatic failover, and intelligent traffic distribution while maintaining security and performance standards. diff --git a/docs/enterprise/custom-plugins.mdx b/docs/enterprise/custom-plugins.mdx new file mode 100644 index 000000000..44a5a5673 --- /dev/null +++ b/docs/enterprise/custom-plugins.mdx @@ -0,0 +1,16 @@ +--- +title: "Custom Plugins" +description: "Build and deploy enterprise-specific plugins to extend Bifrost's functionality with custom business logic, integrations, and workflow automation." +icon: "plug" +--- + +At Bifrost, we understand that every organization has unique requirements for their LLM infrastructure, workflows, and AI-specific business logic that can't always be addressed by off-the-shelf solutions. That's why we offer comprehensive custom plugin development services to help companies extend Bifrost's LLM gateway functionality with tailored solutions that perfectly fit their specific AI and machine learning needs. + +Our expert team works closely with your organization to design, develop, and deploy custom plugins that integrate seamlessly with your LLM infrastructure and AI workflows. We handle everything from initial consultation to ongoing maintenance. + +- **Custom AI Business Logic Implementation** - Embed your unique AI governance rules and LLM processing logic directly into Bifrost +- **LLM Provider Integrations** - Connect Bifrost with proprietary or specialized LLM providers and AI services +- **AI Workflow Automation** - Automate complex multi-step LLM processes specific to your AI use cases +- **AI Security & Compliance Extensions** - Implement custom AI safety policies, content filtering, and compliance requirements +- **LLM Performance Optimization** - Build plugins optimized for your specific LLM workloads and scaling requirements + diff --git a/docs/enterprise/guardrails.mdx b/docs/enterprise/guardrails.mdx new file mode 100644 index 000000000..e73c1f4c0 --- /dev/null +++ b/docs/enterprise/guardrails.mdx @@ -0,0 +1,866 @@ +--- +title: "Guardrails" +description: "Enterprise-grade content safety and security validation with support for AWS Bedrock Guardrails, Azure Content Safety, and Patronus AI for real-time input and output protection." +icon: "road-barrier" +--- + +## Overview + +**Guardrails** in Bifrost provide enterprise-grade content safety, security validation, and policy enforcement for LLM requests and responses. The system validates inputs and outputs in real-time against your specified policies, ensuring responsible AI deployment with comprehensive protection against harmful content, prompt injection, PII leakage, and policy violations. + + +### Key Features + +| Feature | Description | +|---------|-------------| +| **Multi-Provider Support** | AWS Bedrock, Azure Content Safety, and Patronus AI integration | +| **Dual-Stage Validation** | Guard both inputs (prompts) and outputs (responses) | +| **Real-Time Processing** | Synchronous and asynchronous validation modes | +| **Custom Policies** | Define organization-specific guardrail rules | +| **Automatic Remediation** | Block, redact, or modify content based on policy | +| **Comprehensive Logging** | Detailed audit trails for compliance | + +--- + +## Supported Guardrail Providers + +Bifrost integrates with leading guardrail providers to offer comprehensive protection: + +### AWS Bedrock Guardrails + +**Amazon Bedrock Guardrails** provides enterprise-grade content filtering and safety features with deep AWS integration. + +**Capabilities:** +- **Content Filters**: Hate speech, insults, sexual content, violence, misconduct +- **Denied Topics**: Block specific topics or categories +- **Word Filters**: Custom profanity and sensitive word blocking +- **PII Protection**: Detect and redact 50+ PII entity types +- **Contextual Grounding**: Verify responses against source documents +- **Prompt Attack Detection**: Identify injection and jailbreak attempts + +**Supported PII Types:** +- Personal identifiers (SSN, passport, driver's license) +- Financial information (credit cards, bank accounts) +- Contact information (email, phone, address) +- Medical information (health records, insurance) +- Device identifiers (IP addresses, MAC addresses) + +### Azure Content Safety + +**Azure AI Content Safety** provides multi-modal content moderation powered by Microsoft's advanced AI models. + +**Capabilities:** +- **Severity-Based Filtering**: 4-level severity classification (Safe, Low, Medium, High) +- **Multi-Category Detection**: Hate, sexual, violence, self-harm content +- **Prompt Shield**: Advanced jailbreak and injection detection +- **Groundedness Detection**: Verify factual accuracy against sources +- **Protected Material**: Detect copyrighted content +- **Custom Categories**: Define organization-specific content policies + +**Detection Categories:** +- Hate and fairness +- Sexual content +- Violence +- Self-harm +- Profanity +- Jailbreak attempts + +### Patronus AI + +**Patronus AI** specializes in LLM security and safety with advanced evaluation capabilities. + +**Capabilities:** +- **Hallucination Detection**: Identify factually incorrect responses +- **PII Detection**: Comprehensive personal data identification +- **Toxicity Screening**: Multi-language toxic content detection +- **Prompt Injection Defense**: Advanced attack pattern recognition +- **Custom Evaluators**: Build organization-specific safety checks +- **Real-Time Monitoring**: Continuous safety validation + +**Advanced Features:** +- Context-aware evaluation +- Multi-turn conversation analysis +- Custom policy templates +- Integration with existing safety workflows + +--- + +## Configuration + +### AWS Bedrock Guardrails Setup + + + + +1. **Navigate to Guardrails** + - Open Bifrost UI at `http://localhost:8080` + - Go to **Enterprise** β†’ **Guardrails** + - Click **Add Guardrail Provider** + +2. **Configure AWS Bedrock** + +**Required Fields:** +- **Provider Name**: Descriptive name for this guardrail +- **Provider Type**: Select "AWS Bedrock" +- **AWS Region**: Your Bedrock region (e.g., `us-east-1`) +- **Guardrail ID**: Your Bedrock guardrail identifier +- **Guardrail Version**: Version number or `DRAFT` + +**AWS Credentials:** +- **Access Key ID**: AWS IAM access key +- **Secret Access Key**: AWS IAM secret key +- **Session Token**: (Optional) For temporary credentials + +**Validation Settings:** +- **Input Validation**: Enable for prompt validation +- **Output Validation**: Enable for response validation +- **Action on Violation**: Block, Log, or Redact +- **Timeout**: Max validation time (default: 5s) + +3. **Test Configuration** + - Click **Test Guardrail** + - Send sample prompt to verify detection + - Review detection results + + + + +**Configure AWS Bedrock Guardrails:** +```bash +curl -X POST http://localhost:8080/api/enterprise/guardrails \ + -H "Content-Type: application/json" \ + -d '{ + "name": "AWS Bedrock Production Guardrail", + "provider": "aws_bedrock", + "enabled": true, + "config": { + "aws_region": "us-east-1", + "guardrail_id": "gdrail-abc123def456", + "guardrail_version": "1", + "credentials": { + "access_key_id": "AKIA...", + "secret_access_key": "secret...", + "session_token": "" + } + }, + "validation": { + "validate_input": true, + "validate_output": true, + "action_on_violation": "block", + "timeout_ms": 5000 + }, + "content_filters": { + "hate": { + "enabled": true, + "threshold": "MEDIUM" + }, + "insults": { + "enabled": true, + "threshold": "MEDIUM" + }, + "sexual": { + "enabled": true, + "threshold": "HIGH" + }, + "violence": { + "enabled": true, + "threshold": "MEDIUM" + }, + "misconduct": { + "enabled": true, + "threshold": "LOW" + } + }, + "pii_detection": { + "enabled": true, + "action": "redact", + "entities": [ + "SSN", + "EMAIL", + "PHONE", + "CREDIT_CARD", + "BANK_ACCOUNT", + "PASSPORT", + "DRIVER_LICENSE", + "IP_ADDRESS" + ] + }, + "denied_topics": [ + { + "name": "Financial Advice", + "definition": "Investment recommendations or financial guidance", + "action": "block" + }, + { + "name": "Medical Diagnosis", + "definition": "Specific medical diagnoses or treatment recommendations", + "action": "block" + } + ], + "word_filters": { + "profanity": { + "enabled": true, + "action": "redact" + }, + "custom_words": [ + "confidential", + "internal-only", + "do-not-share" + ] + } + }' +``` + +**Test Guardrail:** +```bash +curl -X POST http://localhost:8080/api/enterprise/guardrails/{guardrail_id}/test \ + -H "Content-Type: application/json" \ + -d '{ + "input_text": "My SSN is 123-45-6789 and I need help with something", + "validation_type": "input" + }' + +# Response +{ + "guardrail_id": "gdrail-abc123def456", + "action_taken": "redact", + "violations": [ + { + "type": "PII", + "category": "SSN", + "severity": "HIGH", + "text_excerpt": "My SSN is ***-**-****", + "confidence": 0.99 + } + ], + "modified_text": "My SSN is ***-**-**** and I need help with something", + "processing_time_ms": 245 +} +``` + + + + +```json +{ + "enterprise": { + "guardrails": [ + { + "id": "bedrock-prod-guardrail", + "name": "AWS Bedrock Production Guardrail", + "provider": "aws_bedrock", + "enabled": true, + "config": { + "aws_region": "us-east-1", + "guardrail_id": "gdrail-abc123def456", + "guardrail_version": "1", + "credentials": { + "access_key_id": "${AWS_ACCESS_KEY_ID}", + "secret_access_key": "${AWS_SECRET_ACCESS_KEY}", + "session_token": "${AWS_SESSION_TOKEN}" + } + }, + "validation": { + "validate_input": true, + "validate_output": true, + "action_on_violation": "block", + "timeout_ms": 5000 + }, + "content_filters": { + "hate": { + "enabled": true, + "threshold": "MEDIUM" + }, + "insults": { + "enabled": true, + "threshold": "MEDIUM" + }, + "sexual": { + "enabled": true, + "threshold": "HIGH" + }, + "violence": { + "enabled": true, + "threshold": "MEDIUM" + } + }, + "pii_detection": { + "enabled": true, + "action": "redact", + "entities": [ + "SSN", + "EMAIL", + "PHONE", + "CREDIT_CARD" + ] + } + } + ] + } +} +``` + + + + +### Azure Content Safety Setup + + + + +1. **Navigate to Guardrails** + - Go to **Enterprise** β†’ **Guardrails** + - Click **Add Guardrail Provider** + +2. **Configure Azure Content Safety** + +**Required Fields:** +- **Provider Name**: Descriptive name +- **Provider Type**: Select "Azure Content Safety" +- **Endpoint**: Your Azure Content Safety endpoint +- **API Key**: Azure subscription key + +**Content Filters:** +- **Hate**: Enable with severity threshold +- **Sexual**: Enable with severity threshold +- **Violence**: Enable with severity threshold +- **Self-Harm**: Enable with severity threshold + +**Advanced Features:** +- **Prompt Shield**: Enable jailbreak detection +- **Groundedness Detection**: Enable for factual verification +- **Custom Categories**: Define organization policies + + + + +**Configure Azure Content Safety:** +```bash +curl -X POST http://localhost:8080/api/enterprise/guardrails \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Azure Content Safety Guardrail", + "provider": "azure_content_safety", + "enabled": true, + "config": { + "endpoint": "https://your-resource.cognitiveservices.azure.com/", + "api_key": "your-azure-api-key", + "api_version": "2024-02-15-preview" + }, + "validation": { + "validate_input": true, + "validate_output": true, + "action_on_violation": "block", + "timeout_ms": 3000 + }, + "content_categories": { + "hate": { + "enabled": true, + "severity_threshold": 2, + "action": "block" + }, + "sexual": { + "enabled": true, + "severity_threshold": 4, + "action": "block" + }, + "violence": { + "enabled": true, + "severity_threshold": 2, + "action": "block" + }, + "self_harm": { + "enabled": true, + "severity_threshold": 2, + "action": "block" + } + }, + "prompt_shield": { + "enabled": true, + "detect_jailbreak": true, + "detect_indirect_attack": true, + "action": "block" + }, + "groundedness_detection": { + "enabled": false, + "source_type": "reasoning", + "action": "log" + }, + "custom_categories": [ + { + "name": "Corporate Policy Violation", + "definition": "Content violating company communication policies", + "sample_content": [ + "Example of prohibited content 1", + "Example of prohibited content 2" + ], + "severity_threshold": 2, + "action": "block" + } + ] + }' +``` + +**Analyze Content:** +```bash +curl -X POST http://localhost:8080/api/guardrails/{guardrail_id}/analyze \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Content to analyze for safety violations", + "validation_type": "input" + }' + +# Response +{ + "guardrail_id": "azure-content-safety-001", + "action_taken": "allow", + "categories_analysis": [ + { + "category": "Hate", + "severity": 0 + }, + { + "category": "Sexual", + "severity": 0 + }, + { + "category": "Violence", + "severity": 0 + }, + { + "category": "SelfHarm", + "severity": 0 + } + ], + "prompt_shield_result": { + "jailbreak_detected": false, + "indirect_attack_detected": false + }, + "processing_time_ms": 187 +} +``` + + + + +```json +{ + "enterprise": { + "guardrails": [ + { + "id": "azure-content-safety-001", + "name": "Azure Content Safety Guardrail", + "provider": "azure_content_safety", + "enabled": true, + "config": { + "endpoint": "https://your-resource.cognitiveservices.azure.com/", + "api_key": "${AZURE_CONTENT_SAFETY_API_KEY}", + "api_version": "2024-02-15-preview" + }, + "validation": { + "validate_input": true, + "validate_output": true, + "action_on_violation": "block", + "timeout_ms": 3000 + }, + "content_categories": { + "hate": { + "enabled": true, + "severity_threshold": 2, + "action": "block" + }, + "sexual": { + "enabled": true, + "severity_threshold": 4, + "action": "block" + }, + "violence": { + "enabled": true, + "severity_threshold": 2, + "action": "block" + }, + "self_harm": { + "enabled": true, + "severity_threshold": 2, + "action": "block" + } + }, + "prompt_shield": { + "enabled": true, + "detect_jailbreak": true, + "detect_indirect_attack": true + } + } + ] + } +} +``` + + + + +### Patronus AI Setup + + + + +1. **Navigate to Guardrails** + - Go to **Enterprise** β†’ **Guardrails** + - Click **Add Guardrail Provider** + +2. **Configure Patronus AI** + +**Required Fields:** +- **Provider Name**: Descriptive name +- **Provider Type**: Select "Patronus AI" +- **API Key**: Your Patronus API key +- **Environment**: Production or Development + +**Evaluators:** +- **Hallucination Detection**: Enable factual accuracy checks +- **PII Detection**: Enable personal data identification +- **Toxicity**: Enable harmful content detection +- **Prompt Injection**: Enable attack detection + +**Custom Policies:** +- Upload organization-specific evaluators +- Define custom safety criteria + + + + +**Configure Patronus AI:** +```bash +curl -X POST http://localhost:8080/api/enterprise/guardrails \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Patronus AI Guardrail", + "provider": "patronus_ai", + "enabled": true, + "config": { + "api_key": "your-patronus-api-key", + "api_endpoint": "https://api.patronus.ai/v1", + "environment": "production" + }, + "validation": { + "validate_input": true, + "validate_output": true, + "action_on_violation": "log", + "timeout_ms": 4000 + }, + "evaluators": { + "hallucination_detection": { + "enabled": true, + "action": "log", + "confidence_threshold": 0.8 + }, + "pii_detection": { + "enabled": true, + "action": "redact", + "entity_types": [ + "PERSON", + "EMAIL_ADDRESS", + "PHONE_NUMBER", + "CREDIT_CARD", + "SSN", + "LOCATION" + ] + }, + "toxicity": { + "enabled": true, + "action": "block", + "threshold": 0.7, + "categories": [ + "toxicity", + "severe_toxicity", + "identity_attack", + "insult", + "profanity", + "threat" + ] + }, + "prompt_injection": { + "enabled": true, + "action": "block", + "confidence_threshold": 0.85 + } + }, + "custom_evaluators": [ + { + "name": "brand_safety", + "evaluator_id": "eval-brand-001", + "action": "block", + "threshold": 0.75 + } + ] + }' +``` + +**Run Evaluation:** +```bash +curl -X POST http://localhost:8080/api/enterprise/guardrails/{guardrail_id}/evaluate \ + -H "Content-Type: application/json" \ + -d '{ + "input_text": "Tell me about quantum computing", + "output_text": "Quantum computing uses quantum mechanics principles...", + "context": "Technical documentation query" + }' + +# Response +{ + "guardrail_id": "patronus-ai-001", + "action_taken": "allow", + "evaluations": [ + { + "evaluator": "hallucination_detection", + "score": 0.95, + "passed": true, + "explanation": "Response is factually accurate" + }, + { + "evaluator": "pii_detection", + "score": 0.0, + "passed": true, + "entities_found": [] + }, + { + "evaluator": "toxicity", + "score": 0.02, + "passed": true, + "categories_detected": [] + }, + { + "evaluator": "prompt_injection", + "score": 0.01, + "passed": true + } + ], + "processing_time_ms": 312 +} +``` + + + + +```json +{ + "enterprise": { + "guardrails": [ + { + "id": "patronus-ai-001", + "name": "Patronus AI Guardrail", + "provider": "patronus_ai", + "enabled": true, + "config": { + "api_key": "${PATRONUS_API_KEY}", + "api_endpoint": "https://api.patronus.ai/v1", + "environment": "production" + }, + "validation": { + "validate_input": true, + "validate_output": true, + "action_on_violation": "log", + "timeout_ms": 4000 + }, + "evaluators": { + "hallucination_detection": { + "enabled": true, + "action": "log", + "confidence_threshold": 0.8 + }, + "pii_detection": { + "enabled": true, + "action": "redact", + "entity_types": [ + "PERSON", + "EMAIL_ADDRESS", + "PHONE_NUMBER" + ] + }, + "toxicity": { + "enabled": true, + "action": "block", + "threshold": 0.7 + }, + "prompt_injection": { + "enabled": true, + "action": "block", + "confidence_threshold": 0.85 + } + } + } + ] + } +} +``` + + + + +--- + +## Using Guardrails in Requests + +### Attaching Guardrails to API Calls + +Once configured, attach guardrails to your LLM requests using custom headers: + +**Single Guardrail:** +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-bf-guardrail-id: bedrock-prod-guardrail" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Help me with this task" + } + ] + }' +``` + +**Multiple Guardrails (Sequential):** +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-bf-guardrail-ids: bedrock-prod-guardrail,azure-content-safety-001" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Help me with this task" + } + ] + }' +``` + +**Guardrail Configuration in Request:** +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Help me with this task" + } + ], + "bifrost_config": { + "guardrails": { + "input": ["bedrock-prod-guardrail"], + "output": ["patronus-ai-001"], + "async": false + } + } + }' +``` + +### Guardrail Response Handling + +**Successful Validation (200):** +```json +{ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1699564800, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I'd be happy to help you with your task..." + }, + "finish_reason": "stop" + } + ], + "extra_fields": { + "guardrails": { + "input_validation": { + "guardrail_id": "bedrock-prod-guardrail", + "status": "passed", + "violations": [], + "processing_time_ms": 245 + }, + "output_validation": { + "guardrail_id": "patronus-ai-001", + "status": "passed", + "violations": [], + "processing_time_ms": 312 + } + } + } +} +``` + +**Validation Failure - Blocked (446):** +```json +{ + "error": { + "message": "Request blocked by guardrails", + "type": "guardrail_violation", + "code": 446, + "details": { + "guardrail_id": "bedrock-prod-guardrail", + "validation_stage": "input", + "violations": [ + { + "type": "PII", + "category": "SSN", + "severity": "HIGH", + "action": "block", + "text_excerpt": "My SSN is ***-**-****" + }, + { + "type": "prompt_injection", + "severity": "CRITICAL", + "action": "block", + "confidence": 0.95 + } + ], + "processing_time_ms": 198 + } + } +} +``` + +**Validation Warning - Logged (246):** +```json +{ + "id": "chatcmpl-def456", + "object": "chat.completion", + "created": 1699564800, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Response with redacted content..." + }, + "finish_reason": "stop" + } + ], + "bifrost_metadata": { + "guardrails": { + "output_validation": { + "guardrail_id": "azure-content-safety-001", + "status": "warning", + "violations": [ + { + "type": "profanity", + "severity": "LOW", + "action": "redact", + "modifications": 2 + } + ], + "processing_time_ms": 187 + } + } + } +} +``` diff --git a/docs/enterprise/intelligent-load-balancing.mdx b/docs/enterprise/intelligent-load-balancing.mdx new file mode 100644 index 000000000..13eb0f264 --- /dev/null +++ b/docs/enterprise/intelligent-load-balancing.mdx @@ -0,0 +1,371 @@ +--- +title: "Adaptive Load Balancing" +description: "Advanced load balancing algorithms with predictive scaling, health monitoring, and performance optimization for enterprise-grade traffic distribution." +icon: "brain" +--- + +## Overview + +**Adaptive Load Balancing** in Bifrost automatically optimizes traffic distribution across provider keys and models based on real-time performance metrics. The system continuously monitors error rates, latency, and throughput to dynamically adjust weights, ensuring optimal performance and reliability. + +### Key Features + +| Feature | Description | +|---------|-------------| +| **Dynamic Weight Adjustment** | Automatically adjusts key weights based on performance metrics | +| **Real-time Performance Monitoring** | Tracks error rates, latency, and success rates per model-key combination | +| **Cross-Node Synchronization** | Gossip protocol ensures consistent weight information across all cluster nodes | +| **Predictive Scaling** | Anticipates traffic patterns and adjusts weights proactively | +| **Circuit Breaker Integration** | Temporarily removes poorly performing keys from rotation | +| **Model-Level Optimization** | Optimizes performance at both provider and individual model levels | + +--- + +## How Adaptive Load Balancing Works + +### Performance Metrics Collection + +The system continuously collects performance data for each model-key combination: + +```json +{ + "provider": "openai", + "model_key_id": "gpt-4-key-1", + "metrics": { + "avg_latency_ms": 1200, + "error_rate": 0.01, + "success_rate": 0.99, + "requests_per_minute": 362, + "tokens_processed": 87500, + "current_weight": 0.8, + "baseline_latency_ms": 980, + "performance_score": 0.85 + } +} +``` + +### Weight Adjustment Algorithm + +The adaptive load balancer automatically adjusts weights based on real-time performance metrics: + +- **High Error Rates**: Reduces weight for keys with elevated error rates +- **Latency Spikes**: Decreases weight when response times exceed baseline thresholds +- **Superior Performance**: Increases weight for consistently high-performing keys +- **Gradual Adjustments**: Makes incremental changes to prevent traffic oscillation + +### Real-Time Weight Synchronization + +In clustered deployments, weight adjustments are synchronized across all nodes using the gossip protocol: + +#### Weight Update Message Format + +```json +{ + "version": 1, + "type": "weight_update", + "node_id": "bifrost-node-b", + "timestamp": "2024-01-15T10:30:15Z", + "data": { + "provider": "openai", + "model_key_id": "gpt-4-key-2", + "weight_change": { + "from": 0.8, + "to": 0.6, + "reason": "high_error_rate", + "threshold_exceeded": 0.025, + "adjustment_factor": 0.75 + }, + "performance_metrics": { + "avg_latency_ms": 1450, + "baseline_latency_ms": 1100, + "error_rate": 0.03, + "success_rate": 0.97, + "requests_count": 150, + "performance_score": 0.72 + }, + "next_evaluation": "2024-01-15T10:31:15Z" + } +} +``` + +--- + +## Performance Monitoring & Alerting + +### Key Performance Indicators + +The system tracks these critical metrics for each model-key combination: + +| Metric | Threshold | Action | +|--------|-----------|--------| +| **Error Rate** | > 2.5% | Reduce weight by 30% | +| **Latency Spike** | > 150% baseline | Reduce weight by 20% | +| **Success Rate** | < 95% | Circuit breaker activation | +| **Response Time** | > 5000ms | Temporary removal from pool | +| **Throughput Drop** | < 50% expected | Weight adjustment | + +### Automatic Performance Alerts + +```json +{ + "version": 1, + "type": "performance_alert", + "node_id": "bifrost-node-c", + "timestamp": "2024-01-15T10:31:00Z", + "data": { + "alert_type": "latency_spike", + "severity": "warning", + "provider": "anthropic", + "model_key_id": "claude-3-key-1", + "current_metrics": { + "avg_latency_ms": 2800, + "baseline_latency_ms": 980, + "spike_percentage": 185.7, + "error_rate": 0.008, + "current_weight": 1.0 + }, + "recommended_action": "reduce_weight", + "suggested_new_weight": 0.7, + "auto_applied": true + } +} +``` + +--- + +## Configuration + +### Basic Adaptive Load Balancing Setup + +```json +{ + "adaptive_load_balancing": { + "enabled": true, + "algorithm": "adaptive_weighted", + "evaluation_interval": "30s", + "weight_adjustment": { + "enabled": true, + "max_change_per_cycle": 0.3, + "min_weight": 0.1, + "max_weight": 2.0 + }, + "performance_thresholds": { + "error_rate_warning": 0.02, + "error_rate_critical": 0.05, + "latency_spike_threshold": 1.5, + "circuit_breaker_threshold": 0.95 + } + } +} +``` + +### Advanced Configuration + +```json +{ + "adaptive_load_balancing": { + "enabled": true, + "algorithm": "adaptive_weighted", + "evaluation_interval": "30s", + "weight_adjustment": { + "enabled": true, + "strategy": "performance_based", + "max_change_per_cycle": 0.3, + "min_weight": 0.1, + "max_weight": 2.0, + "adjustment_factors": { + "error_rate_penalty": 0.7, + "latency_penalty": 0.8, + "performance_bonus": 1.1 + } + }, + "performance_thresholds": { + "error_rate_warning": 0.02, + "error_rate_critical": 0.05, + "latency_spike_threshold": 1.5, + "latency_critical_threshold": 2.0, + "circuit_breaker_threshold": 0.95, + "recovery_threshold": 0.98 + }, + "metrics_collection": { + "window_size": "5m", + "sample_rate": "1s", + "baseline_calculation": "rolling_average_7d" + }, + "predictive_scaling": { + "enabled": true, + "prediction_window": "15m", + "confidence_threshold": 0.8, + "proactive_adjustments": true + } + } +} +``` + +### Provider-Specific Configuration + +```json +{ + "providers": [ + { + "id": "openai", + "keys": [ + { + "key": "sk-...", + "model_key_id": "gpt-4-key-1", + "weight": 1.0, + "adaptive_balancing": { + "enabled": true, + "baseline_latency_ms": 1100, + "expected_error_rate": 0.01, + "max_requests_per_minute": 500, + "priority": "high" + } + }, + { + "key": "sk-...", + "model_key_id": "gpt-4-key-2", + "weight": 0.8, + "adaptive_balancing": { + "enabled": true, + "baseline_latency_ms": 1200, + "expected_error_rate": 0.015, + "max_requests_per_minute": 400, + "priority": "medium" + } + } + ] + } + ] +} +``` + +--- + +## Traffic Distribution Examples + +### Before Adaptive Load Balancing + +```json +{ + "provider": "openai", + "traffic_distribution": { + "gpt-4-key-1": { + "weight": 1.0, + "traffic_percentage": 50.0, + "avg_latency_ms": 1450, + "error_rate": 0.03, + "status": "degraded_performance" + }, + "gpt-4-key-2": { + "weight": 1.0, + "traffic_percentage": 50.0, + "avg_latency_ms": 1100, + "error_rate": 0.01, + "status": "healthy" + } + } +} +``` + +### After Adaptive Load Balancing + +```json +{ + "provider": "openai", + "traffic_distribution": { + "gpt-4-key-1": { + "weight": 0.6, + "traffic_percentage": 35.3, + "avg_latency_ms": 1450, + "error_rate": 0.03, + "status": "weight_reduced", + "adjustment_reason": "high_error_rate_and_latency" + }, + "gpt-4-key-2": { + "weight": 1.1, + "traffic_percentage": 64.7, + "avg_latency_ms": 1100, + "error_rate": 0.01, + "status": "weight_increased", + "adjustment_reason": "superior_performance" + } + }, + "overall_improvement": { + "avg_latency_reduction": "12.3%", + "error_rate_reduction": "23.1%", + "throughput_increase": "8.7%" + } +} +``` + +--- + +## Monitoring Dashboard + +### Real-Time Performance View + +Monitor adaptive load balancing effectiveness through these key metrics: + +```json +{ + "adaptive_load_balancing_metrics": { + "last_evaluation": "2024-01-15T10:30:00Z", + "next_evaluation": "2024-01-15T10:30:30Z", + "total_adjustments_last_hour": 12, + "performance_improvements": { + "latency_improvement": "15.2%", + "error_rate_reduction": "28.4%", + "throughput_increase": "11.8%" + }, + "provider_performance": { + "openai": { + "total_keys": 3, + "healthy_keys": 2, + "degraded_keys": 1, + "avg_weight": 0.83, + "traffic_distribution": { + "gpt-4-key-1": { + "weight": 0.6, + "traffic_percentage": 28.5, + "performance_score": 0.72, + "trend": "declining" + }, + "gpt-4-key-2": { + "weight": 1.1, + "traffic_percentage": 52.3, + "performance_score": 0.94, + "trend": "stable" + }, + "gpt-4-key-3": { + "weight": 0.9, + "traffic_percentage": 19.2, + "performance_score": 0.87, + "trend": "improving" + } + } + }, + "anthropic": { + "total_keys": 2, + "healthy_keys": 2, + "degraded_keys": 0, + "avg_weight": 1.05, + "traffic_distribution": { + "claude-3-key-1": { + "weight": 1.0, + "traffic_percentage": 48.2, + "performance_score": 0.91, + "trend": "stable" + }, + "claude-3-key-2": { + "weight": 1.1, + "traffic_percentage": 51.8, + "performance_score": 0.95, + "trend": "improving" + } + } + } + } + } +} +``` \ No newline at end of file diff --git a/docs/enterprise/invpc-deployments.mdx b/docs/enterprise/invpc-deployments.mdx new file mode 100644 index 000000000..42e2f30c1 --- /dev/null +++ b/docs/enterprise/invpc-deployments.mdx @@ -0,0 +1,108 @@ +--- +title: "In-VPC Deployments" +description: "Deploy Bifrost within your private cloud infrastructure with VPC isolation, custom networking, and enhanced security controls for enterprise environments." +icon: "cloud" +--- + +In-VPC (Virtual Private Cloud) deployments allow you to run Bifrost entirely within your private cloud infrastructure, providing maximum security, compliance, and control over your AI gateway deployment. + +## Supported Cloud Providers + +Bifrost supports INVPC deployments across all major cloud providers: + +
+
+ Google Cloud Platform +
+
+ Amazon Web Services +
+
+ Microsoft Azure +
+
+ Cloudflare +
+
+ Vercel +
+
+ +## Architecture Benefits + +### Security & Compliance +- **Network Isolation**: Complete isolation within your VPC with no external network dependencies +- **Data Sovereignty**: All data processing occurs within your controlled environment +- **Compliance Ready**: Meets requirements for HIPAA, SOC2, GDPR, and other regulatory frameworks +- **Zero Trust Architecture**: Implements principle of least privilege with granular access controls + +### Performance & Reliability +- **Low Latency**: Direct communication between services within your network +- **High Availability**: Multi-zone deployment with automatic failover capabilities +- **Guaranteed Uptime**: 99.95% SLA with comprehensive monitoring and alerting + +### Control & Customization +- **Custom Networking**: Configure subnets, routing, and security groups to your specifications +- **Resource Management**: Full control over compute, storage, and network resources +- **Scaling Policies**: Define auto-scaling rules based on your usage patterns + +## Service Level Agreement + +### Availability Commitment +- **Uptime Guarantee**: 99.95% monthly uptime for all core components +- **Downtime Calculation**: `(Total Minutes - Downtime Minutes) / Total Minutes Γ— 100` +- **Partial Downtime**: Reduced functionality counted as 50% downtime + +### Core Components Covered +The following components are monitored for SLA compliance: +- Gateway instance +- Log ingestion pipeline + +### Exclusions +SLA excludes downtime due to: +- Scheduled maintenance (14-day advance notice) +- Downstream provider incidents +- Client hardware/software/network issues +- Third-party AI provider outages +- Client misuse or unauthorized modifications + +## Support & Maintenance + +### Technical Support +- **24/7 Critical Support**: Available for core component issues +- **Multiple Channels**: Platform, email (contact@getmaxim.ai), or Slack Connect +- **Audit Trail**: Detailed logs for any data access during troubleshooting + +### Maintenance Windows +- **Scheduled Maintenance**: 14-day advance notice for major updates +- **Security Patches**: Immediate or 14-day delayed application (your choice) +- **Continuous Updates**: Regular feature improvements with 7-day advance notice + +## Getting Started + +### Prerequisites +- VPC with appropriate CIDR ranges +- Kubernetes cluster (GKE, EKS, or AKS) +- Container registry access +- DNS configuration for internal routing + +### Deployment Process +1. **Infrastructure Setup**: Configure VPC, subnets, and security groups +2. **Cluster Preparation**: Set up Kubernetes cluster with required permissions +3. **Bifrost Installation**: Deploy using provided Helm charts or manifests +4. **Configuration**: Apply your specific settings and integrations +5. **Validation**: Run connectivity and performance tests +6. **Go Live**: Begin routing production traffic + + +## Cost Optimization + +### Resource Sizing +- **Development**: 2 vCPU, 4GB RAM minimum +- **Production**: 4+ vCPU, 8GB+ RAM recommended +- **High Availability**: Multi-zone deployment with load balancing + +### Scaling Strategies +- **Horizontal Pod Autoscaling**: Based on CPU/memory utilization +- **Vertical Pod Autoscaling**: Automatic resource adjustment +- **Cluster Autoscaling**: Node pool expansion/contraction diff --git a/docs/enterprise/log-exports.mdx b/docs/enterprise/log-exports.mdx new file mode 100644 index 000000000..a179e79a5 --- /dev/null +++ b/docs/enterprise/log-exports.mdx @@ -0,0 +1,348 @@ +--- +title: "Log Exports" +description: "Export and analyze request logs, traces, and telemetry data from Bifrost with enterprise-grade data export capabilities for compliance, monitoring, and analytics." +icon: "download" +--- + +# Log Exports + +Bifrost Enterprise provides comprehensive log export capabilities, allowing you to automatically export request logs, traces, and telemetry data to various storage systems and data lakes on configurable schedules. + +## Overview + +The log export system enables: +- **Scheduled Exports**: Daily, weekly, or monthly automated exports +- **Multiple Destinations**: Object stores, data warehouses, and data lakes +- **Format Flexibility**: JSON, CSV, Parquet, and custom formats +- **Filtering & Transformation**: Export specific data subsets with custom transformations +- **Compliance**: Meet data retention and audit requirements + +## Supported Export Destinations + +### Object Storage + +#### Amazon S3 +```json +{ + "export": { + "destination": "s3", + "config": { + "bucket": "bifrost-logs", + "region": "us-west-2", + "prefix": "logs/{year}/{month}/{day}/", + "credentials": { + "access_key_id": "${AWS_ACCESS_KEY_ID}", + "secret_access_key": "${AWS_SECRET_ACCESS_KEY}" + } + } + } +} +``` + +#### Google Cloud Storage +```json +{ + "export": { + "destination": "gcs", + "config": { + "bucket": "bifrost-logs", + "prefix": "logs/{year}/{month}/{day}/", + "credentials": { + "service_account_key": "${GCP_SERVICE_ACCOUNT_KEY}" + } + } + } +} +``` + +#### Azure Blob Storage +```json +{ + "export": { + "destination": "azure_blob", + "config": { + "container": "bifrost-logs", + "account_name": "${AZURE_ACCOUNT_NAME}", + "account_key": "${AZURE_ACCOUNT_KEY}", + "prefix": "logs/{year}/{month}/{day}/" + } + } +} +``` + +### Data Warehouses & Lakes + +#### Snowflake +```json +{ + "export": { + "destination": "snowflake", + "config": { + "account": "your-account.snowflakecomputing.com", + "database": "BIFROST_LOGS", + "schema": "PUBLIC", + "table": "request_logs", + "warehouse": "COMPUTE_WH", + "credentials": { + "username": "${SNOWFLAKE_USERNAME}", + "password": "${SNOWFLAKE_PASSWORD}" + } + } + } +} +``` + +#### Amazon Redshift +```json +{ + "export": { + "destination": "redshift", + "config": { + "cluster": "bifrost-cluster", + "database": "bifrost_logs", + "schema": "public", + "table": "request_logs", + "region": "us-west-2", + "credentials": { + "username": "${REDSHIFT_USERNAME}", + "password": "${REDSHIFT_PASSWORD}" + } + } + } +} +``` + +#### Google BigQuery +```json +{ + "export": { + "destination": "bigquery", + "config": { + "project_id": "your-project-id", + "dataset": "bifrost_logs", + "table": "request_logs", + "credentials": { + "service_account_key": "${GCP_SERVICE_ACCOUNT_KEY}" + } + } + } +} +``` + +## Export Schedules + +### Daily Exports +```json +{ + "export": { + "schedule": "daily", + "time": "02:00", + "timezone": "UTC" + } +} +``` + +### Weekly Exports +```json +{ + "export": { + "schedule": "weekly", + "day": "sunday", + "time": "03:00", + "timezone": "UTC" + } +} +``` + +### Monthly Exports +```json +{ + "export": { + "schedule": "monthly", + "day": 1, + "time": "04:00", + "timezone": "UTC" + } +} +``` + +## Export Configuration + +### Complete Export Configuration Example + +```json +{ + "log_exports": { + "enabled": true, + "exports": [ + { + "name": "daily_s3_export", + "enabled": true, + "schedule": { + "frequency": "daily", + "time": "02:00", + "timezone": "UTC" + }, + "destination": { + "type": "s3", + "config": { + "bucket": "bifrost-logs-prod", + "region": "us-west-2", + "prefix": "daily-exports/{year}/{month}/{day}/", + "credentials": { + "access_key_id": "${AWS_ACCESS_KEY_ID}", + "secret_access_key": "${AWS_SECRET_ACCESS_KEY}" + } + } + }, + "data": { + "format": "parquet", + "compression": "gzip", + "include": [ + "request_logs", + "response_logs", + "error_logs" + ], + "filters": { + "date_range": "last_24_hours", + "status_codes": [200, 400, 401, 403, 404, 500] + } + } + }, + { + "name": "weekly_bigquery_export", + "enabled": true, + "schedule": { + "frequency": "weekly", + "day": "sunday", + "time": "03:00", + "timezone": "UTC" + }, + "destination": { + "type": "bigquery", + "config": { + "project_id": "your-analytics-project", + "dataset": "bifrost_analytics", + "table": "weekly_logs", + "credentials": { + "service_account_key": "${GCP_SERVICE_ACCOUNT_KEY}" + } + } + }, + "data": { + "format": "json", + "include": [ + "request_logs", + "metrics", + "traces" + ], + "transformations": [ + { + "type": "aggregate", + "group_by": ["provider", "model", "customer_id"], + "metrics": ["total_requests", "avg_latency", "error_rate"] + } + ] + } + } + ] + } +} +``` + +## Data Formats + +### JSON Format +```json +{ + "timestamp": "2024-01-15T10:30:00Z", + "request_id": "req_123456789", + "customer_id": "cust_abc123", + "provider": "openai", + "model": "gpt-4", + "endpoint": "/v1/chat/completions", + "method": "POST", + "status_code": 200, + "latency_ms": 1250, + "input_tokens": 100, + "output_tokens": 150, + "cost_usd": 0.0045 +} +``` + +### CSV Format +```csv +timestamp,request_id,customer_id,provider,model,endpoint,method,status_code,latency_ms,input_tokens,output_tokens,cost_usd +2024-01-15T10:30:00Z,req_123456789,cust_abc123,openai,gpt-4,/v1/chat/completions,POST,200,1250,100,150,0.0045 +``` + +### Parquet Schema +``` +message log_record { + required int64 timestamp; + required binary request_id (UTF8); + required binary customer_id (UTF8); + required binary provider (UTF8); + required binary model (UTF8); + required binary endpoint (UTF8); + required binary method (UTF8); + required int32 status_code; + required int32 latency_ms; + optional int32 input_tokens; + optional int32 output_tokens; + optional double cost_usd; +} +``` + +## Data Filtering & Transformation + +### Filtering Options +```json +{ + "filters": { + "date_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-01-31T23:59:59Z" + }, + "providers": ["openai", "anthropic", "azure"], + "models": ["gpt-4", "claude-3-sonnet"], + "status_codes": [200, 201, 400, 401, 403, 404, 500], + "customers": ["cust_123", "cust_456"], + "min_latency_ms": 100, + "max_latency_ms": 10000, + "has_errors": true + } +} +``` + +### Transformation Options +```json +{ + "transformations": [ + { + "type": "aggregate", + "group_by": ["provider", "model", "date"], + "metrics": [ + "count", + "avg_latency", + "p95_latency", + "total_tokens", + "total_cost", + "error_rate" + ] + }, + { + "type": "anonymize", + "fields": ["customer_id", "request_id"], + "method": "hash" + }, + { + "type": "enrich", + "add_fields": { + "export_timestamp": "${EXPORT_TIMESTAMP}", + "export_version": "${EXPORT_VERSION}" + } + } + ] +} +``` \ No newline at end of file diff --git a/docs/enterprise/mcp-with-fa.mdx b/docs/enterprise/mcp-with-fa.mdx new file mode 100644 index 000000000..8959fa106 --- /dev/null +++ b/docs/enterprise/mcp-with-fa.mdx @@ -0,0 +1,189 @@ +--- +title: "MCP with Federated Auth" +description: "Transform your existing private enterprise APIs into LLM-ready MCP tools using federated authentication without writing a single line of code" +icon: "screwdriver-wrench" +--- + +Transform your existing private enterprise APIs into LLM-ready MCP tools instantly. Add your APIs along with authentication information, and Bifrost dynamically syncs user authentication to allow these existing APIs to be used as MCP tools. + +## Supported Import Methods + +Add your existing APIs to Bifrost using any of these methods: + + + +Import your existing Postman collections directly into Bifrost. All request configurations, headers, and parameters are preserved. + +```json +{ + "info": { + "name": "Enterprise API Collection", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Get User Profile", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "{{req.header.authorization}}", + "type": "text" + } + ], + "url": { + "raw": "https://api.company.com/users/profile", + "host": ["api", "company", "com"], + "path": ["users", "profile"] + } + } + } + ] +} +``` + + + +Use your existing OpenAPI 3.0+ specifications. Bifrost automatically converts them into MCP-compatible tools. + +```yaml +openapi: 3.0.0 +info: + title: Enterprise API + version: 1.0.0 +paths: + /users/profile: + get: + summary: Get user profile + security: + - BearerAuth: [] + parameters: + - name: Authorization + in: header + schema: + type: string + example: "{{req.header.authorization}}" +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer +``` + + + +Convert your existing cURL commands directly into MCP tools. + +```bash +curl -X GET "https://api.company.com/users/profile" \ + -H "Authorization: {{req.header.authorization}}" \ + -H "Content-Type: application/json" +``` + + + +Use Bifrost's intuitive UI to manually configure your API endpoints with the same ease as Postman. + +1. Set HTTP method and URL +2. Configure headers with variable substitution +3. Define request body (if needed) +4. Test the endpoint +5. Deploy as MCP tool + + + +## What Happens Next + +Once you upload your API specifications, Bifrost automatically: + +- **Syncs authentication systems** from your existing APIs +- **Converts endpoints** into MCP-compatible tools +- **Maintains security** using your current auth infrastructure +- **Makes APIs available** to LLMs instantly + +## Supported Authentication Types + +Bifrost automatically handles all common authentication patterns: + +- **Bearer Tokens** (JWT, OAuth) +- **API Keys** (headers, query parameters) +- **Custom Headers** (tenant IDs, user tokens) +- **Basic Auth** and other standard methods + +## Real-World Use Cases + +### Enterprise CRM Integration + +Transform your Salesforce, HubSpot, or custom CRM APIs: + +```json +{ + "name": "Get Customer Data", + "method": "GET", + "url": "https://api.company.com/crm/customers/{{req.body.customer_id}}", + "headers": { + "Authorization": "{{req.header.authorization}}", + "X-Tenant-ID": "{{req.header.x-tenant-id}}" + } +} +``` + +### Internal Microservices + +Make your internal microservices LLM-accessible: + +```yaml +paths: + /internal/user-service/profile: + get: + parameters: + - name: Authorization + in: header + schema: + type: string + default: "{{req.header.authorization}}" + - name: X-Service-Token + in: header + schema: + type: string + default: "{{env.INTERNAL_SERVICE_TOKEN}}" +``` + +### Database APIs + +Connect to your database APIs securely: + +```http +POST https://db-api.company.com/query +Content-Type: application/json +Authorization: {{req.header.authorization}} +X-Database-Name: {{req.header.x-database}} + +{ + "query": "SELECT * FROM users WHERE tenant_id = '{{req.body.tenant_id}}'", + "limit": 100 +} +``` + +## Security Benefits + +### 1. **Zero Trust Architecture** +- Authentication happens at the edge (your existing systems) +- Bifrost never stores or caches authentication credentials +- Each request is authenticated independently + +### 2. **Existing Security Policies** +- Leverage your current RBAC (Role-Based Access Control) +- Maintain existing audit trails +- No changes to security infrastructure required + +### 3. **Granular Access Control** +- Different users get different API access based on their credentials +- Tenant isolation maintained through existing headers +- API rate limiting and quotas preserved + +### 4. **Compliance Friendly** +- No sensitive data passes through Bifrost permanently +- Existing compliance frameworks remain intact +- Audit trails maintained in your systems \ No newline at end of file diff --git a/docs/enterprise/vault-support.mdx b/docs/enterprise/vault-support.mdx new file mode 100644 index 000000000..ef2ffc4c4 --- /dev/null +++ b/docs/enterprise/vault-support.mdx @@ -0,0 +1,182 @@ +--- +title: "Vault Support" +description: "Secure API key management with HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, and Azure Key Vault integration. Store and retrieve sensitive credentials using enterprise-grade secret management." +icon: "vault" +--- + +Bifrost's vault support enables seamless integration with enterprise-grade secret management systems, allowing you to connect to existing vaults and automatically sync virtual keys and provider API keys directly onto the Bifrost platform. + +## Overview + +The vault integration provides: + +- **Automated Key Synchronization**: Connect to your existing vault infrastructure and sync all API keys automatically +- **Periodic Key Management**: Regular synchronization ensures deprecated and archived keys are properly managed +- **Multi-Vault Support**: Compatible with HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, and Azure Key Vault +- **Zero-Downtime Operations**: Keys are synced without interrupting your running services + +## Supported Vault Systems + +### HashiCorp Vault + +Connect to your HashiCorp Vault instance for centralized secret management. + +```json +{ + "vault": { + "type": "hashicorp", + "address": "https://vault.company.com:8200", + "token": "${VAULT_TOKEN}", + "mount": "secret", + "sync_interval": "300s" + } +} +``` + +### AWS Secrets Manager + +Integrate with AWS Secrets Manager for cloud-native secret storage. + +```json +{ + "vault": { + "type": "aws_secrets_manager", + "region": "us-east-1", + "access_key_id": "${AWS_ACCESS_KEY_ID}", + "secret_access_key": "${AWS_SECRET_ACCESS_KEY}", + "sync_interval": "300s" + } +} +``` + +### Google Secret Manager + +Use Google Cloud's Secret Manager for secure key storage. + +```json +{ + "vault": { + "type": "google_secret_manager", + "project_id": "your-project-id", + "credentials_file": "/path/to/service-account.json", + "sync_interval": "300s" + } +} +``` + +### Azure Key Vault + +Connect to Azure Key Vault for Microsoft cloud environments. + +```json +{ + "vault": { + "type": "azure_key_vault", + "vault_url": "https://your-keyvault.vault.azure.net/", + "client_id": "${AZURE_CLIENT_ID}", + "client_secret": "${AZURE_CLIENT_SECRET}", + "tenant_id": "${AZURE_TENANT_ID}", + "sync_interval": "300s" + } +} +``` + +## Key Synchronization + +### Automatic Sync Process + +Bifrost automatically synchronizes keys from your vault at regular intervals: + +1. **Discovery**: Scans the configured vault paths for API keys and virtual keys +2. **Validation**: Verifies key format and accessibility +3. **Sync**: Updates Bifrost's internal key store with new and modified keys +4. **Deprecation**: Identifies and archives keys that have been removed from the vault +5. **Notification**: Logs sync status and any issues encountered + +### Sync Configuration + +Configure synchronization behavior to match your operational requirements: + +```json +{ + "vault": { + "sync_interval": "300s", + "sync_paths": [ + "bifrost/provider-keys/*", + "bifrost/virtual-keys/*" + ], + "auto_deprecate": true, + "backup_deprecated_keys": true + } +} +``` + +#### Configuration Options + +| Option | Description | Default | +|--------|-------------|---------| +| `sync_interval` | Time between sync operations | `300s` | +| `sync_paths` | Vault paths to monitor for keys | `["bifrost/*"]` | +| `auto_deprecate` | Automatically deprecate removed keys | `true` | +| `backup_deprecated_keys` | Backup keys before deprecation | `true` | + +## Key Management Lifecycle + +### Key States + +Keys in Bifrost can have the following states: + +- **Active**: Currently in use and available for requests +- **Deprecated**: Marked for removal but still functional +- **Archived**: Removed from active use but retained for audit purposes +- **Expired**: Keys that have exceeded their validity period + +### Deprecation Process + +When keys are removed from the vault: + +1. **Detection**: Next sync cycle identifies missing keys +2. **Grace Period**: Keys enter deprecated state with configurable grace period +3. **Notification**: Administrators are notified of pending deprecation +4. **Archive**: Keys are moved to archived state after grace period expires + +```json +{ + "vault": { + "deprecation": { + "grace_period": "24h", + "notify_admins": true, + "retain_archived": "90d" + } + } +} +``` + +## Security Considerations + +### Authentication + +- **Vault Tokens**: Use time-limited tokens with minimal required permissions +- **IAM Roles**: Leverage cloud provider IAM roles for secure authentication +- **Certificate-based Auth**: Support for mutual TLS authentication where available + +### Encryption + +- **Transit Encryption**: All communication with vault systems uses TLS +- **At-Rest Encryption**: Keys are encrypted in Bifrost's internal storage +- **Key Rotation**: Automatic detection and handling of rotated vault credentials + +### Audit Trail + +Complete audit logging for all vault operations: + +```json +{ + "timestamp": "2024-01-15T10:30:00Z", + "operation": "key_sync", + "vault_type": "hashicorp", + "keys_synced": 15, + "keys_deprecated": 2, + "status": "success" +} +``` diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 000000000..856be557a Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/favicon.png b/docs/favicon.png new file mode 100644 index 000000000..19ed93b1f Binary files /dev/null and b/docs/favicon.png differ diff --git a/docs/features/custom-providers.mdx b/docs/features/custom-providers.mdx new file mode 100644 index 000000000..bc438c4ba --- /dev/null +++ b/docs/features/custom-providers.mdx @@ -0,0 +1,457 @@ +--- +title: "Custom Providers" +description: "Create custom provider configurations with specific request type restrictions, custom naming, and controlled access patterns." +icon: "gears" +--- + +## What Are Custom Providers? + +Custom providers allow you to create multiple instances of the same base provider, each with different configurations and access patterns. The key feature is request type control, which enables you to restrict what operations each custom provider instance can perform. + +Think of custom providers as "multiple views" of the same underlying provider β€” you can create several custom configurations for OpenAI, Anthropic, or any other provider, each optimized for different use cases while sharing the same API keys and base infrastructure. + +## Key Benefits + +- **Multiple Provider Instances**: Create several configurations of the same base provider (e.g., multiple OpenAI configurations) +- **Request Type Control**: Restrict which operations (chat, embeddings, speech, etc.) each custom provider can perform +- **Custom Naming**: Use descriptive names like "openai-production" or "openai-staging" +- **Provider Reuse**: Maximize the value of your existing provider accounts + +## How to Configure + +Custom providers are configured using the `custom_provider_config` field, which extends the standard provider configuration. The main purpose is to create multiple instances of the same base provider, each with different request type restrictions. + +**Important**: The `allowed_requests` field follows a specific behavior: +- **Omitted entirely**: All operations are allowed (default behavior) +- **Partially specified**: Only explicitly set fields are allowed, others default to `false` +- **Fully specified**: Only the operations you explicitly enable are allowed +- **Present but empty object (`{}`)**: All fields are set to false + + + + + +![Provider Configuration Interface](../media/ui-custom-provider.png) + +1. Go to **http://localhost:8080** +2. Navigate to **"Providers"** in the sidebar +3. Click **"Add New Provider"** +4. Choose a unique provider name (e.g., "openai-custom") +5. Select the base provider type (e.g., "openai") +6. Configure which request types are allowed +7. Save configuration + + + + + +```bash +# Create a chat-only custom provider +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai-custom", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "list_models": false, + "text_completion": false, + "text_completion_stream": false, + "chat_completion": true, + "chat_completion_stream": true, + "responses": false, + "responses_stream": false, + "embedding": false, + "speech": false, + "speech_stream": false, + "transcription": false, + "transcription_stream": false + }, + "request_path_overrides": { + "chat_completion": "/v1/chat/completions" + } + } +}' +``` + + + + + +```json +{ + "providers": { + "openai-custom": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "list_models": false, + "text_completion": false, + "text_completion_stream": false, + "chat_completion": true, + "chat_completion_stream": true, + "responses": false, + "responses_stream": false, + "embedding": false, + "speech": false, + "speech_stream": false, + "transcription": false, + "transcription_stream": false + }, + "request_path_overrides": { + "chat_completion": "/v1/chat/completions" + } + } + } + } +} +``` + + + + + +Create a custom provider using the Go SDK by implementing the Account interface with custom provider configuration: + +```go +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// Define custom provider name +const ProviderOpenAICustom = schemas.ModelProvider("openai-custom") + +type MyAccount struct{} + +func (a *MyAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{ + schemas.OpenAI, + ProviderOpenAICustom, // Include your custom provider + }, nil +} + +func (a *MyAccount) GetKeysForProvider(ctx context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.OpenAI: + return []schemas.Key{{ + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }}, nil + case ProviderOpenAICustom: + return []schemas.Key{{ + Value: os.Getenv("OPENAI_CUSTOM_API_KEY"), // API key for OpenAI-compatible endpoint + Models: []string{}, + Weight: 1.0, + }}, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} + +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch provider { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + case ProviderOpenAICustom: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: "https://your-openai-compatible-endpoint.com", // Custom base URL + DefaultRequestTimeoutInSeconds: 60, + MaxRetries: 1, + RetryBackoffInitial: 100 * time.Millisecond, + RetryBackoffMax: 2 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 3, + BufferSize: 10, + }, + CustomProviderConfig: &schemas.CustomProviderConfig{ + BaseProviderType: schemas.OpenAI, // Use OpenAI protocol + AllowedRequests: &schemas.AllowedRequests{ + TextCompletion: false, + TextCompletionStream: false, + ChatCompletion: true, // Enable chat completion + ChatCompletionStream: true, // Enable streaming + Responses: false, + ResponsesStream: false, + Embedding: false, + Speech: false, + SpeechStream: false, + Transcription: false, + TranscriptionStream: false, + }, + RequestPathOverrides: map[schemas.RequestType]string{ + schemas.ChatCompletionRequest: "/v1/chat/completions", + schemas.ChatCompletionStreamRequest: "/v1/chat/completions", + }, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} + +``` + + + + + +## Configuration Options + +### Allowed Request Types + +Control which operations your custom provider can perform. The behavior is: + +- **If `allowed_requests` is not specified**: All operations are allowed by default +- **If `allowed_requests` is specified**: Only the fields set to `true` are allowed, all others default to `false` + +Available operations: + +- **`text_completion`**: Legacy text completion requests +- **`text_completion_stream`**: Streaming text completion requests +- **`chat_completion`**: Standard chat completion requests +- **`chat_completion_stream`**: Streaming chat responses +- **`responses`**: Standard responses requests +- **`responses_stream`**: Streaming responses requests +- **`embedding`**: Text embedding generation +- **`speech`**: Text-to-speech conversion +- **`speech_stream`**: Streaming text-to-speech +- **`transcription`**: Speech-to-text conversion +- **`transcription_stream`**: Streaming speech-to-text + +### Base Provider Types + +Custom providers can be built on these supported providers: + +- `openai` - OpenAI API +- `anthropic` - Anthropic Claude +- `bedrock` - AWS Bedrock +- `cohere` - Cohere +- `gemini` - Gemini + +### Request Path Overrides + +The `request_path_overrides` field allows you to override the default API endpoint paths for specific request types. This is useful when: + +- Connecting to custom or self-hosted model providers +- Integrating with proxies that expect specific URL patterns +- Using provider forks with modified API paths + + +**Not Supported:** `request_path_overrides` is not supported for `gemini` and `bedrock` base provider types due to their specialized API implementations. + + +The field accepts a mapping of request types to custom paths: + +```json +{ + "request_path_overrides": { + "chat_completion": "/v1/chat/completions", + "chat_completion_stream": "/v1/chat/completions", + "embedding": "/v1/embeddings", + "text_completion": "/v1/completions" + } +} +``` + +**Example: OpenAI-Compatible Endpoint with Custom Paths** + +```json +{ + "custom-llm": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "network_config": { + "base_url": "https://your-openai-compatible-endpoint.com" + }, + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": true + }, + "request_path_overrides": { + "chat_completion": "/api/v2/chat", + "chat_completion_stream": "/api/v2/chat" + } + } + } +} +``` + +In this example, instead of using OpenAI's default `/v1/chat/completions` path, requests will be sent to `https://custom-endpoint.example.com/api/v2/chat`. + +## Use Cases + +### 1. Environment-Specific Configurations + +Create different configurations for production, staging, and development environments: + +```json +{ + "openai-production": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": true, + "embedding": true, + "speech": true, + "speech_stream": true + } + } + }, + "openai-staging": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": true, + "embedding": true, + "speech": false, + "speech_stream": false + } + } + }, + "openai-dev": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": false, + "embedding": false, + "speech": false, + "speech_stream": false + } + } + } +} +``` + +### 2. Role-Based Access Control + +Restrict capabilities based on user roles or team permissions. You can then create virtual keys for better management of who can access which providers, providing granular control over team permissions and resource usage. This integrates seamlessly with Bifrost's **[governance](./governance)** features for comprehensive access control and monitoring: + +```json +{ + "openai-developers": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": true, + "embedding": true, + "text_completion": true + } + } + }, + "openai-analysts": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "embedding": true + } + } + }, + "openai-support": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": false + } + } + } +} +``` + +### 3. Feature Testing and Rollouts + +Test new features with limited user groups: + +```json +{ + "openai-beta-streaming": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": true, + "embedding": false + } + } + }, + "openai-stable": { + "keys": [{ "value": "env.PROVIDER_API_KEY", "models": [], "weight": 1.0 }], + "custom_provider_config": { + "base_provider_type": "openai", + "allowed_requests": { + "chat_completion": true, + "chat_completion_stream": false, + "embedding": true + } + } + } +} +``` + +## Making Requests + +Use your custom provider name in requests: + +```bash +# Request to custom provider +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai-custom/gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Hello!"} + ] +}' +``` + +## Relationship to Provider Configuration + +Custom providers extend the standard provider configuration system. They inherit all the capabilities of their base provider while adding request type restrictions. + +**Learn more about provider configuration:** +- **[Gateway Provider Configuration](../quickstart/gateway/provider-configuration)** +- **[Go SDK Provider Configuration](../quickstart/go-sdk/provider-configuration)** + +## Next Steps + +- **[Fallbacks](./fallbacks)** - Automatic failover between providers +- **[Load Balancing](./keys-management)** - Intelligent API key management with weighted load balancing +- **[Governance](./governance)** - Advanced access control and monitoring diff --git a/docs/features/drop-in-replacement.mdx b/docs/features/drop-in-replacement.mdx new file mode 100644 index 000000000..b6be05818 --- /dev/null +++ b/docs/features/drop-in-replacement.mdx @@ -0,0 +1,78 @@ +--- +title: "Drop-in Replacement" +description: "Replace your existing AI SDK connections with Bifrost by changing just the base URL. Keep your code, gain advanced features like fallbacks, load balancing, and governance." +icon: "shuffle" +--- + +## Zero Code Changes + +The Bifrost Gateway acts as a drop-in replacement for popular AI SDKs. This means you can point your existing OpenAI, Anthropic, or Google GenAI client to Bifrost's HTTP gateway and instantly gain access to advanced features without rewriting your application. + +The magic happens with a single line change: update your `base_url` to point to Bifrost's gateway, and everything else stays exactly the same. + +## How It Works + +Bifrost provides **100% compatible endpoints** for popular AI SDKs by acting as a protocol adapter. Your existing SDK code continues to work unchanged, but now benefits from Bifrost's multi-provider support, automatic failovers, semantic caching, and governance features. + + + + + +```python +# Before: Direct to OpenAI +client = openai.OpenAI( + api_key="your-openai-key" +) + +# After: Through Bifrost +client = openai.OpenAI( + base_url="http://localhost:8080/openai", # Only change needed + api_key="dummy-key" # Keys handled by Bifrost +) +``` + + + + + +```python +# Before: Direct to Anthropic +client = anthropic.Anthropic( + api_key="your-anthropic-key" +) + +# After: Through Bifrost +client = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", # Only change needed + api_key="dummy-key" # Keys handled by Bifrost +) +``` + + + + + +## Instant Advanced Features + +Once your SDK points to Bifrost, you automatically get: + +- **Multi-provider support** with automatic failovers +- **Load balancing** across multiple API keys +- **Semantic caching** for faster responses +- **Governance controls** for usage monitoring and budgets +- **Request/response logging** and analytics +- **Rate limiting** and circuit breakers + +and so much more! All without changing a **single line** of your application logic. + +## Complete Integration Support + +Bifrost provides drop-in compatibility for multiple popular AI SDKs and frameworks: + +- **[OpenAI SDK](../integrations/openai-sdk)** +- **[Anthropic SDK](../integrations/anthropic-sdk)** +- **[Google GenAI SDK](../integrations/genai-sdk)** +- **[LiteLLM](../integrations/litellm-sdk)** +- **[LangChain](../integrations/langchain-sdk)** + +**For detailed setup instructions and compatibility information:** [Complete Integration Guide](../integrations/what-is-an-integration) \ No newline at end of file diff --git a/docs/features/fallbacks.mdx b/docs/features/fallbacks.mdx new file mode 100644 index 000000000..889ff59f6 --- /dev/null +++ b/docs/features/fallbacks.mdx @@ -0,0 +1,187 @@ +--- +title: "Fallbacks" +description: "Automatic failover between AI providers and models. When your primary provider fails, Bifrost seamlessly switches to backup providers without interrupting your application." +icon: "list-check" +--- + +## Automatic Provider Failover + +Fallbacks provide automatic failover when your primary AI provider experiences issues. Whether it's rate limiting, outages, or model unavailability, Bifrost automatically tries backup providers in the order you specify until one succeeds. + +When a fallback is triggered, Bifrost treats it as a completely new request - all configured plugins (caching, governance, logging, etc.) run again for the fallback provider, ensuring consistent behavior across all providers. + +## How Fallbacks Work + +When you configure fallbacks, Bifrost follows this process: + +1. **Primary Attempt**: Tries your main provider/model first +2. **Automatic Detection**: If the primary fails (network error, rate limit, model unavailable), Bifrost detects the failure +3. **Sequential Fallbacks**: Tries each fallback provider in order until one succeeds +4. **Success Response**: Returns the response from the first successful provider +5. **Complete Failure**: If all providers fail, returns the original error from the primary provider + +Each fallback attempt is treated as a fresh request, so all your configured plugins (semantic caching, governance rules, monitoring) apply to whichever provider ultimately handles the request. + +## Implementation Examples + + + + +```bash +# Chat completion with multiple fallbacks +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Explain quantum computing in simple terms" + } + ], + "fallbacks": [ + "anthropic/claude-3-5-sonnet-20241022", + "bedrock/anthropic.claude-3-sonnet-20240229-v1:0" + ], + "max_tokens": 1000, + "temperature": 0.7 + }' +``` + +**Response (from whichever provider succeeded):** +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Quantum computing is like having a super-powered calculator..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 150, + "total_tokens": 162 + }, + "extra_fields": { + "provider": "anthropic", + "latency": 1.2 + } +} +``` + + + + + +```go +package main + +import ( + "context" + "fmt" + "github.com/maximhq/bifrost" + "github.com/maximhq/bifrost/core/schemas" +) + +func chatWithFallbacks(client *bifrost.Bifrost) { + ctx := context.Background() + + // Chat request with multiple fallbacks + response, err := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Explain quantum computing in simple terms"), + }, + }, + }, + // Fallback chain: OpenAI β†’ Anthropic β†’ Bedrock + Fallbacks: []schemas.Fallback{ + { + Provider: schemas.Anthropic, + Model: "claude-3-5-sonnet-20241022", + }, + { + Provider: schemas.Bedrock, + Model: "anthropic.claude-3-sonnet-20240229-v1:0", + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(1000), + Temperature: bifrost.Ptr(0.7), + }, + }) + + if err != nil { + fmt.Printf("All providers failed: %v\n", err) + return + } + + // Success! Response came from whichever provider worked + fmt.Printf("Response from %s: %s\n", + response.ExtraFields.Provider, + *response.Choices[0].BifrostNonStreamResponseChoice.Message.Content.ContentStr) +} +``` + + + + + +## Real-World Scenarios + +**Scenario 1: Rate Limiting** +- Primary: OpenAI hits rate limit β†’ Fallback: Anthropic succeeds +- Your application continues without interruption + +**Scenario 2: Model Unavailability** +- Primary: Specific model unavailable β†’ Fallback: Different provider with similar model +- Seamless transition to equivalent capability + +**Scenario 3: Provider Outage** +- Primary: Provider experiencing downtime β†’ Fallback: Alternative provider +- Business continuity maintained + +**Scenario 4: Cost Optimization** +- Primary: Premium model for quality β†’ Fallback: Cost-effective alternative if budget exceeded +- Governance rules can trigger fallbacks based on usage + +## Fallback Behavior Details + +**What Triggers Fallbacks:** +- Network connectivity issues +- Provider API errors (500, 502, 503, 504) +- Rate limiting (429 errors) +- Model unavailability +- Request timeouts +- Authentication failures + +**What Preserves Original Error:** +- Request validation errors (malformed requests) +- Plugin-enforced blocks (governance violations) +- Certain provider-specific errors marked as non-retryable + +**Plugin Execution:** +When a fallback is triggered, the fallback request is treated as completely new: +- Semantic cache checks run again (different provider might have cached responses) +- Governance rules apply to the new provider +- Logging captures the fallback attempt +- All configured plugins execute fresh for the fallback provider + +**Plugin Fallback Control:** +Plugins can control whether fallbacks should be triggered based on their specific logic. For example: +- A custom plugin might prevent fallbacks for certain types of errors +- Security plugins might disable fallbacks for compliance reasons + +When a plugin determines that fallbacks should not be attempted, it can prevent the fallback mechanism entirely, ensuring the original error is returned immediately. + +This ensures consistent behavior regardless of which provider ultimately handles your request, while giving plugins full control over the fallback decision process. And you can always know which provider handled your request via `extra_fields`. diff --git a/docs/features/governance/budget-and-limits.mdx b/docs/features/governance/budget-and-limits.mdx new file mode 100644 index 000000000..3468543d5 --- /dev/null +++ b/docs/features/governance/budget-and-limits.mdx @@ -0,0 +1,605 @@ +--- +title: "Budget and Limits" +description: "Enterprise-grade budget management and cost control with hierarchical budget allocation through virtual keys, teams, and customers." +icon: "money-bills" +--- + +## Overview + +Budgeting and rate limiting are a core feature of Bifrost's governance system managed through [Virtual Keys](./virtual-keys). + +Bifrost's budget management system provides comprehensive cost control and financial governance for enterprise AI deployments. It operates through a **hierarchical budget structure** that enables granular cost management, usage tracking, and financial oversight across your entire organization. + +**Core Hierarchy:** +``` +Customer (has independent budget) + ↓ (one-to-many) +Team (has independent budget) + ↓ (one-to-many) +Virtual Key (has independent budget + rate limits) + ↓ (one-to-many) +Provider Config (has independent budget + rate limits) + +OR + +Customer (has independent budget) + ↓ (direct attachment) +Virtual Key (has independent budget + rate limits) + ↓ (one-to-many) +Provider Config (has independent budget + rate limits) + +OR + +Virtual Key (standalone - has independent budget + rate limits) + ↓ (one-to-many) +Provider Config (has independent budget + rate limits) +``` + +**Key Capabilities:** +- **Virtual Keys** - Primary access control via `x-bf-vk` header (exclusive team OR customer attachment) +- **Budget Management** - Independent budget limits at each hierarchy level with cumulative checking +- **Rate Limiting** - Request and token-based throttling at both VK and provider config levels +- **Provider-Level Governance** - Granular budgets and rate limits per AI provider within a virtual key +- **Model/Provider Filtering** - Granular access control per virtual key +- **Usage Tracking** - Real-time monitoring and audit trails +- **Audit Headers** - Optional team and customer identification + +--- + +## Budget Management + +### Cost Calculation + +Bifrost automatically calculates costs based on: +- **Provider Pricing** - Real-time model pricing data +- **Token Usage** - Input + output tokens from API responses +- **Request Type** - Different pricing for chat, text, embedding, speech, transcription +- **Cache Status** - Reduced costs for cached responses +- **Batch Operations** - Volume discounts for batch requests + +All cost calculation details are covered in [Architecture > Framework > Model Catalog](../../architecture/framework/model-catalog). + +### Budget Checking Flow + +When a request is made with a virtual key, Bifrost checks **all applicable budgets independently** in the hierarchy. Each budget must have sufficient remaining balance for the request to proceed. + +**Checking Sequence:** + +**For VK β†’ Team β†’ Customer:** +``` +1. βœ“ Provider Config Budget (if provider config has budget) +2. βœ“ VK Budget (if VK has budget) +3. βœ“ Team Budget (if VK's team has budget) +4. βœ“ Customer Budget (if team's customer has budget) +``` + +**For VK β†’ Customer (direct):** +``` +1. βœ“ Provider Config Budget (if provider config has budget) +2. βœ“ VK Budget (if VK has budget) +3. βœ“ Customer Budget (if VK's customer has budget) +``` + +**For Standalone VK:** +``` +1. βœ“ Provider Config Budget (if provider config has budget) +2. βœ“ VK Budget (if VK has budget) +``` + +**Important Notes:** +- **All applicable budgets must pass** - any single budget failure blocks the request +- **Budgets are independent** - each tracks its own usage and limits +- **Costs are deducted from all applicable budgets** - same cost applied to each level +- **Rate limits checked at provider config and VK levels** - teams and customers have no rate limits +- **Provider selection** - providers that exceed their budget or rate limits are excluded from [routing](./routing) + +**Example:** +``` +- Provider config budget: $4/$5 remaining βœ“ +- VK budget: $9/$10 remaining βœ“ +- Team budget: $15/$20 remaining βœ“ +- Customer budget: $45/$50 remaining βœ“ +- Result: Allowed (no budget is exceeded) + +- After request: + - Request cost: $2 + - Updated Provider=$6/$5, VK=$11/$10, Team=$17/$20, Customer=$47/$50 + - Then the next request will be blocked (both provider and VK budgets exceeded). +``` + +## Rate Limiting + +Rate limits protect your system from abuse and manage traffic by setting thresholds on request frequency and token usage over a specific time window. Rate limits can be configured at **both the Virtual Key level and Provider Config level** for granular control. + +Bifrost supports two types of rate limits that work in parallel: +- **Request Limits**: Control the maximum number of API calls that can be made within a set duration (e.g., 100 requests per minute). +- **Token Limits**: Control the maximum number of tokens (prompt + completion) that can be processed within a set duration (e.g., 50,000 tokens per hour). + +### Rate Limit Hierarchy + +Rate limits are checked in hierarchical order: +``` +1. βœ“ Provider Config Rate Limits (if provider config has rate limits) +2. βœ“ Virtual Key Rate Limits (if VK has rate limits) +``` + +For a request to be allowed, it must pass both the request limit and token limit checks at **all applicable levels**. If a provider config exceeds its rate limits, that provider is excluded from routing, but other providers within the same virtual key remain available. + +### Provider-Level Rate Limiting + +Provider configs within a virtual key can have independent rate limits, enabling: +- **Per-Provider Throttling**: Different rate limits for OpenAI vs Anthropic +- **Provider Isolation**: Rate limit violations on one provider don't affect others +- **Granular Control**: Fine-tune limits based on provider capabilities and costs + +## Reset Durations + +Budgets and rate limits support flexible reset durations: + +**Format Examples:** +- `1m` - 1 minute +- `5m` - 5 minutes +- `1h` - 1 hour +- `1d` - 1 day +- `1w` - 1 week +- `1M` - 1 month + +**Common Patterns:** +- **Rate Limits**: `1m`, `1h`, `1d` for request throttling +- **Budgets**: `1d`, `1w`, `1M` for cost control + +--- + +## Configuration Guide + +Configure provider-level budgets and rate limits using any of these methods: + + + + +The Bifrost Web UI provides an intuitive interface for configuring provider-level governance through the Virtual Keys management page. + +### Creating Virtual Keys with Provider Configs + +1. **Navigate to Virtual Keys**: Go to **Virtual Keys** page in the Bifrost dashboard +2. **Create New Virtual Key**: Click "Create Virtual Key" button +3. **Configure Providers**: In the "Provider Configurations" section: + - Add multiple providers with individual weights + - Set provider-specific budgets and rate limits + - Configure allowed models per provider + +### Provider Configuration Interface + +![Virtual Key Provider Configuration Interface](../../media/ui-virtual-key-provider-config.png) + +**Key Features:** +- **Visual Provider Cards**: Each provider displays as an expandable card +- **Budget Controls**: Set spending limits with reset periods per provider +- **Rate Limit Controls**: Configure token and request limits independently +- **Model Filtering**: Specify allowed models for each provider +- **Weight Distribution**: Visual indicators for load balancing weights +- **Real-time Validation**: Immediate feedback on configuration errors + +### Monitoring Provider Usage + +![Provider Usage Sheet](../../media/ui-virtual-key-provider-usage-sheet.png) + +The info sheet for the virtual key provides real-time monitoring of: +- Budget consumption per provider +- Rate limit utilization (tokens and requests) +- Provider availability status +- Usage trends and forecasting + + + + +Use the Bifrost HTTP API to programmatically manage provider-level governance configurations. + +### Create Virtual Key with Provider Configs + +```bash +curl -X POST "https://your-bifrost-instance.com/api/governance/virtual-keys" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "marketing-team-vk", + "description": "Marketing team virtual key with provider-specific limits", + "provider_configs": [ + { + "provider": "openai", + "weight": 0.7, + "allowed_models": ["gpt-4", "gpt-3.5-turbo"], + "budget": { + "max_limit": 500.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 1000000, + "token_reset_duration": "1h", + "request_max_limit": 1000, + "request_reset_duration": "1h" + } + }, + { + "provider": "anthropic", + "weight": 0.3, + "allowed_models": ["claude-3-opus", "claude-3-sonnet"], + "budget": { + "max_limit": 200.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 500000, + "token_reset_duration": "1h", + "request_max_limit": 500, + "request_reset_duration": "1h" + } + } + ], + "budget": { + "max_limit": 1000.00, + "reset_duration": "1M" + }, + "is_active": true + }' +``` + +### Update Provider Configuration + +```bash +curl -X PUT "https://your-bifrost-instance.com/api/governance/virtual-keys/{vk_id}" \ + -H "Content-Type: application/json" \ + -d '{ + "provider_configs": [ + { + "id": 1, + "provider": "openai", + "weight": 0.8, + "budget": { + "max_limit": 600.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 1200000, + "token_reset_duration": "1h" + } + } + ] + }' +``` + +### API Response Structure + +```json +{ + "message": "Virtual key created successfully", + "virtual_key": { + "id": "vk_123", + "name": "marketing-team-vk", + "value": "vk_abc123def456", + "provider_configs": [ + { + "id": 1, + "provider": "openai", + "weight": 0.7, + "allowed_models": ["gpt-4", "gpt-3.5-turbo"], + "budget": { + "id": "budget_789", + "max_limit": 500.00, + "current_usage": 0.00, + "reset_duration": "1M", + "last_reset": "2024-01-01T00:00:00Z" + }, + "rate_limit": { + "id": "rate_limit_456", + "token_max_limit": 1000000, + "token_current_usage": 0, + "token_reset_duration": "1h", + "token_last_reset": "2024-01-01T00:00:00Z", + "request_max_limit": 1000, + "request_current_usage": 0, + "request_reset_duration": "1h", + "request_last_reset": "2024-01-01T00:00:00Z" + } + } + ] + } +} +``` + +### Field Descriptions + +| Field | Type | Description | +|-------|------|-------------| +| `provider` | string | AI provider name (e.g., "openai", "anthropic") | +| `weight` | float | Load balancing weight (0.0-1.0) | +| `allowed_models` | array | Specific models allowed for this provider | +| `budget.max_limit` | float | Maximum spend in USD | +| `budget.reset_duration` | string | Reset period (e.g., "1h", "1d", "1M") | +| `rate_limit.token_max_limit` | integer | Maximum tokens per period | +| `rate_limit.request_max_limit` | integer | Maximum requests per period | + + + + +Configure provider-level governance through Bifrost's configuration file for declarative management. + +### Basic Configuration Structure + +```json +{ + "governance": { + "virtual_keys": [ + { + "name": "development-team-vk", + "description": "Development team with multi-provider setup", + "provider_configs": [ + { + "provider": "openai", + "weight": 0.6, + "allowed_models": ["gpt-4", "gpt-3.5-turbo"], + "budget": { + "max_limit": 1000.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 2000000, + "token_reset_duration": "1h", + "request_max_limit": 2000, + "request_reset_duration": "1h" + } + }, + { + "provider": "anthropic", + "weight": 0.4, + "allowed_models": ["claude-3-opus", "claude-3-sonnet"], + "budget": { + "max_limit": 500.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 1000000, + "token_reset_duration": "1h", + "request_max_limit": 1000, + "request_reset_duration": "1h" + } + } + ], + "budget": { + "max_limit": 2000.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 5000000, + "token_reset_duration": "1h", + "request_max_limit": 3000, + "request_reset_duration": "1h" + }, + "is_active": true + } + ] + } +} +``` + +### Advanced Configuration Examples + +#### Cost-Optimized Setup +```json +{ + "governance": { + "virtual_keys": [ + { + "name": "cost-optimized-vk", + "provider_configs": [ + { + "provider": "openai-gpt-3.5", + "weight": 0.8, + "budget": { + "max_limit": 50.00, + "reset_duration": "1d" + }, + "rate_limit": { + "request_max_limit": 1000, + "request_reset_duration": "1h" + } + }, + { + "provider": "openai-gpt-4", + "weight": 0.2, + "budget": { + "max_limit": 200.00, + "reset_duration": "1d" + }, + "rate_limit": { + "request_max_limit": 100, + "request_reset_duration": "1h" + } + } + ] + } + ] + } +} +``` + +#### High-Volume Production Setup +```json +{ + "governance": { + "virtual_keys": [ + { + "name": "production-high-volume-vk", + "provider_configs": [ + { + "provider": "openai", + "weight": 0.5, + "budget": { + "max_limit": 5000.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 10000000, + "token_reset_duration": "1h", + "request_max_limit": 10000, + "request_reset_duration": "1h" + } + }, + { + "provider": "anthropic", + "weight": 0.3, + "budget": { + "max_limit": 3000.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 6000000, + "token_reset_duration": "1h", + "request_max_limit": 6000, + "request_reset_duration": "1h" + } + }, + { + "provider": "azure-openai", + "weight": 0.2, + "budget": { + "max_limit": 2000.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 4000000, + "token_reset_duration": "1h", + "request_max_limit": 4000, + "request_reset_duration": "1h" + } + } + ] + } + ] + } +} +``` + +**Validation Rules:** +- Budget limits must be positive numbers +- Reset durations must be valid time formats +- Rate limits must be positive integers +- Provider names must match configured providers + + + + +## Provider-Level Governance Examples + +### Example 1: Mixed Provider Budgets + +A virtual key configured with multiple providers and different budget allocations: + +```json +{ + "name": "marketing-team-vk", + "budget": { "max_limit": 100, "reset_duration": "1M" }, + "provider_configs": [ + { + "provider": "openai", + "weight": 0.7, + "budget": { "max_limit": 50, "reset_duration": "1M" } + }, + { + "provider": "anthropic", + "weight": 0.3, + "budget": { "max_limit": 30, "reset_duration": "1M" } + } + ] +} +``` + +**Behavior:** +- OpenAI requests limited to 50 dollars/month at provider level + 100 dollars/month at VK level +- Anthropic requests limited to 30 dollars/month at provider level + 100 dollars/month at VK level +- If any provider's budget is exhausted, all requests to that provider will be blocked + +### Example 2: Provider-Specific Rate Limits + +Different rate limits based on provider capabilities: + +```json +{ + "name": "high-volume-vk", + "provider_configs": [ + { + "provider": "openai", + "rate_limit": { + "request_max_limit": 1000, + "request_reset_duration": "1h", + "token_max_limit": 1000000, + "token_reset_duration": "1h" + } + }, + { + "provider": "anthropic", + "rate_limit": { + "request_max_limit": 500, + "request_reset_duration": "1h", + "token_max_limit": 500000, + "token_reset_duration": "1h" + } + } + ] +} +``` + +**Behavior:** +- OpenAI: 1000 requests/hour, 1M tokens/hour +- Anthropic: 500 requests/hour, 500K tokens/hour +- If any provider's rate limits are exceeded, all requests to that provider will be blocked + +### Example 3: Failover Strategy + +Provider configurations with budget-based failover: + +```json +{ + "name": "cost-optimized-vk", + "provider_configs": [ + { + "provider": "openai-cheap", + "weight": 1.0, + "budget": { "max_limit": 10, "reset_duration": "1d" } + }, + { + "provider": "openai-premium", + "weight": 0.0, + "budget": { "max_limit": 50, "reset_duration": "1d" }, + "rate_limit": { + "request_max_limit": 100, + "request_reset_duration": "1h", + "token_max_limit": 50000, + "token_reset_duration": "1h" + } + } + ] +} +``` + +**Behavior:** +- Primary: Use cheap provider until $10 daily budget exhausted +- Fallback: Automatically switch to premium provider when cheap option unavailable. To enable this, you should not send `provider` name in the request body, read [Routing](./routing#automatic-fallbacks) for more details. +- Cost containment: Prevent unexpected overspend on premium resources and limit the number of requests to the premium provider + + +## Key Benefits of Provider-Level Governance + +- **Granular Control**: Set specific spending limits and rate limits per AI provider +- **Automatic Fallback**: Route to alternative providers when budgets or rate limits are exceeded +- **Cost Control**: Track and control spending by provider for better financial oversight +- **Performance Testing**: A/B testing across providers with controlled budgets +- **Multi-Provider Strategies**: Primary/backup provider configurations +- **Cost-Tiered Access**: Cheap providers for basic tasks, premium for complex workloads + +--- + +## Next Steps + +- **[Routing](./routing)** - Direct requests to specific AI models, providers, and keys using Virtual Keys. +- **[MCP Tool Filtering](./mcp-tools)** - Manage MCP clients/tools for virtual keys. +- **[Tracing](../observability/default)** - Audit trails and request tracking diff --git a/docs/features/governance/mcp-tools.mdx b/docs/features/governance/mcp-tools.mdx new file mode 100644 index 000000000..df17c0102 --- /dev/null +++ b/docs/features/governance/mcp-tools.mdx @@ -0,0 +1,160 @@ +--- +title: "MCP Tool Filtering" +description: "Control which MCP tools are available for each Virtual Key." +icon: "grid-2" +--- + +## Overview + +MCP Tool Filtering allows you to control which tools are available to AI models on a per-request basis using Virtual Keys (VKs). By configuring a VirtualKey, you can create a strict allow-list of MCP clients and tools, ensuring that only approved tools can be executed. + +Make sure you have at least one MCP client set up. Read more about it [here](../mcp). + +## How It Works + +The filtering logic is determined by the Virtual Key's configuration: + +1. **No MCP Configuration on Virtual Key (Default)** + - If a Virtual Key has no specific MCP configurations, all tools from all enabled MCP clients are available by default. + - In this state, a user can still manually filter tools for a single request by passing the `x-bf-mcp-include-tools` header. + +2. **With MCP Configuration on Virtual Key** + - When you configure MCP clients on a Virtual Key, its settings take full precedence. + - Bifrost automatically generates an `x-bf-mcp-include-tools` header based on your VK configuration. This acts as a strict allow-list for the request. + - This generated header **overrides** any `x-bf-mcp-include-tools` header that might have been sent manually with the request. + +For each MCP client associated with a Virtual Key, you can specify the allowed tools: +- **Select specific tools**: Only the chosen tools from that client will be available. +- **Use `*` wildcard**: All available tools from that client will be permitted. +- **Leave tool list empty**: All tools from that client will be **blocked**. +- **Do not configure a client**: All tools from that client will be **blocked** (if other clients are configured). + +## Setting MCP Tool Restrictions + + + + +You can configure which tools a Virtual Key has access to via the UI. + +1. Go to **Virtual Keys** page. +2. Create/Edit virtual key +![Virtual Key MCP Tool Restrictions](../../media/ui-virtual-key-mcp-filter.png) +3. In **MCP Client Configurations** section, add the MCP client you want to restrict the VK to +4. Add the tools you want to restrict the VK to, or leave it blank to allow all tools for this client +5. Click on the **Save** button + + + + +You can configure this via the REST API when creating (`POST`) or updating (`PUT`) a virtual key. + +**Create Virtual Key:** +```bash +curl -X POST http://localhost:8080/api/governance/virtual-keys \ + -H "Content-Type: application/json" \ + -d '{ + "name": "vk-for-billing-support", + "mcp_configs": [ + { + "mcp_client_name": "billing-client", + "tools_to_execute": ["check-status"] + }, + { + "mcp_client_name": "support-client", + "tools_to_execute": ["*"] + } + ] + }' +``` + +**Update Virtual Key:** +```bash +curl -X PUT http://localhost:8080/api/governance/virtual-keys/{vk_id} \ + -H "Content-Type: application/json" \ + -d '{ + "mcp_configs": [ + { + "mcp_client_name": "billing-client", + "tools_to_execute": ["check-status"] + }, + { + "mcp_client_name": "support-client", + "tools_to_execute": ["*"] + } + ] + }' +``` + +**Behavior:** +- The virtual key can only access the `check-status` tool from `billing-client`. +- It can access all tools from `support-client`. +- Any other MCP client is implicitly blocked for this key. + + + + + +You can also define MCP tool restrictions directly in your `config.json` file. The `mcp_configs` array under a virtual key should reference the MCP client by name. + +```json +{ + "governance": { + "virtual_keys": [ + { + "id": "vk-billing-support-only", + "name": "VK for Billing and Support", + "mcp_configs": [ + { + "mcp_client_name": "billing-client", + "tools_to_execute": ["check-status"] + }, + { + "mcp_client_name": "support-client", + "tools_to_execute": ["*"] + } + ] + } + ] + } +} +``` + + + +## Example Scenario + +**Available MCP Clients & Tools:** +- **`billing-client`**: with tools `[create-invoice, check-status]` +- **`support-client`**: with tools `[create-ticket, get-faq]` + + + +**Configuration:** +- `billing-client` -> Allowed Tools: `[*]` (wildcard) +- `support-client` -> Allowed Tools: `[*]` (wildcard) + +**Result:** +A request with this Virtual Key can access all four tools: `create-invoice`, `check-status`, `create-ticket`, and `get-faq`. + + + +**Configuration:** +- `billing-client` -> Allowed Tools: `[check-status]` +- `support-client` -> Not configured + +**Result:** +A request with this Virtual Key can only access the `check-status` tool. All other tools are blocked. + + + +**Configuration:** +- `billing-client` -> Allowed Tools: `[]` (empty list) + +**Result:** +A request with this Virtual Key cannot access any tools. All tools from all clients are blocked. + + + + +When a Virtual Key has MCP configurations, it dynamically generates the `x-bf-mcp-include-tools` header. This ensures that the VK's rules are always enforced and will override any manual header sent by the user. Though you can still use `x-bf-mcp-include-clients` header to filter the MCP clients per request. + \ No newline at end of file diff --git a/docs/features/governance/routing.mdx b/docs/features/governance/routing.mdx new file mode 100644 index 000000000..1bf7a6fcf --- /dev/null +++ b/docs/features/governance/routing.mdx @@ -0,0 +1,268 @@ +--- +title: "Routing" +description: "Direct requests to specific AI models, providers, and keys using Virtual Keys." +icon: "arrow-progress" +--- + +## Overview + +Bifrost's routing capabilities offer granular control over how requests are directed to different AI models and providers. By configuring routing rules on a Virtual Key, you can enforce which providers and models are accessible, implement sophisticated load balancing strategies, create automatic fallbacks, and restrict access to specific provider API keys. + +This powerful feature enables key use cases like: + +- **Resilience & Failover**: Automatically fall back to a secondary provider if the primary one fails. +- **Environment Separation**: Dedicate specific virtual keys to development, testing, and production environments with different provider and key access. +- **Cost Management**: Route traffic to cheaper models or providers based on weights to optimize costs. +- **Fine-grained Access Control**: Ensure that different teams or applications only use the models and API keys they are explicitly permitted to. + +## Provider/Model Restrictions + +Virtual Keys can be restricted to use only specific provider/models. When provider/model restrictions are configured, the VK can only access those designated provider/models, providing fine-grained control over which provider/models different users or applications can utilize. + +**How It Works:** +- **No Restrictions** (default): VK can use any available provider/models based on global configuration +- **With Restrictions**: VK limited to only the specified provider/models with weighted load balancing + +## Weighted Load Balancing + +When you configure multiple providers on a Virtual Key, Bifrost automatically implements weighted load balancing. Each provider is assigned a weight, and requests are distributed proportionally. + +**Example Configuration:** +``` +Virtual Key: vk-prod-main +β”œβ”€β”€ OpenAI +β”‚ β”œβ”€β”€ Allowed Models: [gpt-4o, gpt-4o-mini] +β”‚ └── Weight: 0.2 (20% of traffic) +└── Azure + β”œβ”€β”€ Allowed Models: [gpt-4o] + └── Weight: 0.8 (80% of traffic) +``` + +**Load Balancing Behavior:** +- For `gpt-4o`: 80% Azure, 20% OpenAI (both providers support it) +- For `gpt-4o-mini`: 100% OpenAI (only provider that supports it) + +**Usage:** +To trigger weighted load balancing, send requests with just the model name: +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-prod-main" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}' +``` + +To bypass load balancing and target a specific provider: +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-prod-main" \ + -d '{"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}' +``` + + +Weights are automatically normalized to a sum 1.0 based on the weights of all providers available on the VK for the given model. + + +## Automatic Fallbacks + +When multiple providers are configured on a Virtual Key, Bifrost automatically creates fallback chains for resilience. This feature provides automatic failover without manual intervention. + +**How It Works:** +- **Only activated when**: Your request has no existing `fallbacks` array in the request body +- **Fallback creation**: Providers are sorted by weight (highest first) and added as fallbacks +- **Respects existing fallbacks**: If you manually specify fallbacks, they are preserved + +**Example Request Flow:** +1. Primary request goes to weighted-selected provider (e.g., Azure with 80% weight) +2. If Azure fails, automatically retry with OpenAI +3. Continue until success or all providers exhausted + +**Request with automatic fallbacks:** +```bash +# This request will get automatic fallbacks +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-prod-main" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}' +``` + +**Request with manual fallbacks (no automatic fallbacks added):** +```bash +# This request keeps your specified fallbacks +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-prod-main" \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello!"}], + "fallbacks": ["anthropic/claude-3-sonnet-20240229"] + }' +``` + +## Setting Provider/Model Routing + + + + +1. Go to **Virtual Keys** +2. Create/Edit virtual key + +![Virtual Key Provider/Model Restrictions](../../media/ui-virtual-key-routing.png) + +3. In **Provider Configurations** section, add the provider you want to restrict the VK to +4. Add the models you want to restrict the VK to, or leave it blank to allow all models for this provider +5. Add the weight you want to give to this provider +6. Click on the **Save** button + + + + +```bash +curl -X PUT http://localhost:8080/api/governance/virtual-keys/{vk_id} \ + -H "Content-Type: application/json" \ + -d '{ + "provider_configs": [ + { + "provider": "openai", + "allowed_models": ["gpt-4o", "gpt-4o-mini"], + "weight": 0.2 + }, + { + "provider": "azure", + "allowed_models": ["gpt-4o"], + "weight": 0.8 + } + ] + }' +``` + + + + + +```json +{ + "governance": { + "virtual_keys": [ + { + "id": "vk-prod-main", + "provider_configs": [ + { + "provider": "openai", + "allowed_models": ["gpt-4o", "gpt-4o-mini"], + "weight": 0.2 + }, + { + "provider": "azure", + "allowed_models": ["gpt-4o"], + "weight": 0.8 + } + ] + } + ] + } +} +``` + + + + + +## API Key Restrictions + +Virtual Keys can be restricted to use only specific provider API keys. When key restrictions are configured, the VK can only access those designated keys, providing fine-grained control over which API keys different users or applications can utilize. + +**How It Works:** +- **No Restrictions** (default): VK can use any available provider keys based on load balancing +- **With Restrictions**: VK limited to only the specified key IDs, regardless of other available keys + +**Example Scenario:** +``` +Available Provider Keys: +β”œβ”€β”€ key-prod-001 β†’ sk-prod-key... (Production OpenAI key) +β”œβ”€β”€ key-dev-002 β†’ sk-dev-key... (Development OpenAI key) +└── key-test-003 β†’ sk-test-key... (Testing OpenAI key) + +Virtual Key Restrictions: +β”œβ”€β”€ vk-prod-main +β”‚ β”œβ”€β”€ Allowed Models: [gpt-4o] +β”‚ └── Restricted Keys: [key-prod-001] ← ONLY production key +β”œβ”€β”€ vk-dev-main +β”‚ β”œβ”€β”€ Allowed Models: [gpt-4o-mini] +β”‚ └── Restricted Keys: [key-dev-002, key-test-003] ← Dev + test keys +└── vk-unrestricted + β”œβ”€β”€ Allowed Models: [all models] + └── Restricted Keys: [] ← Can use ANY available key +``` + +**Request Behavior:** +```bash +# Production VK - will ONLY use key-prod-001 +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-prod-main" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}' + +# Development VK - will load balance between key-dev-002 and key-test-003 +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-dev-main" \ + -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hello!"}]}' + +# VK with no key restrictions - can use any available OpenAI key +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-vk: vk-unrestricted" \ + -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hello!"}]}' +``` + +**Setting API Key Restrictions:** + + + + +1. Go to **Virtual Keys** +2. Create/Edit virtual key + +![Virtual Key API Key Restrictions](../../media/ui-virtual-key-keys-filter.png) + +3. In **Allowed Keys** section, select the API key you want to restrict the VK to +4. Click on the **Save** button + + + + + +```bash +curl -X PUT http://localhost:8080/api/governance/virtual-keys/{vk_id} \ + -H "Content-Type: application/json" \ + -d '{ + "key_ids": ["key-prod-001"] + }' +``` + + + + + +```json +{ + "governance": { + "virtual_keys": [ + { + "id": "vk-prod-main", + "keys": [ + { + "key_id": "key-prod-001" + } + ] + } + ] + } +} +``` + + + + + +**Use Cases:** +- **Environment Separation** - Production VKs use production keys, dev VKs use dev keys +- **Cost Control** - Different teams use keys with different billing accounts +- **Access Control** - Restrict sensitive keys to specific VKs only +- **Compliance** - Ensure certain workloads only use compliant/audited keys + +The models restrictions applied on the keys of individual providers will always be applied and will work together with the provider/model or api key restrictions set on the virtual key. \ No newline at end of file diff --git a/docs/features/governance/virtual-keys.mdx b/docs/features/governance/virtual-keys.mdx new file mode 100644 index 000000000..bee781f83 --- /dev/null +++ b/docs/features/governance/virtual-keys.mdx @@ -0,0 +1,643 @@ +--- +title: "Virtual Keys" +description: "Virtual keys are a way to manage access to your AI models." +icon: "key" +--- + +## Overview + +Virtual Keys are the primary governance entity in Bifrost. Users and applications authenticate using the given headers to access virtual keys and get specific access permissions, budgets, and rate limits. + +**Allowed Headers:** +- `x-bf-vk` - Virtual key header, eg. `sk-bf-*` +- `Authorization` - Authorization header, eg. `Bearer sk-bf-*` (OpenAI style) +- `x-api-key` - API key header, eg. `sk-bf-*` (Anthropic style) + +You can also use `Authorization` and `x-api-key` headers to pass direct keys to the provider. Read more about it in [Direct Key Bypass](../keys-management#direct-key-bypass). + +**Key Features:** +- **Access Control** - Model and provider filtering +- **Cost Management** - Independent budgets (checked along with team/customer budgets if attached) +- **Rate Limiting** - Token and request-based throttling (VK-level only) +- **Key Restrictions** - Limit VK to specific provider API keys (if configured, VK can only use those keys) +- **Exclusive Attachment** - Belongs to either one team OR one customer OR neither (mutually exclusive) +- **Active/Inactive Status** - Enable/disable access instantly + +## Configuration + + + + +1. Go to **Virtual Keys** +2. Click on **Add Virtual Key** button + +![Virtual Key Creation](../../media/ui-virtual-key.png) + +**Budget Settings:** +- **Max Limit**: Dollar amount (e.g., `10.50`) +- **Reset Duration**: `1m`, `1h`, `1d`, `1w`, `1M` + +**Rate Limits:** +- **Token Limit**: Max tokens per period +- **Request Limit**: Max requests per period +- **Reset Duration**: Reset frequency for each limit + +**Associations:** +- **Team**: Assign to existing team (mutually exclusive with customer) +- **Customer**: Assign to existing customer (mutually exclusive with team) + +3. Click **Create Virtual Key** + + + + +**Create Virtual Key (attached to team):** +```bash +curl -X POST http://localhost:8080/api/governance/virtual-keys \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Engineering Team API", + "description": "Main API key for engineering team", + "provider_configs": [ + { + "provider": "openai", + "weight": 0.5, + "allowed_models": ["gpt-4o-mini"] + }, + { + "provider": "anthropic", + "weight": 0.5, + "allowed_models": ["claude-3-sonnet-20240229"] + } + ], + "team_id": "team-eng-001", + "budget": { + "max_limit": 100.00, + "reset_duration": "1M" + }, + "rate_limit": { + "token_max_limit": 10000, + "token_reset_duration": "1h", + "request_max_limit": 100, + "request_reset_duration": "1m" + }, + "key_ids": ["8c52039e-38c6-48b2-8016-0bd884b7befb"], + "is_active": true + }' +``` + +**Create Virtual Key (directly attached to customer):** +```bash +curl -X POST http://localhost:8080/api/governance/virtual-keys \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Executive API Key", + "description": "Direct customer-level API access", + "provider_configs": [ + { + "provider": "openai", + "weight": 0.5, + "allowed_models": ["gpt-4o"] + }, + { + "provider": "anthropic", + "weight": 0.5, + "allowed_models": ["claude-3-opus-20240229"] + } + ], + "customer_id": "customer-acme-corp", + "budget": { + "max_limit": 500.00, + "reset_duration": "1M" + }, + "is_active": true + }' +``` + +> **Note**: +> - `team_id` and `customer_id` are mutually exclusive - a VK can only belong to one team OR one customer, not both. +> - `key_ids` restricts the VK to only use those specific provider API keys. Omit this field to allow access to all available keys. + +**Update Virtual Key:** +```bash +curl -X PUT http://localhost:8080/api/governance/virtual-keys/{vk_id} \ + -H "Content-Type: application/json" \ + -d '{ + "description": "Updated description", + "budget": { + "max_limit": 150.00, + "reset_duration": "1M" + } + }' +``` + +**Get Virtual Keys:** +```bash +# List all virtual keys +curl http://localhost:8080/api/governance/virtual-keys + +# Get specific virtual key +curl http://localhost:8080/api/governance/virtual-keys/{vk_id} +``` + +**Delete Virtual Key:** +```bash +curl -X DELETE http://localhost:8080/api/governance/virtual-keys/{vk_id} +``` + + + + +```json +{ + "client": { + "enable_governance": true, + "enforce_governance_header": true + }, + "governance": { + "virtual_keys": [ + { + "id": "vk-001", + "name": "Engineering Team API", + "value": "vk-engineering-main", + "description": "Main API key for engineering team", + "is_active": true, + "provider_configs": [ + { + "provider": "openai", + "weight": 0.5, + "allowed_models": ["gpt-4o-mini"] + }, + { + "provider": "anthropic", + "weight": 0.5, + "allowed_models": ["claude-3-sonnet-20240229"] + } + ], + "team_id": "team-eng-001", + "budget_id": "budget-eng-vk", + "rate_limit_id": "rate-limit-eng-vk", + "keys": [ + {"key_id": "8c52039e-38c6-48b2-8016-0bd884b7befb"} + ] + }, + { + "id": "vk-002", + "name": "Executive API Key", + "value": "vk-executive-direct", + "description": "Direct customer-level API access", + "is_active": true, + "provider_configs": [ + { + "provider": "openai", + "weight": 0.5, + "allowed_models": ["gpt-4o"] + }, + { + "provider": "anthropic", + "weight": 0.5, + "allowed_models": ["claude-3-opus-20240229"] + } + ], + "customer_id": "customer-acme-corp", + "budget_id": "budget-exec-vk", + "keys": [ + {"key_id": "8c52039e-38c6-48b2-8016-0bd884b7befb"} + ] + } + ], + "budgets": [ + { + "id": "budget-eng-vk", + "max_limit": 100.00, + "reset_duration": "1M", + "current_usage": 0.0, + "last_reset": "2025-01-01T00:00:00Z" + }, + { + "id": "budget-exec-vk", + "max_limit": 500.00, + "reset_duration": "1M", + "current_usage": 0.0, + "last_reset": "2025-01-01T00:00:00Z" + } + ], + "rate_limits": [ + { + "id": "rate-limit-eng-vk", + "token_max_limit": 10000, + "token_reset_duration": "1h", + "token_current_usage": 0, + "token_last_reset": "2025-01-01T00:00:00Z", + "request_max_limit": 100, + "request_reset_duration": "1m", + "request_current_usage": 0, + "request_last_reset": "2025-01-01T00:00:00Z" + } + ] + } +} +``` + + + + +## User Groups + +### Teams + +Teams provide organizational grouping for virtual keys with department-level budget management. Teams can belong to one customer and have their own independent budget allocation. + +**Key Features:** +- **Organizational Structure** - Group multiple virtual keys +- **Independent Budgets** - Department-level cost control (separate from customer budgets) +- **Customer Association** - Can belong to one customer (optional) +- **No Rate Limits** - Teams cannot have rate limits (VK-level only) + +**Configuration** + + + + +1. Go to **Users & Groups** β†’ **Teams** + +2. Click on **Add Team** button + +![Team Creation](../../media/ui-create-teams.png) + +Fill the form and click on **Create Team** button + +3. **Assign Virtual Keys to Team** + - Go to **Virtual Keys** page + - Edit the virtual key and assign it to the team + - Click on **Save** button + + + + +**Create Team:** +```bash +curl -X POST http://localhost:8080/api/governance/teams \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Engineering Team", + "customer_id": "customer-acme-corp", + "budget": { + "max_limit": 500.00, + "reset_duration": "1M" + } + }' +``` + +**Update Team:** +```bash +curl -X PUT http://localhost:8080/api/governance/teams/{team_id} \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Updated Engineering Team", + "budget": { + "max_limit": 750.00, + "reset_duration": "1M" + } + }' +``` + +**Get Teams:** +```bash +# List all teams +curl http://localhost:8080/api/governance/teams + +# Get specific team +curl http://localhost:8080/api/governance/teams/{team_id} +``` + +**Delete Team:** +```bash +curl -X DELETE http://localhost:8080/api/governance/teams/{team_id} +``` + + + + +```json +{ + "governance": { + "teams": [ + { + "id": "team-eng-001", + "name": "Engineering Team", + "customer_id": "customer-acme-corp", + "budget_id": "budget-team-eng" + }, + { + "id": "team-sales-001", + "name": "Sales Team", + "customer_id": "customer-acme-corp", + "budget_id": "budget-team-sales" + } + ], + "budgets": [ + { + "id": "budget-team-eng", + "max_limit": 500.00, + "reset_duration": "1M", + "current_usage": 0.0, + "last_reset": "2025-01-01T00:00:00Z" + }, + { + "id": "budget-team-sales", + "max_limit": 250.00, + "reset_duration": "1M", + "current_usage": 0.0, + "last_reset": "2025-01-01T00:00:00Z" + } + ] + } +} +``` + + + + +### Customers + +Customers represent the highest level in the governance hierarchy, typically corresponding to organizations or major business units. They provide top-level budget control and organizational structure. + +**Key Features:** +- **Top-Level Organization** - Highest hierarchy level +- **Independent Budgets** - Organization-wide cost control (separate from team/VK budgets) +- **Team Management** - Contains multiple teams and direct VKs +- **No Rate Limits** - Customers cannot have rate limits (VK-level only) + +**Configuration** + + + + +1. Go to **Users & Groups** β†’ **Customers** + +2. Click on **Add Customer** button + +![Customer Creation](../../media/ui-create-customer.png) + +Fill the form and click on **Create Customer** button + +3. **Assign Teams to Customer** + - Go to **Teams** page + - Edit the team and assign it to the customer + - Click on **Save** button + +4. **Assign Virtual Keys to Customer** + - Go to **Virtual Keys** page + - Edit the virtual key and assign it to the customer + - Click on **Save** button + + + + +**Create Customer:** +```bash +curl -X POST http://localhost:8080/api/governance/customers \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Acme Corporation", + "budget": { + "max_limit": 2000.00, + "reset_duration": "1M" + } + }' +``` + +**Update Customer:** +```bash +curl -X PUT http://localhost:8080/api/governance/customers/{customer_id} \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Acme Corp (Updated)", + "budget": { + "max_limit": 2500.00, + "reset_duration": "1M" + } + }' +``` + +**Get Customers:** +```bash +# List all customers +curl http://localhost:8080/api/governance/customers + +# Get specific customer +curl http://localhost:8080/api/governance/customers/{customer_id} +``` + +**Delete Customer:** +```bash +curl -X DELETE http://localhost:8080/api/governance/customers/{customer_id} +``` + + + + +```json +{ + "governance": { + "customers": [ + { + "id": "customer-acme-corp", + "name": "Acme Corporation", + "budget_id": "budget-customer-acme" + }, + { + "id": "customer-beta-inc", + "name": "Beta Inc", + "budget_id": "budget-customer-beta" + } + ], + "budgets": [ + { + "id": "budget-customer-acme", + "max_limit": 2000.00, + "reset_duration": "1M", + "current_usage": 0.0, + "last_reset": "2025-01-01T00:00:00Z" + }, + { + "id": "budget-customer-beta", + "max_limit": 1500.00, + "reset_duration": "1M", + "current_usage": 0.0, + "last_reset": "2025-01-01T00:00:00Z" + } + ] + } +} +``` + + + + +## Features + +- **[Budget and Limits](./budget-and-limits)** - Enterprise-grade budget management and cost control and rate limiting using virtual keys +- **[Routing](./routing)** - Route requests to the appropriate providers/models and restrict api keys using virtual keys +- **[MCP Tool Filtering](./mcp-tools)** - Manage MCP clients/tools for virtual keys + + +## Usage + +### Required Header + +All governance-enabled requests must include the virtual key header: + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-bf-vk: vk-engineering-main" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +By default governance is optional, meaning that if the `x-bf-vk` header is not present, the request will be allowed but without any governance checks/routing. But you can make it mandatory by enforcing the governance header. + + + + +1. Go to **Client** β†’ **Governance** + +2. Check the **Enforce Governance Header** checkbox + + + +```bash +curl -X PUT http://localhost:8080/api/config \ + -H "Content-Type: application/json" \ + -d '{ + "client_config": { + "enforce_governance_header": true + } + }' +``` + + + + +```json +{ + "client": { + "enable_governance": true, + "enforce_governance_header": true + } +} +``` + + + + +When the governance header is enforced, the request will be rejected if the `x-bf-vk` header is not present. + +### Optional Audit Headers + +Include additional headers for enhanced tracking and audit trails: + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-bf-vk: vk-engineering-main" \ + -H "x-bf-team: team-eng-001" \ + -H "x-bf-customer: customer-acme-corp" \ + -H "x-bf-user-id: user-alice" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +**Header Definitions:** +- `x-bf-vk` - **Required** virtual key for access control +- `x-bf-team` - Optional team identifier for audit trails +- `x-bf-customer` - Optional customer identifier for audit trails +- `x-bf-user-id` - Optional user identifier for detailed tracking + +### Error Responses + +- Virtual Key Not Found (400) +```json +{ + "error": { + "type": "virtual_key_required", + "message": "x-bf-vk header is missing" + } +} +``` + +- Virtual Key Blocked (403) +```json +{ + "error": { + "type": "virtual_key_blocked", + "message": "Virtual key is inactive" + } +} +``` + +- Rate Limit Exceeded (429) +```json +{ + "error": { + "type": "rate_limited", + "message": "Rate limits exceeded: [token limit exceeded (1500/1000, resets every 1h)]" + } +} +``` + +- Token Limit Exceeded (429) +```json +{ + "error": { + "type": "token_limited", + "message": "Rate limits exceeded: [token limit exceeded (1500/1000, resets every 1h)]" + } +} +``` + +- Request Limit Exceeded (429) +```json +{ + "error": { + "type": "request_limited", + "message": "Rate limits exceeded: [request limit exceeded (101/100, resets every 1m)]" + } +} +``` + +- Budget Exceeded (402) +```json +{ + "error": { + "type": "budget_exceeded", + "message": "Budget check failed: VK budget exceeded: 105.50 > 100.00 dollars" + } +} +``` + +- Model Not Allowed (403) +```json +{ + "error": { + "type": "model_blocked", + "message": "Model 'gpt-4o' is not allowed for this virtual key" + } +} +``` + +- Provider Not Allowed (403) +```json +{ + "error": { + "type": "provider_blocked", + "message": "Provider 'anthropic' is not allowed for this virtual key" + } +} +``` \ No newline at end of file diff --git a/docs/features/keys-management.mdx b/docs/features/keys-management.mdx new file mode 100644 index 000000000..718fbd4b9 --- /dev/null +++ b/docs/features/keys-management.mdx @@ -0,0 +1,253 @@ +--- +title: "Load Balance" +description: "Intelligent API key management with weighted load balancing, model-specific filtering, and automatic failover. Distribute traffic across multiple keys for optimal performance and reliability." +icon: "scale-balanced" +--- + +## Smart Key Distribution + +Bifrost's key management system goes beyond simple API key storage. It provides intelligent load balancing, model-specific key filtering, and weighted distribution to optimize performance and manage costs across multiple API keys. + +When you configure multiple keys for a provider, Bifrost automatically distributes requests using sophisticated selection algorithms that consider key weights, model compatibility, and deployment mappings. + +## How Key Selection Works + +Bifrost follows a precise selection process for every request: + +1. **Context Override Check**: First checks if a key is explicitly provided in context (bypassing management) +2. **Provider Key Lookup**: Retrieves all configured keys for the requested provider +3. **Model Filtering**: Filters keys that support the requested model +4. **Deployment Validation**: For Azure/Bedrock, validates deployment mappings +5. **Weighted Selection**: Uses weighted random selection among eligible keys + +This ensures optimal key usage while respecting your configuration constraints. + +## Implementation Examples + + + + + +```bash +# Configure multiple keys with weights via API +curl -X POST http://localhost:8080/api/providers \ + -H "Content-Type: application/json" \ + -d '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY_1", + "models": ["gpt-4o", "gpt-4o-mini"], + "weight": 0.7 + }, + { + "value": "env.OPENAI_API_KEY_2", + "models": [], + "weight": 0.3 + } + ] + }' + +# Regular request (uses weighted key selection) +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# Request with direct API key (bypasses key management) +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-direct-api-key" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + + + + + +```go +package main + +import ( + "context" + "github.com/maximhq/bifrost/core/schemas" +) + +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.OpenAI: + return []schemas.Key{ + { + ID: "primary-key", + Value: "env.OPENAI_API_KEY_1", + Models: ["gpt-4o", "gpt-4o-mini"], // Model whitelist + Weight: 0.7, // 70% of traffic + }, + { + ID: "secondary-key", + Value: "env.OPENAI_API_KEY_2", + Models: [], // Empty = supports all models + Weight: 0.3, // 30% of traffic + }, + }, nil + case schemas.Anthropic: + return []schemas.Key{ + { + Value: "env.ANTHROPIC_API_KEY", + Models: ["claude-3-5-sonnet-20241022"], + Weight: 1.0, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} + +// Using with explicit context key (bypasses key management) +func makeRequestWithDirectKey() { + ctx := context.Background() + + // Direct key bypasses all key management + directKey := schemas.Key{ + Value: "sk-direct-api-key", + Weight: 1.0, + } + ctx = context.WithValue(ctx, schemas.BifrostContextKeyDirectKey, directKey) + + response, err := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: messages, + }) +} +``` + + + + + +## Weighted Load Balancing + +Bifrost uses weighted random selection to distribute requests across multiple keys. This allows you to: + +**Control Traffic Distribution:** +- Assign higher weights to premium keys with better rate limits +- Balance between production and backup keys +- Gradually migrate traffic during key rotation + +**Weight Calculation Example:** +``` +Key 1: Weight 0.7 (70% probability) +Key 2: Weight 0.3 (30% probability) +Total Weight: 1.0 + +Random selection ensures statistical distribution over time +``` + +**Algorithm Details:** +1. Calculate total weight of all eligible keys +2. Generate random number between 0 and total weight +3. Select key based on cumulative weight ranges +4. If selected key fails, automatic fallback to next available key + +## Model Whitelisting and Filtering + +Keys can be restricted to specific models for access control and cost management: + +**Model Filtering Logic:** +- **Empty `models` array**: Key supports ALL models for that provider +- **Populated `models` array**: Key only supports listed models +- **Model mismatch**: Key is excluded from selection for that request + +**Use Cases:** +- **Premium Models**: Dedicated keys for expensive models (GPT-4, Claude-3) +- **Team Separation**: Different keys for different teams or projects +- **Cost Control**: Restrict access to specific model tiers +- **Compliance**: Separate keys for different security requirements + +**Example Model Restrictions:** +```json +{ + "keys": [ + { + "value": "premium-key", + "models": ["gpt-4o", "o1-preview"], // Only premium models + "weight": 1.0 + }, + { + "value": "standard-key", + "models": ["gpt-4o-mini", "gpt-3.5-turbo"], // Only standard models + "weight": 1.0 + } + ] +} +``` + +## Deployment Mapping (Azure & Bedrock) + +For cloud providers with deployment-based routing, Bifrost validates deployment availability: + +**Azure OpenAI:** +- Keys must have deployment mappings for specific models +- Deployment name maps to actual Azure deployment identifier +- Missing deployment excludes key from selection + +**AWS Bedrock:** +- Supports model profiles and direct model access +- Deployment mappings enable inference profile routing +- ARN configuration determines URL formation + +**Deployment Validation Process:** +1. Check if provider uses deployments (Azure/Bedrock) +2. Verify deployment exists for requested model +3. Exclude keys without proper deployment mapping +4. Continue with standard weighted selection + +## Direct Key Bypass + +For scenarios requiring explicit key control, Bifrost supports bypassing the entire key management system: + +**Go SDK Context Override:** +Pass a key directly in the request context using `schemas.BifrostContextKeyDirectKey`. This completely bypasses provider key lookup and selection. + +**Gateway Header-based Keys:** +Send API keys in `Authorization` (Bearer) or `x-api-key` headers. Requires `allow_direct_keys` setting to be enabled. + +**Enable Direct Keys:** + + + + + +![Web UI](../../media/ui-config-direct-keys.png) + +1. Navigate to **Configuration** page +2. Toggle **"Allow Direct Keys"** to enabled +3. Save configuration + + + + +```json +{ + "client": { + "allow_direct_keys": true + } +} +``` + + + + + +If a Bifrost virtual key (`sk-bf-*`) is attached in the auth header, direct key bypass will be skipped. + +**When to Use Direct Keys:** +- Per-user API key scenarios +- External key management systems +- Testing with specific keys +- Debugging key-related issues diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx new file mode 100644 index 000000000..edcaa2d8e --- /dev/null +++ b/docs/features/mcp.mdx @@ -0,0 +1,815 @@ +--- +title: "Model Context Protocol (MCP)" +description: "Enable AI models to discover and execute external tools dynamically. Transform static chat models into action-capable agents with filesystem access, web search, databases, and custom business logic." +icon: "toolbox" +--- + +## Overview + +**Model Context Protocol (MCP)** enables AI models to seamlessly discover and execute external tools at runtime, transforming static chat models into dynamic, action-capable agents. Instead of being limited to text generation, AI models can interact with filesystems, search the web, query databases, and execute custom business logic through external MCP servers. + +Bifrost's MCP integration provides a secure, high-performance bridge between AI models and external tools, with client-side control over all tool execution and granular filtering capabilities. + +**πŸ”’ Security-First Design**: Bifrost never automatically executes tool calls. Instead, it provides APIs for explicit tool execution, ensuring human oversight and approval for all potentially dangerous operations. + +**Key Benefits:** + +| Feature | Description | +|---------|-------------| +| **Dynamic Discovery** | Tools are discovered at runtime from external MCP servers | +| **Stateless Design** | Independent API calls with no session state management | +| **Client-Side Control** | Bifrost manages all tool execution for security and observability | +| **Multiple Protocols** | STDIO, HTTP, and SSE connection types | +| **Granular Filtering** | Control tool availability per request and client | +| **High Performance** | Async execution with minimal latency overhead | +| **Copy-Pastable Responses** | Tool results designed for seamless conversation assembly | + +--- + +## How MCP Works in Bifrost + +Bifrost acts as an MCP client that connects to external MCP servers hosting tools. The integration is **completely stateless** with independent API calls: + +1. **Discovery**: Bifrost connects to configured MCP servers and discovers available tools +2. **Integration**: Tools are automatically added to the AI model's function calling schema +3. **Suggestion**: Chat completion requests return tool call suggestions (not executed) +4. **Execution**: Separate tool execution API calls execute specific tool calls +5. **Assembly**: Your application manages conversation state and assembles chat history +6. **Continuation**: Follow-up chat requests use the complete conversation history + +**Stateless Tool Flow:** +``` +Chat Request β†’ Tool Call Suggestions (Independent) + ↓ +Tool Execution Request β†’ Tool Results (Independent) + ↓ +Your App Assembles History β†’ Continue Chat (Independent) +``` + +**Bifrost never automatically executes tool calls.** All API calls are independent and stateless: + +- **Chat completions** return tool call suggestions without executing them +- **Tool execution** requires separate API calls with explicit tool call data +- **No state management** - your application controls conversation flow +- **Copy-pastable responses** designed for easy conversation assembly + +This design prevents: +- Unintended API calls to external services +- Accidental data modification or deletion +- Execution of potentially harmful commands + +**Implementation Pattern:** +``` +1. POST /v1/chat/completions β†’ Get tool call suggestions (stateless) +2. Your App Reviews Tool Calls β†’ Decides which to execute +3. POST /v1/mcp/tool/execute β†’ Execute specific tool calls (stateless) +4. Your App Assembles History β†’ Continue with complete conversation +``` + +This stateless pattern ensures **explicit control** over all tool operations while providing responses optimized for conversation continuity. + +--- + +## Setup Guides + +### Go SDK Setup + +Configure MCP in your Bifrost initialization: + +```go +package main + +import ( + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +func main() { + mcpConfig := &schemas.MCPConfig{ + ClientConfigs: []schemas.MCPClientConfig{ + { + Name: "filesystem-tools", + ConnectionType: schemas.MCPConnectionTypeSTDIO, + StdioConfig: &schemas.MCPStdioConfig{ + Command: "node", + Args: []string{"filesystem-mcp-server.js"}, + }, + ToolsToExecute: []string{"read_file", "write_file"}, + }, + { + Name: "web-search", + ConnectionType: schemas.MCPConnectionTypeHTTP, + ConnectionString: bifrost.Ptr("http://localhost:3001/mcp"), + ToolsToExecute: []string{"*"}, // Allow all tools from this client + }, + }, + } + // ToolsToExecute semantics for MCPClientConfig: + // - ["*"] => all tools are included + // - [] => no tools are included (deny-by-default) + // - nil/omitted => treated as [] (no tools) + // - ["tool1", "tool2"] => include only the specified tools + + // Initialize Bifrost with MCP configuration + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: account, + MCPConfig: mcpConfig, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo), + }) + if err != nil { + panic(err) + } +} +``` + +Note: Bifrost needs to be initialized with the MCP configuration(even an empty MCP config is fine) before using the MCP Methods. Read more about runtime MCP client management [here](#client-state-management). + +### Gateway Setup + + + + +![MCP Configuration in Web UI](../media/ui-mcp-config.png) + +1. Navigate to **MCP Clients** in the Bifrost Gateway UI +2. Click **New MCP Client** +3. Configure connection details: + - **Name**: Unique identifier for the MCP client + - **Connection Type**: STDIO, HTTP, or SSE + - **Connection Details**: Command/URL based on connection type + +By default, all tools from the MCP client are included. You can update the tools to be included after the MCP client is created. + +![MCP Tools Configuration in Web UI](../media/ui-mcp-tool-config.png) + + + + + +Add MCP clients via the Gateway API: + +```bash +# Add STDIO MCP client +curl -X POST http://localhost:8080/api/mcp/client \ + -H "Content-Type: application/json" \ + -d '{ + "name": "filesystem-tools", + "connection_type": "stdio", + "stdio_config": { + "command": "node", + "args": ["filesystem-mcp-server.js"], + "envs": ["NODE_ENV"] + }, + "tools_to_execute": ["read_file", "write_file"] + }' + +# Add HTTP MCP client +curl -X POST http://localhost:8080/api/mcp/client \ + -H "Content-Type: application/json" \ + -d '{ + "name": "web-search", + "connection_type": "http", + "connection_string": "http://localhost:3001/mcp", + "tools_to_execute": ["*"] + }' + +# Update tools to be included to only specific tools +curl -X PUT http://localhost:8080/api/mcp/client/web-search \ + -H "Content-Type: application/json" \ + -d '{ + "tools_to_execute": ["search"] + }' + +# Update tools to be included to none +curl -X PUT http://localhost:8080/api/mcp/client/web-search \ + -H "Content-Type: application/json" \ + -d '{ + "tools_to_execute": [] + }' +``` + + + + +Configure MCP clients in your `config.json`: + +```json +{ + "mcp": { + "client_configs": [ + { + "name": "filesystem-tools", + "connection_type": "stdio", + "stdio_config": { + "command": "node", + "args": ["filesystem-mcp-server.js"], + "envs": ["NODE_ENV"] + }, + "tools_to_execute": ["read_file", "write_file", "list_directory"] + }, + { + "name": "web-search", + "connection_type": "http", + "connection_string": "env.WEB_SEARCH_MCP_URL", + "tools_to_execute": ["*"] + }, + { + "name": "real-time-data", + "connection_type": "sse", + "connection_string": "https://api.example.com/mcp/sse", + "tools_to_execute": [] + } + ] + } +} +``` + + + + +--- + +## Connection Types + +### STDIO Connection + +STDIO connections launch external processes and communicate via standard input/output. Best for local tools and scripts. + +**Configuration:** +```json +{ + "name": "local-tools", + "connection_type": "stdio", + "stdio_config": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "envs": ["PYTHON_PATH", "API_KEY"] + } +} +``` + +**Use Cases:** +- Local filesystem operations +- Database queries with local credentials +- Python/Node.js MCP servers +- Custom business logic scripts + +### HTTP Connection + +HTTP connections communicate with MCP servers via HTTP requests. Ideal for remote services and microservices. + +**Configuration:** +```json +{ + "name": "remote-api", + "connection_type": "http", + "connection_string": "https://mcp-server.example.com/api" +} +``` + +**Use Cases:** +- Remote API integrations +- Cloud-hosted MCP services +- Microservice architectures +- Third-party tool providers + +### SSE Connection + +Server-Sent Events (SSE) connections provide real-time, persistent connections to MCP servers. Best for streaming data and live updates. + +**Configuration:** +```json +{ + "name": "live-data", + "connection_type": "sse", + "connection_string": "https://stream.example.com/mcp/events" +} +``` + +**Use Cases:** +- Real-time market data +- Live system monitoring +- Streaming analytics +- Event-driven workflows + +--- + +## End-to-End Tool Calling + + + + +Complete tool calling workflow with the Go SDK: + +```go +package main + +import ( + "context" + "fmt" + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +func main() { + // Initialize Bifrost with MCP + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: account, + MCPConfig: &schemas.MCPConfig{ + ClientConfigs: []schemas.MCPClientConfig{ + { + Name: "filesystem", + ConnectionType: schemas.MCPConnectionTypeSTDIO, + StdioConfig: &schemas.MCPStdioConfig{ + Command: "node", + Args: []string{"fs-mcp-server.js"}, + }, + ToolsToExecute: []string{"*"}, + }, + }, + }, + }) + + firstMessage := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Read the contents of config.json file"), + }, + } + + // Create request with tools automatically included + request := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + firstMessage, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.7), + }, + } + + // Send chat completion request - MCP tools are automatically available + response, err := client.ChatCompletionRequest(context.Background(), request) + if err != nil { + panic(err) + } + + // Build conversation history for final response + conversationHistory := []schemas.ChatMessage{ + firstMessage, + } + + // Handle tool calls in response (suggestions only - not executed) + if response.Choices[0].Message.ToolCalls != nil { + secondMessage := response.Choices[0].Message + + // Add assistant message with tool calls to history + conversationHistory = append(conversationHistory, secondMessage) + + for _, toolCall := range *secondMessage.ToolCalls { + fmt.Printf("Tool suggested: %s\n", *toolCall.Function.Name) + + // YOUR APPLICATION DECISION: Review the tool call + // - Validate tool name and arguments + // - Apply security and business rules + // - Check permissions and rate limits + // - Decide whether to execute + + shouldExecute := validateToolCall(toolCall) // Your validation logic + if !shouldExecute { + fmt.Printf("Tool call rejected by application\n") + continue + } + + // EXPLICIT EXECUTION: Separate API call + thirdMessage, err := client.ExecuteMCPTool(context.Background(), toolCall) + if err != nil { + fmt.Printf("Tool execution failed: %v\n", err) + continue + } + + fmt.Printf("Tool result: %s\n", *thirdMessage.Content.ContentStr) + + // Add tool result to conversation history + conversationHistory = append(conversationHistory, thirdMessage) + } + + // Send complete conversation history for final response + finalRequest := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: conversationHistory, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.7), + }, + } + + finalResponse, err := client.ChatCompletionRequest(context.Background(), finalRequest) + if err != nil { + panic(err) + } + + fmt.Printf("Final response: %s\n", *finalResponse.Choices[0].Message.Content.ContentStr) + } +} +``` + + + + +Complete tool calling workflow via Gateway API: + +```bash +# 1. Send chat completion request - tools are automatically included +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": "Show me latest videos regarding Bifrost" + } + ] + }' + +# Response includes tool calls (suggestions only - NOT executed yet): +{ + "choices": [{ + "message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_f5aAgjJAC9FO4Or0F2oCVAho", + "type": "function", + "function": { + "name": "YOUTUBE_SEARCH_YOU_TUBE", + "arguments": "{\"q\":\"Bifrost\",\"part\":\"snippet\",\"maxResults\":5}" + } + }] + } + }] +} + +# 2. YOUR APPLICATION DECISION: Review the tool call +# - Validate the search query is appropriate +# - Check rate limits and quotas +# - Apply content filtering rules +# - Approve or reject based on business logic + +# 3. EXPLICIT EXECUTION: Execute the approved tool call (request body is the same as the tool call suggestion) +curl -X POST http://localhost:8080/v1/mcp/tool/execute \ + -H "Content-Type: application/json" \ + -d '{ + "type": "function", + "id": "call_f5aAgjJAC9FO4Or0F2oCVAho", + "function": { + "name": "YOUTUBE_SEARCH_YOU_TUBE", + "arguments": "{\"q\":\"Bifrost\",\"part\":\"snippet\",\"maxResults\":5}" + } + }' + +# Tool execution response (copy-pastable for conversation): +{ + "role": "tool", + "content": "{\n\"data\": {\n\"response_data\": {\n\"items\": [\n{\n\"snippet\": {\n\"title\": \"Fastest LLM Gateway - Bifrost\",\n \"description\": \"Bifrost is the fastest LLM Gateway that allows you to use any LLM...\"\n}\n}\n]\n}\n}\n}", + "tool_call_id": "call_f5aAgjJAC9FO4Or0F2oCVAho" +} + +# 4. Assemble complete conversation history and continue +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": "Show me latest videos regarding Bifrost" + }, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_f5aAgjJAC9FO4Or0F2oCVAho", + "type": "function", + "function": { + "name": "YOUTUBE_SEARCH_YOU_TUBE", + "arguments": "{\"q\":\"Bifrost\",\"part\":\"snippet\",\"maxResults\":5}" + } + }] + }, + { + "role": "tool", + "content": "{\n\"data\": {\n\"response_data\": {...}\n }\n}", + "tool_call_id": "call_f5aAgjJAC9FO4Or0F2oCVAho" + } + ] + }' + +# Final response with formatted results: +{ + "choices": [{ + "message": { + "role": "assistant", + "content": "Here are the latest videos related to \"Bifrost\":\n\n1. **Fastest LLM Gateway - Bifrost**\n - Published: August 21, 2025\n - Description: Bifrost is the fastest LLM Gateway that allows you to use any LLM..." + } + }] +} +``` + + + + +--- + +## Tool Registry (Go SDK Only) + +The Go SDK provides a powerful tool registry for hosting custom tools directly within your application using typed handlers. + +```go +package main + +import ( + "fmt" + "strings" + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// Define typed arguments for your tool +type CalculatorArgs struct { + Operation string `json:"operation"` // add, subtract, multiply, divide + A float64 `json:"a"` + B float64 `json:"b"` +} + +// Define typed tool handler +func calculatorHandler(args CalculatorArgs) (string, error) { + switch strings.ToLower(args.Operation) { + case "add": + return fmt.Sprintf("%.2f", args.A + args.B), nil + case "subtract": + return fmt.Sprintf("%.2f", args.A - args.B), nil + case "multiply": + return fmt.Sprintf("%.2f", args.A * args.B), nil + case "divide": + if args.B == 0 { + return "", fmt.Errorf("cannot divide by zero") + } + return fmt.Sprintf("%.2f", args.A / args.B), nil + default: + return "", fmt.Errorf("unsupported operation: %s", args.Operation) + } +} + +func main() { + // Initialize Bifrost (tool registry creates in-process MCP automatically) + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: account, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo), + }) + + // Define tool schema + calculatorSchema := schemas.ChatTool{ + Type: "function", + Function: schemas.ChatToolFunction{ + Name: "calculator", + Description: "Perform basic arithmetic operations", + Parameters: schemas.ToolFunctionParameters{ + Type: "object", + Properties: map[string]interface{}{ + "operation": map[string]interface{}{ + "type": "string", + "description": "The operation to perform", + "enum": []string{"add", "subtract", "multiply", "divide"}, + }, + "a": map[string]interface{}{ + "type": "number", + "description": "First number", + }, + "b": map[string]interface{}{ + "type": "number", + "description": "Second number", + }, + }, + Required: []string{"operation", "a", "b"}, + }, + }, + } + + // Register the typed tool + err = client.RegisterMCPTool("calculator", "Perform arithmetic calculations", + func(args any) (string, error) { + // Convert args to typed struct + calculatorArgs := CalculatorArgs{} + if jsonBytes, err := json.Marshal(args); err == nil { + json.Unmarshal(jsonBytes, &calculatorArgs) + } + return calculatorHandler(calculatorArgs) + }, calculatorSchema) + + if err != nil { + panic(fmt.Sprintf("Failed to register tool: %v", err)) + } + + // Now use the tool in requests + request := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Calculate 15.5 + 24.3"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.7), + }, + } + + response, err := client.ChatCompletionRequest(context.Background(), request) + // The model can now use the calculator tool automatically +} +``` + +**Tool Registry Benefits:** + +- **Type Safety**: Compile-time checking of tool arguments and return types +- **Performance**: In-process execution with zero network overhead +- **Simplicity**: No external MCP server setup required +- **Integration**: Tools are automatically available to all AI requests +- **Error Handling**: Structured error responses with detailed context + +--- + +## Advanced Configuration + +### Tool and Client Filtering + +Control which tools and clients are available per request or globally: + +**Request-Level Filtering:** + + + + +Use context values to filter clients and tools per request: + +```go +// Include only specific clients +ctx := context.WithValue(context.Background(), "mcp-include-clients", []string{"filesystem", "web-search"}) + +// Include only specific tools (use clientName/toolName format) +ctx = context.WithValue(ctx, "mcp-include-tools", []string{"web-search/search", "filesystem/read_file"}) + +// Use wildcard to include all tools from a specific client +ctx = context.WithValue(ctx, "mcp-include-tools", []string{"web-search/*", "filesystem/read_file"}) + +// Use wildcard to include all clients +ctx = context.WithValue(ctx, "mcp-include-clients", []string{"*"}) + +response, err := client.ChatCompletionRequest(ctx, request) +``` + + + + +Use headers to filter clients and tools per request: + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-bf-mcp-include-clients: filesystem,web-search" \ + -H "x-bf-mcp-include-tools: web-search/search,filesystem/read_file" \ + -d '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": "Search for recent AI developments" + } + ] + }' + +# Alternative filtering options: +# -H "x-bf-mcp-include-clients: *" # Include all clients +# -H "x-bf-mcp-include-tools: web-search/*,filesystem/read_file" # Include all tools from the web-search client and read_file from the filesystem client +``` + +**Available MCP Headers:** +- `x-bf-mcp-include-clients`: Comma-separated list of clients to include (use "*" for all clients) +- `x-bf-mcp-include-tools`: Comma-separated list of tools to include in `clientName/toolName` format (use "*" for all tools) + + + + +**Filtering Logic:** + +The client's configuration (`ToolsToExecute`) defines the set of enabled tools for that client. The request-level `mcp-include-tools` list can then be used to select a subset of those tools for a specific request. If `mcp-include-tools` is not provided, all tools enabled by the client's configuration are available. + +- **Include lists are strict whitelists**: If `include-clients`/`include-tools` is specified, ONLY those clients/tools are allowed. +- **Wildcard support**: Use `*` to include all clients. For tools, use `*` in the client configuration to include all its tools. At the request level, use `/*` to include all tools from a specific client. +- **Empty array behavior**: An empty array `[]` means no clients/tools are included. + +### Environment Variables + +Use environment variables for sensitive configuration: + +**Gateway:** +```json +{ + "name": "secure-api", + "connection_type": "http", + "connection_string": "env.SECURE_MCP_URL", // References $SECURE_MCP_URL + "stdio_config": { + "command": "python", + "args": ["-m", "secure_server"], + "envs": ["API_SECRET", "DATABASE_URL"] // Required environment variables + } +} +``` + +**Environment variables are:** +- Automatically resolved during client connection +- Redacted in API responses and UI for security +- Validated at startup to ensure all required variables are set + +### Client State Management + +Monitor and manage MCP client connections: + + + + +```go +// Get all connected clients and their status +clients, err := client.GetMCPClients() +for _, mcpClient := range clients { + fmt.Printf("Client: %s, State: %s, Tools: %v\n", + mcpClient.Name, mcpClient.State, mcpClient.Tools) +} + +// Reconnect a disconnected client +err = client.ReconnectMCPClient("filesystem-tools") + +// Add new client at runtime +err = client.AddMCPClient(newClientConfig) + +// Remove client +err = client.RemoveMCPClient("old-client") + +// Edit client tools +err = client.EditMCPClientTools("filesystem-tools", + []string{"read_file", "write_file"}) // tools to be included +``` + + + + +```bash +# Get client status +curl http://localhost:8080/api/mcp/clients + +# Reconnect client +curl -X POST http://localhost:8080/api/mcp/client/filesystem-tools/reconnect + +# Add new client +curl -X POST http://localhost:8080/api/mcp/client \ + -H "Content-Type: application/json" \ + -d '{ + "name": "new-filesystem", + "connection_type": "stdio", + "stdio_config": { + "command": "node", + "args": ["fs-server.js"] + } + }' + +# Edit client tools +curl -X PUT http://localhost:8080/api/mcp/client/filesystem-tools \ + -H "Content-Type: application/json" \ + -d '{ + "tools_to_add": ["read_file", "write_file"], + }' + +# Remove client +curl -X DELETE http://localhost:8080/api/mcp/client/old-client +``` + + + + +**Connection States:** +- **Connected**: Client is active and tools are available +- **Connecting**: Client is establishing connection +- **Disconnected**: Client lost connection but can be reconnected +- **Error**: Client configuration or connection failed + +--- + +## Architecture Details + +For detailed information about MCP's internal architecture, concurrency model, tool discovery process, and performance characteristics, see the [MCP Architecture Guide](../architecture/core/mcp). diff --git a/docs/features/observability/default.mdx b/docs/features/observability/default.mdx new file mode 100644 index 000000000..fecb95654 --- /dev/null +++ b/docs/features/observability/default.mdx @@ -0,0 +1,418 @@ +--- +title: "Built-in Observability" +description: "Monitor and analyze every AI request and response in real-time. Track performance, debug issues, and gain insights into your AI application's behavior with comprehensive request tracing." +icon: "cube" +--- + +## Overview + +Bifrost includes **built-in observability**, a powerful feature that automatically captures and stores detailed information about every AI request and response that flows through your system. This provides structured, searchable data with real-time monitoring capabilities, making it easy to debug issues, analyze performance patterns, and understand your AI application's behavior at scale. + +All LLM interactions are captured with comprehensive metadata including inputs, outputs, tokens, costs, and latency. The logging plugin operates **asynchronously** with zero impact on request latency. + +![Live Log Stream Interface](../../media/ui-live-log-stream.gif) + +--- + +## What's Captured + +Bifrost traces comprehensive information for every request, without any changes to your application code. + +![Complete Request Tracing Overview](../../media/ui-request-tracing-overview.png) + +### **Request Data** +- **Input Messages**: Complete conversation history and user prompts +- **Model Parameters**: Temperature, max tokens, tools, and all other parameters +- **Provider Context**: Which provider and model handled the request + +### **Response Data** +- **Output Messages**: AI responses, tool calls, and function results +- **Performance Metrics**: Latency and token usage +- **Status Information**: Success or error details + +### **Multimodal & Tool Support** +- **Audio Processing**: Speech synthesis and transcription inputs/outputs +- **Vision Analysis**: Image URLs and vision model responses +- **Tool Execution**: Function calling arguments and results + +![Multimodal Request Tracing](../../media/ui-multimodal-tracing.png) + +--- + +## How It Works + +The logging plugin intercepts all requests flowing through Bifrost using the plugin architecture, ensuring your LLM requests maintain optimal performance: + +1. **PreHook**: Captures request metadata (provider, model, input messages, parameters). +2. **Async Processing**: Logs are written in background goroutines with `sync.Pool` optimization. +3. **PostHook**: Updates log entry with response data (output, tokens, cost, latency, errors). +4. **Real-time Updates**: WebSocket broadcasts keep the UI synchronized. + +All logging operations are non-blocking, ensuring your LLM requests maintain optimal performance. + +--- + +## Configuration + +Configure request tracing to control what gets logged and where it's stored. + + + + + +![Tracing Configuration Interface](../../media/ui-tracing-config.png) + +1. Navigate to **http://localhost:8080** +2. Go to **"Settings"** +3. Toggle **"Enable Logs"** + + + + + +**Enable/Disable Tracing:** +```bash +curl --location 'http://localhost:8080/api/config' \ +--header 'Content-Type: application/json' \ +--method PUT \ +--data '{ + "client_config": { + "enable_logging": true, + "disable_content_logging": false, + "drop_excess_requests": false, + "initial_pool_size": 300, + "enable_governance": true, + "enforce_governance_header": false, + "allow_direct_keys": false, + "prometheus_labels": [], + "allowed_origins": [] + } +}' +``` + +**Check Current Configuration:** +```bash +curl --location 'http://localhost:8080/api/config' +``` + +**Response includes tracing status:** +```json +{ + "client_config": { + "enable_logging": true, + "disable_content_logging": false, + "drop_excess_requests": false + }, + "is_db_connected": true, + "is_cache_connected": true, + "is_logs_connected": true +} +``` + + + + + +In your `config.json` file, you can enable logging and configure the log store: +```json +{ + "client": { + "enable_logging": true, + "disable_content_logging": false, + "drop_excess_requests": false, + "initial_pool_size": 300, + "enable_governance": true, + "allow_direct_keys": false + }, + "logs_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "./logs.db" + } + } +} +``` +- **`enable_logging`**: Master toggle for request tracing. +- **`disable_content_logging`**: Disable logging of request/response content, but still log usage metadata (latency, cost, token count, etc.). +- **`logs_store`**: Check [Log Store Options](#log-store-options) for more details. + + + + + +When using Bifrost as a Go SDK, initialize the logging plugin manually: + +```go +package main + +import ( + "context" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/framework/pricing" + "github.com/maximhq/bifrost/plugins/logging" +) + +func main() { + ctx := context.Background() + logger := schemas.NewLogger() + + // Initialize log store (SQLite) + store, err := logstore.NewLogStore(ctx, &logstore.Config{ + Enabled: true, + Type: logstore.LogStoreTypeSQLite, + Config: &logstore.SQLiteConfig{ + Path: "./logs.db", + }, + }, logger) + if err != nil { + panic(err) + } + + // Initialize pricing manager (required for cost calculation) + pricingManager := pricing.NewPricingManager(logger) + + // Initialize logging plugin + loggingPlugin, err := logging.Init(ctx, logger, store, pricingManager) + if err != nil { + panic(err) + } + + // Initialize Bifrost with logging plugin + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &yourAccount, + Plugins: []schemas.Plugin{loggingPlugin}, + }) + if err != nil { + panic(err) + } + defer client.Shutdown() + + // All requests are now logged automatically +} +``` + + + + + +--- + +## Accessing & Filtering Logs + +Retrieve and analyze logs with powerful filtering capabilities via the UI, API, and WebSockets. + +![Advanced Log Filtering Interface](../../media/ui-log-filtering.gif) + +### Web UI + +When running the Gateway, access the built-in dashboard at `http://localhost:8080`. The UI provides: +- Real-time log streaming +- Advanced filtering and search +- Detailed request/response inspection +- Token and cost analytics + +### API Endpoints + +Query logs programmatically using the `GET` request. + +```bash +curl 'http://localhost:8080/api/logs?' \ +'providers=openai,anthropic&' \ +'models=gpt-4o-mini&' \ +'status=success,error&' \ +'start_time=2024-01-15T00:00:00Z&' \ +'end_time=2024-01-15T23:59:59Z&' \ +'min_latency=1000&' \ +'max_latency=5000&' \ +'min_tokens=10&' \ +'max_tokens=1000&' \ +'min_cost=0.001&' \ +'max_cost=10&' \ +'content_search=python&' \ +'limit=100&' \ +'offset=0' +``` +**Available Filters:** + +| Filter | Description | Example | +|--------|-------------|---------| +| `providers` | Filter by AI providers | `openai,anthropic` | +| `models` | Filter by specific models | `gpt-4o-mini,claude-3-sonnet` | +| `status` | Request status | `success,error,processing` | +| `objects` | Request types | `chat.completion,embedding` | +| `start_time` / `end_time` | Time range (RFC3339) | `2024-01-15T10:00:00Z` | +| `min_latency` / `max_latency` | Response time (ms) | `1000` to `5000` | +| `min_tokens` / `max_tokens` | Token usage range | `10` to `1000` | +| `min_cost` / `max_cost` | Cost range (USD) | `0.001` to `10` | +| `content_search` | Search in messages | `"error handling"` | +| `limit` / `offset` | Pagination | `100`, `200` | + +**Response Format** + +```json +{ + "logs": [...], + "pagination": { + "limit": 100, + "offset": 0, + "sort_by": "timestamp", + "order": "desc" + }, + "stats": { + "total_requests": 1234, + "success_rate": 0.85, + "average_latency": 100, + "total_tokens": 10000, + "total_cost": 100 + } +} +``` + +Perfect for analytics, debugging specific issues, or building custom monitoring dashboards. + +### WebSocket + +Subscribe to real-time log updates for live monitoring: + +```javascript +const ws = new WebSocket('ws://localhost:8080/ws') + +ws.onmessage = (event) => { + const logUpdate = JSON.parse(event.data) + console.log('New log entry:', logUpdate) +} +``` + +--- + +## Log Store Options + +Choose the right storage backend for your scale and requirements. + +The logging plugin is **automatically enabled** in Gateway mode with SQLite storage by default. You can configure it to use PostgreSQL by setting the `logs_store` configuration in your `config.json` file. + +### **Current Support** + + + + +- **Best for**: Development, small-medium deployments +- **Performance**: Excellent for read-heavy workloads +- **Setup**: Zero configuration, single file storage +- **Limits**: Single-writer, local filesystem only + +```json +{ + "logs_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "./logs.db" + } + } +} +``` + + + + +- **Best for**: High-volume production deployments +- **Performance**: Excellent concurrent writes and complex queries +- **Features**: Advanced indexing, partitioning, replication + +```json +{ + "logs_store": { + "enabled": true, + "type": "postgres", + "config": { + "host": "localhost", + "port": "5432", + "user": "bifrost", + "password": "postgres", + "db_name": "bifrost", + "ssl_mode": "disable" + } + } +} +``` + + + + +### **Planned Support** + +- **MySQL**: For traditional MySQL environments. +- **ClickHouse**: For large-scale analytics and time-series workloads. + +--- + +## Supported Request Types + +The logging plugin captures all Bifrost request types: + +- Text Completion (streaming and non-streaming) +- Chat Completion (streaming and non-streaming) +- Responses (streaming and non-streaming) +- Embeddings +- Speech Generation (streaming and non-streaming) +- Transcription (streaming and non-streaming) + +--- + +## When to Use + +### Built-in Observability + +Use the built-in logging plugin for: + +- **Local Development**: Quick setup with SQLite, no external dependencies +- **Self-hosted Deployments**: Full control over your data with PostgreSQL +- **Simple Use Cases**: Basic monitoring and debugging needs +- **Privacy-sensitive Workloads**: Keep all logs on your infrastructure + +### vs. Maxim Plugin + +Switch to the [Maxim plugin](./maxim) for: + +- Advanced evaluation and testing workflows +- Prompt engineering and experimentation +- Multi-team governance and collaboration +- Production monitoring with alerts and SLAs +- Dataset management and annotation pipelines + +### vs. OTel Plugin + +Switch to the [OTel plugin](./otel) for: + +- Integration with existing observability infrastructure +- Correlation with application traces and metrics +- Custom collector configurations +- Compliance and enterprise requirements + +--- + +## Performance + +The logging plugin is designed for **zero-impact observability**: + +- **Async Operations**: All database writes happen in background goroutines +- **Sync.Pool**: Reuses memory allocations for LogMessage and UpdateLogData structs +- **Batch Processing**: Efficiently handles high request volumes +- **Automatic Cleanup**: Removes stale processing logs every 30 seconds + +In benchmarks, the logging plugin adds **< 0.1ms overhead** to request processing time. + +--- + +## Next Steps + +- **[Maxim Plugin](./maxim)** - Advanced observability with evaluation and monitoring +- **[OTel Plugin](./otel)** - OpenTelemetry integration for distributed tracing +- **[Gateway Setup](../../quickstart/gateway/setting-up)** - Get Bifrost running with tracing enabled +- **[Provider Configuration](../../quickstart/gateway/provider-configuration)** - Configure multiple providers for better insights +- **[Telemetry](../telemetry)** - Prometheus metrics and dashboards +- **[Governance](../governance)** - Virtual keys and usage limits \ No newline at end of file diff --git a/docs/features/observability/maxim.mdx b/docs/features/observability/maxim.mdx new file mode 100644 index 000000000..2c5ec4d53 --- /dev/null +++ b/docs/features/observability/maxim.mdx @@ -0,0 +1,225 @@ +--- +title: "Maxim AI" +description: "Integrate Maxim SDK for comprehensive LLM observability, tracing, and evaluation." +icon: "infinity" +--- + +## Overview + +Bifrost provides comprehensive LLM observability through the **Maxim plugin**, enabling seamless tracking, evaluation, and analysis of AI interactions. The plugin automatically forwards all LLM requests and responses to Maxim's platform for detailed monitoring and performance insights. + +![Maxim Logs](https://github.com/maximhq/bifrost/blob/main/docs/media/maxim-logs.png?raw=true) + +--- + +## Setup + +The Maxim plugin enables seamless observability and evaluation of LLM interactions by forwarding inputs/outputs to Maxim's platform: + + + + +```go +package main + +import ( + "context" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + maxim "github.com/maximhq/bifrost/plugins/maxim" +) + +func main() { + // Initialize Maxim plugin + maximPlugin, err := maxim.Init(maxim.Config{ + ApiKey: "your_maxim_api_key", + LogRepoId: "your_default_repo_id", // Optional: fallback repository + }) + if err != nil { + panic(err) + } + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &yourAccount, + Plugins: []schemas.Plugin{maximPlugin}, + }) + if err != nil { + panic(err) + } + defer client.Shutdown() + + // All requests will now be traced to Maxim +} +``` + + + + +For HTTP transport, configure via environment variables: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "maxim", + "config": { + "api_key": "your_maxim_api_key", + "log_repo_id": "your_default_repo_id" + } + } + ] +} +``` + + + + +## Configuration + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `ApiKey` | `string` | βœ… Yes | Your Maxim API key for authentication | +| `LogRepoId` | `string` | ❌ No | Default log repository ID (can be overridden per request) | + +## Repository Selection + +The plugin uses repository selection with the following priority: + +1. **Header/Context Repository** - Highest priority +2. **Default Repository** (from plugin config) - Fallback +3. **Skip Logging** - If neither is available + + + + +```go +ctx := context.Background() + +// Use specific repository for this request +ctx = context.WithValue(ctx, maxim.LogRepoIDKey, "project-specific-repo") +``` + + + + +```bash +# Use default repository (from config) +curl -X POST http://localhost:8080/v1/chat/completions \ + -d '{"model": "gpt-4", "messages": [...]}' + +# Override with specific repository +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-maxim-log-repo-id: project-specific-repo" \ + -d '{"model": "gpt-4", "messages": [...]}' +``` + + + + + +## Custom Trace Management + +### Trace Propagation + +The plugin supports custom session, trace, and generation IDs for advanced tracing scenarios: + + + +```go +ctx := context.Background() + +// Prefer typed keys from the Maxim plugin +ctx = context.WithValue(ctx, maxim.TraceIDKey, "custom-trace-123") +ctx = context.WithValue(ctx, maxim.GenerationIDKey, "custom-gen-456") +ctx = context.WithValue(ctx, maxim.SessionIDKey, "user-session-789") + +// Optionally set human-friendly names +ctx = context.WithValue(ctx, maxim.TraceNameKey, "checkout-flow") +ctx = context.WithValue(ctx, maxim.GenerationNameKey, "rerank-step") +``` + + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-maxim-trace-id: custom-trace-123" \ + -H "x-bf-maxim-generation-id: custom-gen-456" \ + -H "x-bf-maxim-session-id: user-session-789" \ + -H "x-bf-maxim-trace-name: checkout-flow" \ + -H "x-bf-maxim-generation-name: rerank-step" \ + -d '{"model": "gpt-4", "messages": [...]}' +``` + + + +### Custom Tags + +You can add custom tags to traces for enhanced filtering and analytics: + + + + +```go +ctx := context.Background() + +// Pass arbitrary tag key-values via context map +tags := map[string]string{ + "environment": "production", + "user-id": "user-123", + "feature-flag": "new-ui", +} +ctx = context.WithValue(ctx, maxim.TagsKey, tags) +``` + + + + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "x-bf-maxim-environment: production" \ + -H "x-bf-maxim-user-id: user-123" \ + -H "x-bf-maxim-feature-flag: new-ui" \ + -d '{"model": "gpt-4", "messages": [...]}' +``` + +Reserved keys are `session-id`, `trace-id`, `trace-name`, `generation-id`, `generation-name`, `log-repo-id`. All other `x-bf-maxim-*` headers are treated as tags. + + + + +## Supported Request Types + +The plugin supports the following Bifrost request types: + +- Text Completion +- Chat Completion + +## Monitoring & Analytics + +Once configured, monitor your AI apps in the [Maxim Dashboard](https://getmaxim.ai/). Maxim is an end-to-end evaluation & observability platform built to help teams ship AI agents faster while maintaining high quality. + +* **Experiment / Prompt Engineering** + Playground++ for prompt design: versioning, comparison (A/B), visual chaining, low-code tooling. + +* **Simulation & Evaluation** + Test agents over thousands of scenarios, both automated (statistical, programmatic) and human-in-the-loop for edge cases. Custom and off-the-shelf evaluators. + +* **Observability / Monitoring** + Real-time traces, logging, debugging of multi-agent workflows, live issue tracking, alerts when quality or performance degrade. + +* **Data Engine & Dataset Management** + Support for multi-modal datasets, import & continuous curation, feedback/annotation pipelines, data splitting for experiments. + +* **Governance, Security & Compliance** + Features like SOC 2 Type II compliance, enterprise security controls, permissions, auditability. + +* **Alerts & SLAs**: Threshold-based notifications to keep quality and latency in guardrails + +## Next Steps + +Now that you have observability set up with the Maxim plugin, explore these related topics: + +- **[Tracing](./observability/default)** - Deep-dive into request/response logging and correlation +- **[Telemetry](./telemetry)** - Prometheus metrics, dashboards, and alerting +- **[Governance](./governance/virtual-keys)** - Virtual keys, per-team controls, and usage limits diff --git a/docs/features/observability/otel.mdx b/docs/features/observability/otel.mdx new file mode 100644 index 000000000..4e89e4a3b --- /dev/null +++ b/docs/features/observability/otel.mdx @@ -0,0 +1,714 @@ +--- +title: "OpenTelemetry (OTel)" +description: "Integrate with OpenTelemetry collectors for enterprise observability and distributed tracing" +icon: "bolt" +--- + +## Overview + +The **OTel plugin** enables seamless integration with OpenTelemetry Protocol (OTLP) collectors, allowing you to send LLM traces to your existing observability infrastructure. Connect Bifrost to platforms like Grafana Cloud, Datadog, New Relic, Honeycomb, or self-hosted collectors. + +All traces follow OpenTelemetry semantic conventions, making it easy to correlate LLM operations with your broader application telemetry. + +--- + +## Supported Trace Formats + +The plugin supports multiple trace formats to match your observability platform: + +| Format | Description | Use Case | Status | +|--------|-------------|----------|----------| +| `genai_extension` | OpenTelemetry GenAI semantic conventions | **Recommended** - Standard OTel format with rich LLM metadata | βœ… Released | +| `vercel` | Vercel AI SDK format | For Vercel AI SDK compatibility | πŸ”„ Coming soon | +| `open_inference` | Arize OpenInference format | For Arize Phoenix and OpenInference tools | πŸ”„ Coming soon | + +--- + +## Configuration + +### Required Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `collector_url` | `string` | βœ… Yes | OTLP collector endpoint URL | +| `trace_type` | `string` | βœ… Yes | One of: `genai_extension`, `vercel`, `open_inference` | +| `protocol` | `string` | βœ… Yes | Transport protocol: `http` or `grpc` | +| `headers` | `object` | ❌ No | Custom headers for authentication (supports `env.VAR_NAME`) | + +### Environment Variable Substitution + +Headers support environment variable substitution using the `env.` prefix: + +```json +{ + "headers": { + "Authorization": "env.OTEL_API_KEY", + "X-Custom-Header": "env.CUSTOM_VALUE" + } +} +``` + +--- + +## Setup + + + +![Otel UI setup](../../media/otel-ui-setup.png) + + + +```go +package main + +import ( + "context" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/pricing" + otel "github.com/maximhq/bifrost/plugins/otel" +) + +func main() { + ctx := context.Background() + logger := schemas.NewLogger() + + // Initialize pricing manager (required for cost calculation) + pricingManager := pricing.NewPricingManager(logger) + + // Initialize OTel plugin + otelPlugin, err := otel.Init(ctx, &otel.Config{ + CollectorURL: "http://localhost:4318", + TraceType: otel.TraceTypeGenAIExtension, + Protocol: otel.ProtocolHTTP, + Headers: map[string]string{ + "Authorization": "env.OTEL_API_KEY", + }, + }, logger, pricingManager) + if err != nil { + panic(err) + } + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &yourAccount, + Plugins: []schemas.Plugin{otelPlugin}, + }) + if err != nil { + panic(err) + } + defer client.Shutdown() + + // All requests are now traced to OTel collector +} +``` + + + + +For Gateway mode, configure via `config.json`: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "http://localhost:4318", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "Authorization": "env.OTEL_API_KEY" + } + } + } + ] +} +``` + + + + +--- + +## Quick Start with Docker + +Get started quickly with a complete observability stack using the included Docker Compose configuration: + +```yml +services: + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: otel-collector + command: ["--config=/etc/otelcol/config.yaml"] + configs: + - source: otel-collector-config + target: /etc/otelcol/config.yaml + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "8888:8888" # Collector /metrics + - "9464:9464" # Prometheus scrape endpoint + - "13133:13133" # Health check + - "1777:1777" # pprof + - "55679:55679" # zpages + restart: unless-stopped + depends_on: + - tempo + + tempo: + image: grafana/tempo:latest + container_name: tempo + command: [ "-config.file=/etc/tempo.yaml" ] + configs: + - source: tempo-config + target: /etc/tempo.yaml + ports: + - "3200:3200" # tempo HTTP API + expose: + - "4317" # OTLP gRPC (internal) + volumes: + - tempo-data:/var/tempo + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + depends_on: + - otel-collector + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + - "--web.enable-remote-write-receiver" + ports: + - "9090:9090" + volumes: + - prometheus-data:/prometheus + configs: + - source: prometheus-config + target: /etc/prometheus/prometheus.yml + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + depends_on: + - prometheus + - tempo + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "grafana-pyroscope-app,grafana-exploretraces-app,grafana-metricsdrilldown-app" + GF_PLUGINS_ENABLE_ALPHA: "true" + GF_INSTALL_PLUGINS: "" + ports: + - "4000:3000" + volumes: + - grafana-data:/var/lib/grafana + configs: + - source: grafana-datasources + target: /etc/grafana/provisioning/datasources/datasources.yml + restart: unless-stopped + +configs: + otel-collector-config: + content: | + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + + processors: + batch: + + exporters: + prometheus: + endpoint: 0.0.0.0:9464 + namespace: otel + const_labels: + source: otelcol + + otlp/tempo: + endpoint: tempo:4317 + tls: + insecure: true + + debug: + verbosity: detailed + + extensions: + health_check: + endpoint: 0.0.0.0:13133 + pprof: + endpoint: 0.0.0.0:1777 + zpages: + endpoint: 0.0.0.0:55679 + + service: + extensions: [health_check, pprof, zpages] + telemetry: + logs: + level: debug + metrics: + level: detailed + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlp/tempo] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug, prometheus] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] + + tempo-config: + content: | + server: + http_listen_port: 3200 + log_level: info + + distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + + ingester: + max_block_duration: 5m + trace_idle_period: 10s + + compactor: + compaction: + block_retention: 1h + + storage: + trace: + backend: local + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + + metrics_generator: + registry: + external_labels: + source: tempo + storage: + path: /var/tempo/generator/wal + remote_write: + - url: http://prometheus:9090/api/v1/write + + prometheus-config: + content: | + global: + scrape_interval: 15s + scrape_configs: + - job_name: "otelcol-internal" + static_configs: + - targets: ["otel-collector:8888"] + - job_name: "otelcol-exporter" + static_configs: + - targets: ["otel-collector:9464"] + - job_name: "tempo" + static_configs: + - targets: ["tempo:3200"] + + grafana-datasources: + content: | + apiVersion: 1 + datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + orgId: 1 + url: http://prometheus:9090 + isDefault: true + editable: true + - name: Tempo + uid: tempo + type: tempo + access: proxy + orgId: 1 + url: http://tempo:3200 + editable: true + jsonData: + tracesToMetrics: + datasourceUid: prometheus + nodeGraph: + enabled: true + +volumes: + prometheus-data: + grafana-data: + tempo-data: +``` + +This launches: +- **OTel Collector** - Receives traces on ports 4317 (gRPC) and 4318 (HTTP) +- **Tempo** - Distributed tracing backend +- **Prometheus** - Metrics collection +- **Grafana** - Visualization dashboard + +Access Grafana at `http://localhost:3000` (default credentials: admin/admin) + +![Grafana Traces](../../media/grafana-otel-traces.png) + +--- + +## Popular Platform Integrations + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "https://otlp-gateway-prod-us-central-0.grafana.net/otlp", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "Authorization": "env.GRAFANA_CLOUD_API_KEY" + } + } + } + ] +} +``` + +Set environment variable: +```bash +export GRAFANA_CLOUD_API_KEY="Basic " +``` + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "https://trace.agent.datadoghq.com", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "DD-API-KEY": "env.DATADOG_API_KEY" + } + } + } + ] +} +``` + +Set environment variable: +```bash +export DATADOG_API_KEY="your-datadog-api-key" +``` + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "https://otlp.nr-data.net:4318", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "api-key": "env.NEW_RELIC_LICENSE_KEY" + } + } + } + ] +} +``` + +Set environment variable: +```bash +export NEW_RELIC_LICENSE_KEY="your-license-key" +``` + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "https://api.honeycomb.io", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "x-honeycomb-team": "env.HONEYCOMB_API_KEY", + "x-honeycomb-dataset": "bifrost-traces" + } + } + } + ] +} +``` + +Set environment variable: +```bash +export HONEYCOMB_API_KEY="your-api-key" +``` + + + + +Use the included Docker Compose stack or point to your own collector: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "http://your-collector:4318", + "trace_type": "genai_extension", + "protocol": "http" + } + } + ] +} +``` + + + + +--- + +## Captured Data + +Each trace includes comprehensive LLM operation metadata following OpenTelemetry semantic conventions: + +### Span Attributes + +- **Span Name**: Based on request type (`gen_ai.chat`, `gen_ai.text`, `gen_ai.embedding`, etc.) +- **Service Info**: `service.name=bifrost`, `service.version` +- **Provider & Model**: `gen_ai.provider.name`, `gen_ai.request.model` + +### Request Parameters + +- Temperature, max_tokens, top_p, stop sequences +- Presence/frequency penalties +- Tool configurations and parallel tool calls +- Custom parameters via `ExtraParams` + +### Input/Output Data + +- Complete chat history with role-based messages +- Prompt text for completions +- Response content with role attribution +- Tool calls and results + +### Performance Metrics + +- Token usage (prompt, completion, total) +- Cost calculations in dollars +- Latency and timing (start/end timestamps) +- Error details with status codes + +### Example Span + +```json +{ + "name": "gen_ai.chat", + "attributes": { + "gen_ai.provider.name": "openai", + "gen_ai.request.model": "gpt-4", + "gen_ai.request.temperature": 0.7, + "gen_ai.request.max_tokens": 1000, + "gen_ai.usage.prompt_tokens": 45, + "gen_ai.usage.completion_tokens": 128, + "gen_ai.usage.total_tokens": 173, + "gen_ai.usage.cost": 0.0052 + } +} +``` + +![Span Details](../../media/grafana-otel-traces.png) + +--- + +## Supported Request Types + +The OTel plugin captures all Bifrost request types: + +- **Chat Completion** (streaming and non-streaming) β†’ `gen_ai.chat` +- **Text Completion** (streaming and non-streaming) β†’ `gen_ai.text` +- **Embeddings** β†’ `gen_ai.embedding` +- **Speech Generation** (streaming and non-streaming) β†’ `gen_ai.speech` +- **Transcription** (streaming and non-streaming) β†’ `gen_ai.transcription` +- **Responses API** β†’ `gen_ai.responses` + +--- + +## Protocol Support + +### HTTP (OTLP/HTTP) + +Uses HTTP/1.1 or HTTP/2 with JSON or Protobuf encoding: + +```json +{ + "collector_url": "http://localhost:4318", + "protocol": "http" +} +``` + +Default port: **4318** + +### gRPC (OTLP/gRPC) + +Uses gRPC with Protobuf encoding for lower latency: + +```json +{ + "collector_url": "http://localhost:4317", + "protocol": "grpc" +} +``` + +Default port: **4317** + +--- + +## Advanced Features + +### Automatic Span Management + +- Spans are tracked with a **20-minute TTL** using an efficient sync.Map implementation +- Automatic cleanup prevents memory leaks for long-running processes +- Handles streaming requests with accumulator for chunked responses + +### Async Emission + +All span emissions happen asynchronously in background goroutines: + +```go +// Zero impact on request latency +go func() { + p.client.Emit(ctx, spans) +}() +``` + +### Streaming Support + +The plugin accumulates streaming chunks and emits a single complete span when the stream finishes, providing accurate token counts and costs. + +### Environment Variable Security + +Sensitive credentials never appear in config files: + +```json +{ + "headers": { + "Authorization": "env.OTEL_API_KEY" + } +} +``` + +The plugin reads `OTEL_API_KEY` from the environment at runtime. + +--- + +## When to Use + +### OTel Plugin + +Choose the OTel plugin when you: + +- Have existing OpenTelemetry infrastructure +- Need to correlate LLM traces with application traces +- Require compliance with enterprise observability standards +- Want vendor flexibility (switch backends without code changes) +- Need multi-service distributed tracing + +### vs. Built-in Observability + +Use [Built-in Observability](./default) for: + +- Local development and testing +- Simple self-hosted deployments +- No external dependencies +- Direct database access to logs + +### vs. Maxim Plugin + +Use the [Maxim Plugin](./maxim) for: + +- Advanced LLM evaluation and testing +- Prompt engineering and experimentation +- Team collaboration and governance +- Production monitoring with alerts +- Dataset management and curation + +--- + +## Troubleshooting + +### Connection Issues + +Verify collector is reachable: + +```bash +# Test HTTP endpoint +curl -v http://localhost:4318/v1/traces + +# Test gRPC endpoint (requires grpcurl) +grpcurl -plaintext localhost:4317 list +``` + +### Missing Traces + +Check Bifrost logs for emission errors: + +```bash +# Enable debug logging +bifrost-http --log-level debug +``` + +### Authentication Failures + +Verify environment variables are set: + +```bash +echo $OTEL_API_KEY +``` + +--- + +## Next Steps + +- **[Built-in Observability](./default)** - Local logging for development +- **[Maxim Plugin](./maxim)** - Advanced LLM evaluation and monitoring +- **[Telemetry](../telemetry)** - Prometheus metrics and dashboards diff --git a/docs/features/plugins/circuit-breaker.mdx b/docs/features/plugins/circuit-breaker.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/features/plugins/jsonparser.mdx b/docs/features/plugins/jsonparser.mdx new file mode 100644 index 000000000..1d73e15fe --- /dev/null +++ b/docs/features/plugins/jsonparser.mdx @@ -0,0 +1,306 @@ +--- +title: JSON Parser +description: A simple Bifrost plugin that handles partial JSON chunks in streaming responses by making them valid JSON objects. +icon: "code-branch" +--- + +## Overview + +When using AI providers that stream JSON responses, the individual chunks often contain incomplete JSON that cannot be parsed directly. This plugin automatically detects and fixes partial JSON chunks by adding the necessary closing braces, brackets, and quotes to make them valid JSON. + +## Features + +- **Automatic JSON Completion**: Detects partial JSON and adds missing closing characters +- **Streaming Only**: Processes only streaming responses (non-streaming responses are ignored) +- **Flexible Usage Modes**: Supports two usage types for different deployment scenarios +- **Safe Fallback**: Returns original content if JSON cannot be fixed +- **Memory Leak Prevention**: Automatic cleanup of stale accumulated content with configurable intervals +- **Zero Dependencies**: Only depends on Go's standard library + +## Usage + +### Usage Types + +The plugin supports two usage types: + +1. **AllRequests**: Processes all streaming responses automatically +2. **PerRequest**: Processes only when explicitly enabled via request context + + +```go +package main + +import ( + "time" + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/plugins/jsonparser" +) + +func main() { + // Create the JSON parser plugin for all requests + jsonPlugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{ + Usage: jsonparser.AllRequests, + CleanupInterval: 2 * time.Minute, // Cleanup every 2 minutes + MaxAge: 10 * time.Minute, // Remove entries older than 10 minutes + }) + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &MyAccount{}, + Plugins: []schemas.Plugin{ + jsonPlugin, + }, + }) + + if err != nil { + panic(err) + } + + // Use the client normally - JSON parsing happens automatically + // in the PostHook for all streaming responses +} +``` + +### PerRequest Mode + +```go +package main + +import ( + "context" + "time" + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/plugins/jsonparser" +) + +func main() { + // Create the JSON parser plugin for per-request control + jsonPlugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{ + Usage: jsonparser.PerRequest, + CleanupInterval: 2 * time.Minute, // Cleanup every 2 minutes + MaxAge: 10 * time.Minute, // Remove entries older than 10 minutes + }) + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &MyAccount{}, + Plugins: []schemas.Plugin{ + jsonPlugin, + }, + }) + + if err != nil { + panic(err) + } + + ctx := context.WithValue(context.Background(), jsonparser.EnableStreamingJSONParser, true) + + // Enable JSON parsing for specific requests + stream, bifrostErr := client.ChatCompletionStreamRequest(ctx, request) + if bifrostErr != nil { + // handle error + } + for chunk := range stream { + _ = chunk // handle each streaming chunk + } +} +``` + +### Configuration + +```go +// Custom cleanup configuration +plugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{ + Usage: jsonparser.AllRequests, + CleanupInterval: 2 * time.Minute, // Cleanup every 2 minutes + MaxAge: 10 * time.Minute, // Remove entries older than 10 minutes +}) +``` + +#### Default Values + +- **CleanupInterval**: 5 minutes (how often to run cleanup) +- **MaxAge**: 30 minutes (how old entries can be before cleanup) +- **Usage**: Must be specified (AllRequests or PerRequest) + +### Context Key for PerRequest Mode + +When using `PerRequest` mode, the plugin checks for the context key `jsonparser.EnableStreamingJSONParser` with a boolean value: + +- `true`: Enable JSON parsing for this request +- `false`: Disable JSON parsing for this request +- Key not present: Disable JSON parsing for this request + +**Example:** + +```go +import ( + "context" + + "github.com/maximhq/bifrost/plugins/jsonparser" +) + +// Enable JSON parsing for this request +ctx := context.WithValue(context.Background(), jsonparser.EnableStreamingJSONParser, true) + +// Disable JSON parsing for this request +ctx := context.WithValue(context.Background(), jsonparser.EnableStreamingJSONParser, false) + +// No context key - JSON parsing disabled (default behavior) +ctx := context.Background() +``` + +## How It Works + +The plugin implements an optimized `parsePartialJSON` function with the following steps: + +1. **Usage Check**: Determines if processing should occur based on usage type and context +2. **Validates Input**: First tries to parse the string as valid JSON +3. **Character Analysis**: If invalid, processes the string character-by-character to track: + - String boundaries (inside/outside quotes) + - Escape sequences + - Opening/closing braces and brackets +4. **Auto-Completion**: Adds missing closing characters in the correct order +5. **Validation**: Verifies the completed JSON is valid +6. **Fallback**: Returns original content if completion fails + +### Memory Management + +The plugin automatically manages memory by: + +1. **Accumulating Content**: Stores partial JSON chunks with timestamps for each request +2. **Periodic Cleanup**: Runs a background goroutine that removes stale entries based on `MaxAge` +3. **Request Completion**: Automatically clears accumulated content when requests complete successfully +4. **Configurable Intervals**: Allows customization of cleanup frequency and retention periods + +### Real-Life Streaming Example + +Here's a practical example showing how the JSON parser plugin fixes broken JSON chunks in streaming responses: + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "time" + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/plugins/jsonparser" +) + +func main() { + // Create JSON parser plugin + jsonPlugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{ + Usage: jsonparser.AllRequests, + CleanupInterval: 2 * time.Minute, + MaxAge: 10 * time.Minute, + }) + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &MyAccount{}, + Plugins: []schemas.Plugin{jsonPlugin}, + }) + if err != nil { + panic(err) + } + defer client.Shutdown() + + // Request structured JSON response + request := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Return user profile as JSON: {\"name\": \"John Doe\", \"email\": \"john@example.com\"}"), + }, + }, + }, + } + + // Stream the response + stream, bifrostErr := client.ChatCompletionStreamRequest(context.Background(), request) + if bifrostErr != nil { + panic(bifrostErr) + } + + fmt.Println("Streaming JSON response:") + for chunk := range stream { + if chunk.BifrostChatResponse != nil && len(chunk.BifrostChatResponse.Choices) > 0 { + choice := chunk.BifrostChatResponse.Choices[0] + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil { + content := *choice.ChatStreamResponseChoice.Delta.Content + fmt.Printf("Chunk: %s\n", content) + + // With JSON parser, you can parse each chunk immediately + var jsonData map[string]interface{} + if err := json.Unmarshal([]byte(content), &jsonData); err == nil { + fmt.Printf("βœ… Valid JSON parsed successfully\n") + } else { + fmt.Printf("❌ Invalid JSON: %v\n", err) + } + } + } + } +} +``` + +**Without JSON Parser** (raw streaming chunks): +``` +Chunk 1: `{` ❌ Invalid JSON +Chunk 2: `{"name"` ❌ Invalid JSON +Chunk 3: `{"name": "John"` ❌ Invalid JSON +Chunk 4: `{"name": "John Doe"` ❌ Invalid JSON +``` + +**With JSON Parser** (processed chunks): +``` +Chunk 1: `{}` βœ… Valid JSON +Chunk 2: `{"name": ""}` βœ… Valid JSON +Chunk 3: `{"name": "John"}` βœ… Valid JSON +Chunk 4: `{"name": "John Doe"}` βœ… Valid JSON +``` + +### Use Cases + +- **Function Calling**: Stream tool call arguments as valid JSON throughout the response +- **Structured Data**: Stream complex JSON objects (user profiles, product catalogs) progressively +- **Real-time Parsing**: Enable client-side JSON parsing at each streaming step without waiting for completion +- **API Integration**: Forward streaming JSON to downstream services that expect valid JSON +- **Live Updates**: Update UI components with valid JSON data as it streams in + +### Example Transformations + +| Input | Output | +|-------|--------| +| `{"name": "John"` | `{"name": "John"}` | +| `["apple", "banana"` | `["apple", "banana"]` | +| `{"user": {"name": "John"` | `{"user": {"name": "John"}}` | +| `{"message": "Hello\nWorld"` | `{"message": "Hello\nWorld"}` | +| `""` (empty string) | `{}` | +| `" "` (whitespace only) | `{}` | + +## Testing + +Run the test suite: + +```bash +cd plugins/jsonparser +go test -v +``` + +The tests cover: +- Plugin interface compliance +- Both usage types (AllRequests and PerRequest) +- Context-based enabling/disabling +- Streaming responses only (non-streaming responses are ignored) +- Various JSON completion scenarios +- Edge cases and error conditions +- Memory cleanup functionality with real and simulated requests +- Configuration options and default values \ No newline at end of file diff --git a/docs/features/plugins/mocker.mdx b/docs/features/plugins/mocker.mdx new file mode 100644 index 000000000..ebbc949e4 --- /dev/null +++ b/docs/features/plugins/mocker.mdx @@ -0,0 +1,566 @@ +--- +title: "Mocker" +description: "Mock AI provider responses for testing, development, and simulation purposes." +icon: "mask" +--- + +## Quick Start + +### Minimal Configuration + +The simplest way to use the Mocker plugin is with no configuration - it will create a default catch-all rule: + +```go +package main + +import ( + "context" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + mocker "github.com/maximhq/bifrost/plugins/mocker" +) + +func main() { + // Create plugin with minimal config + plugin, err := mocker.NewMockerPlugin(mocker.MockerConfig{ + Enabled: true, // Default rule will be created automatically + }) + if err != nil { + panic(err) + } + + // Initialize Bifrost with the plugin + client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &yourAccount, + Plugins: []schemas.Plugin{plugin}, + }) + if err != nil { + panic(err) + } + defer client.Shutdown() + + // All chat and responses requests will now return: "This is a mock response from the Mocker plugin" + + // Chat completion request + chatResponse, _ := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello!"), + }, + }, + }, + }) + + // Responses request + responsesResponse, _ := client.ResponsesRequest(context.Background(), &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + Input: []schemas.ResponsesMessage{ + { + Role: bifrost.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: bifrost.Ptr("Hello!"), + }, + }, + }, + }) +} +``` + +### Custom Response + +```go +plugin, err := mocker.NewMockerPlugin(mocker.MockerConfig{ + Enabled: true, + Rules: []mocker.MockRule{ + { + Name: "openai-mock", + Enabled: true, + Probability: 1.0, // Always trigger + Conditions: mocker.Conditions{ + Providers: []string{"openai"}, + }, + Responses: []mocker.Response{ + { + Type: mocker.ResponseTypeSuccess, + Content: &mocker.SuccessResponse{ + Message: "Hello! This is a custom mock response for OpenAI.", + Usage: &mocker.Usage{ + PromptTokens: 15, + CompletionTokens: 25, + TotalTokens: 40, + }, + }, + }, + }, + }, + }, +}) +``` + +### Responses Request Example + +The mocker plugin automatically handles both chat completion and responses requests with the same configuration: + +```go +// This rule will work for both ChatCompletionRequest and ResponsesRequest +{ + Name: "universal-mock", + Enabled: true, + Probability: 1.0, + Conditions: mocker.Conditions{ + MessageRegex: stringPtr("(?i).*hello.*"), + }, + Responses: []mocker.Response{ + { + Type: mocker.ResponseTypeSuccess, + Content: &mocker.SuccessResponse{ + Message: "Hello! I'm a mock response that works for both request types.", + }, + }, + }, +} +``` + +## Installation + +Add the plugin to your project: + + ```bash + go get github.com/maximhq/bifrost/plugins/mocker + ``` + +Import in your code: + + ```go + import mocker "github.com/maximhq/bifrost/plugins/mocker" + ``` + +## Basic Usage + +### Creating the Plugin + +```go +config := mocker.MockerConfig{ + Enabled: true, + DefaultBehavior: mocker.DefaultBehaviorPassthrough, // "passthrough", "success", "error" + Rules: []mocker.MockRule{ + // Your rules here + }, +} + +plugin, err := mocker.NewMockerPlugin(config) +if err != nil { + log.Fatal(err) +} +``` + +### Adding to Bifrost + +```go +client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &yourAccount, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo), +}) +``` + +### Disabling the Plugin + +```go +config := mocker.MockerConfig{ + Enabled: false, // All requests pass through to real providers +} +``` + +## Supported Request Types + +The Mocker plugin supports the following Bifrost request types: + +- **Chat Completion Requests** (`ChatCompletionRequest`) - Standard chat-based interactions +- **Responses Requests** (`ResponsesRequest`) - OpenAI-compatible responses API format +- **Skip Context Key** - Use `"skip-mocker"` context key to bypass mocking per request + +### Skip Mocker for Specific Requests + +You can skip the mocker plugin for specific requests by adding a context key: + +```go +import "github.com/maximhq/bifrost/core/schemas" + +// Create context that skips mocker +ctx := context.WithValue(context.Background(), + schemas.BifrostContextKey("skip-mocker"), true) + +// This request will bypass the mocker and go to the real provider +response, err := client.ChatCompletionRequest(ctx, request) +``` + +## Key Features + +### Template Variables + +Create dynamic responses using templates: + +```go +Response{ + Type: mocker.ResponseTypeSuccess, + Content: &mocker.SuccessResponse{ + MessageTemplate: stringPtr("Hello from {{provider}} using model {{model}}!"), + }, +} +``` + +**Available Variables:** +- `{{provider}}` - Provider name (e.g., "openai", "anthropic") +- `{{model}}` - Model name (e.g., "gpt-4", "claude-3") +- `{{faker.*}}` - Fake data generation (see Configuration Reference) + +### Weighted Response Selection + +Configure multiple responses with different probabilities: + +```go +Responses: []mocker.Response{ + { + Type: mocker.ResponseTypeSuccess, + Weight: 0.8, // 80% chance + Content: &mocker.SuccessResponse{ + Message: "Success response", + }, + }, + { + Type: mocker.ResponseTypeError, + Weight: 0.2, // 20% chance + Error: &mocker.ErrorResponse{ + Message: "Rate limit exceeded", + Type: stringPtr("rate_limit"), + Code: stringPtr("429"), + }, + }, +} +``` + +### Latency Simulation + +Add realistic delays to responses: + +```go +// Fixed latency +Latency: &mocker.Latency{ + Type: mocker.LatencyTypeFixed, + Min: 250 * time.Millisecond, +} + +// Variable latency +Latency: &mocker.Latency{ + Type: mocker.LatencyTypeUniform, + Min: 100 * time.Millisecond, + Max: 500 * time.Millisecond, +} +``` + +### Advanced Matching + +#### Regex Message Matching +```go +Conditions: mocker.Conditions{ + MessageRegex: stringPtr(`(?i).*support.*|.*help.*`), +} +``` + +#### Request Size Filtering +```go +Conditions: mocker.Conditions{ + RequestSize: &mocker.SizeRange{ + Min: 100, // bytes + Max: 1000, // bytes + }, +} +``` + +### Faker Data Generation + +Create realistic test data using faker variables: + +```go +{ + Name: "user-profile-example", + Responses: []mocker.Response{ + { + Type: mocker.ResponseTypeSuccess, + Content: &mocker.SuccessResponse{ + MessageTemplate: stringPtr(`User Profile: +- Name: {{faker.name}} +- Email: {{faker.email}} +- Company: {{faker.company}} +- Address: {{faker.address}}, {{faker.city}} +- Phone: {{faker.phone}} +- User ID: {{faker.uuid}} +- Join Date: {{faker.date}} +- Premium Account: {{faker.boolean}}`), + }, + }, + }, +} +``` + +### Statistics and Monitoring + +Get runtime statistics for monitoring: + +```go +stats := plugin.GetStatistics() +fmt.Printf("Plugin enabled: %v\n", stats.Enabled) +fmt.Printf("Total requests: %d\n", stats.TotalRequests) +fmt.Printf("Mocked requests: %d\n", stats.MockedRequests) + +// Rule-specific stats +for ruleName, ruleStats := range stats.Rules { + fmt.Printf("Rule %s: %d triggers\n", ruleName, ruleStats.Triggers) +} +``` + +## Configuration Reference + +### MockerConfig + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `Enabled` | `bool` | `false` | Enable/disable the entire plugin | +| `DefaultBehavior` | `string` | `"passthrough"` | Action when no rules match: `"passthrough"`, `"success"`, `"error"` | +| `GlobalLatency` | `*Latency` | `nil` | Global latency applied to all rules | +| `Rules` | `[]MockRule` | `[]` | List of mock rules evaluated in priority order | + +### MockRule + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `Name` | `string` | - | Unique rule name for identification | +| `Enabled` | `bool` | `true` | Enable/disable this specific rule | +| `Priority` | `int` | `0` | Higher numbers = higher priority | +| `Probability` | `float64` | `1.0` | Activation probability (0.0=never, 1.0=always) | +| `Conditions` | `Conditions` | `{}` | Matching conditions (empty = match all) | +| `Responses` | `[]Response` | - | Possible responses (weighted random selection) | +| `Latency` | `*Latency` | `nil` | Rule-specific latency override | + +### Conditions + +| Field | Type | Description | +|-------|------|-------------| +| `Providers` | `[]string` | Match specific providers: `["openai", "anthropic"]` | +| `Models` | `[]string` | Match specific models: `["gpt-4", "claude-3"]` | +| `MessageRegex` | `*string` | Regex pattern to match message content | +| `RequestSize` | `*SizeRange` | Request size constraints in bytes | + +### Response + +| Field | Type | Description | +|-------|------|-------------| +| `Type` | `string` | Response type: `"success"` or `"error"` | +| `Weight` | `float64` | Weight for random selection (default: 1.0) | +| `Content` | `*SuccessResponse` | Required if `Type="success"` | +| `Error` | `*ErrorResponse` | Required if `Type="error"` | +| `AllowFallbacks` | `*bool` | Control fallback behavior (`nil`=allow, `false`=block) | + +### SuccessResponse + +| Field | Type | Description | +|-------|------|-------------| +| `Message` | `string` | Static response message | +| `MessageTemplate` | `*string` | Template with variables: `{{provider}}`, `{{model}}`, `{{faker.*}}` | +| `Model` | `*string` | Override model name in response | +| `Usage` | `*Usage` | Token usage information | +| `FinishReason` | `*string` | Completion reason (default: `"stop"`) | +| `CustomFields` | `map[string]interface{}` | Additional metadata fields | + +### ErrorResponse + +| Field | Type | Description | +|-------|------|-------------| +| `Message` | `string` | Error message to return | +| `Type` | `*string` | Error type (e.g., `"rate_limit"`, `"auth_error"`) | +| `Code` | `*string` | Error code (e.g., `"429"`, `"401"`) | +| `StatusCode` | `*int` | HTTP status code | + +### Latency + +| Field | Type | Description | +|-------|------|-------------| +| `Type` | `string` | Latency type: `"fixed"` or `"uniform"` | +| `Min` | `time.Duration` | Minimum/exact latency (use `time.Millisecond`) | +| `Max` | `time.Duration` | Maximum latency (required for `"uniform"`) | + +**Important**: Use Go's `time.Duration` constants: +- βœ… Correct: `100 * time.Millisecond` +- ❌ Wrong: `100` (nanoseconds, barely noticeable) + +### Faker Variables + +#### Personal Information +- `{{faker.name}}` - Full name +- `{{faker.first_name}}` - First name only +- `{{faker.last_name}}` - Last name only +- `{{faker.email}}` - Email address +- `{{faker.phone}}` - Phone number + +#### Location +- `{{faker.address}}` - Street address +- `{{faker.city}}` - City name +- `{{faker.state}}` - State/province +- `{{faker.zip_code}}` - Postal code + +#### Business +- `{{faker.company}}` - Company name +- `{{faker.job_title}}` - Job title + +#### Text and Data +- `{{faker.lorem_ipsum}}` - Lorem ipsum text +- `{{faker.lorem_ipsum:10}}` - Lorem ipsum with 10 words +- `{{faker.uuid}}` - UUID v4 +- `{{faker.hex_color}}` - Hex color code + +#### Numbers and Dates +- `{{faker.integer}}` - Random integer (1-100) +- `{{faker.integer:10,50}}` - Random integer between 10-50 +- `{{faker.float}}` - Random float (0-100, 2 decimals) +- `{{faker.float:1,10}}` - Random float between 1-10 +- `{{faker.boolean}}` - Random boolean +- `{{faker.date}}` - Date (YYYY-MM-DD format) +- `{{faker.datetime}}` - Datetime (YYYY-MM-DD HH:MM:SS format) + +## Best Practices + +### Rule Organization + +```go +// Use priority to control rule evaluation order +rules := []mocker.MockRule{ + {Name: "specific-error", Priority: 100, Conditions: /* specific */}, + {Name: "general-success", Priority: 50, Conditions: /* general */}, + {Name: "catch-all", Priority: 0, Conditions: /* empty */}, +} +``` + +### Development vs Production + +```go +// Development: High mock rate +config := mocker.MockerConfig{ + Enabled: true, + Rules: []mocker.MockRule{ + {Probability: 1.0}, // Always mock + }, +} + +// Production: Occasional testing +config := mocker.MockerConfig{ + Enabled: true, + Rules: []mocker.MockRule{ + {Probability: 0.1}, // 10% mock rate + }, +} +``` + +### Performance Considerations + +- Place specific conditions before general ones (higher priority) +- Use simple string matching over complex regex when possible +- Keep response templates reasonably sized +- Consider disabling debug logging in production + +### Testing Your Configuration + +```go +func validateMockerConfig(config mocker.MockerConfig) error { + _, err := mocker.NewMockerPlugin(config) + return err +} + +// Test before deployment +if err := validateMockerConfig(yourConfig); err != nil { + log.Fatalf("Invalid mocker configuration: %v", err) +} +``` + +## Common Issues + +### Plugin Not Triggering + +1. Check if plugin is enabled: `Enabled: true` +2. Verify rule is enabled: `rule.Enabled: true` +3. Check probability: `Probability: 1.0` for testing +4. Verify conditions match your request + +### Latency Not Working + +Use `time.Duration` constants, not raw integers: + +```go +// ❌ Wrong: 100 nanoseconds (barely noticeable) +Min: 100 + +// βœ… Correct: 100 milliseconds +Min: 100 * time.Millisecond +``` + +### Regex Not Matching + +Test your regex pattern and ensure proper escaping: + +```go +// Case-insensitive matching +MessageRegex: stringPtr(`(?i).*help.*`) + +// Escape special characters +MessageRegex: stringPtr(`\$\d+\.\d+`) // Match $12.34 +``` + +### Controlling Fallbacks + +```go +Response{ + Type: mocker.ResponseTypeError, + AllowFallbacks: boolPtr(false), // Block fallbacks + Error: &mocker.ErrorResponse{ + Message: "Authentication failed", + }, +} +``` + +### Skip Mocker Not Working + +Ensure you're using the correct context key format: + +```go +// βœ… Correct +ctx := context.WithValue(context.Background(), + schemas.BifrostContextKey("skip-mocker"), true) + +// ❌ Wrong +ctx := context.WithValue(context.Background(), "skip-mocker", true) +``` + +### Responses Request Issues + +If responses requests aren't being mocked: + +1. Verify the plugin supports `ResponsesRequest` (version 1.2.13+) +2. Check that your regex patterns match the message content +3. Ensure the request type is `schemas.ResponsesRequest` + +### Debug Mode + +Enable debug logging to troubleshoot: + +```go +client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), +}) +``` diff --git a/docs/features/semantic-caching.mdx b/docs/features/semantic-caching.mdx new file mode 100644 index 000000000..d6f16cee1 --- /dev/null +++ b/docs/features/semantic-caching.mdx @@ -0,0 +1,519 @@ +--- +title: "Semantic Caching" +description: "Intelligent response caching based on semantic similarity. Reduce costs and latency by serving cached responses for semantically similar requests." +icon: "database" +--- + +## Overview + +Semantic caching uses vector similarity search to intelligently cache AI responses, serving cached results for semantically similar requests even when the exact wording differs. This dramatically reduces API costs and latency for repeated or similar queries. + +**Key Benefits:** +- **Cost Reduction**: Avoid expensive LLM API calls for similar requests +- **Improved Performance**: Sub-millisecond cache retrieval vs multi-second API calls +- **Intelligent Matching**: Semantic similarity beyond exact text matching +- **Streaming Support**: Full streaming response caching with proper chunk ordering + +--- + +## Core Features + +- **Dual-Layer Caching**: Exact hash matching + semantic similarity search (customizable threshold) +- **Vector-Powered Intelligence**: Uses embeddings to find semantically similar requests +- **Dynamic Configuration**: Per-request TTL and threshold overrides via headers/context +- **Model/Provider Isolation**: Separate caching per model and provider combination + +--- + +## Vector Store Setup + + + + + +```go +import ( + "context" + "github.com/maximhq/bifrost/framework/vectorstore" + "github.com/maximhq/bifrost/core/schemas" +) + +// Configure vector store +vectorConfig := &vectorstore.Config{ + Enabled: true, + Type: vectorstore.VectorStoreTypeWeaviate, + Config: vectorstore.WeaviateConfig{ + Scheme: "http", + Host: "localhost:8080", + }, +} + +// Create vector store +store, err := vectorstore.NewVectorStore(context.Background(), vectorConfig, logger) +if err != nil { + log.Fatal("Failed to create vector store:", err) +} +``` + + + + + +```json +{ + "vector_store": { + "enabled": true, + "type": "weaviate", + "config": { + "host": "localhost:8080", + "scheme": "http", + } + } +} +``` + +**For Weaviate Cloud:** +```json +{ + "vector_store": { + "enabled": true, + "type": "weaviate", + "config": { + "host": "your-cluster.weaviate.network", + "scheme": "https", + "api_key": "your-weaviate-api-key" + } + } +} +``` + + + + + +--- + +## Semantic Cache Configuration + + + + + +```go +import ( + "github.com/maximhq/bifrost/plugins/semanticcache" + "github.com/maximhq/bifrost/core/schemas" +) + +// Configure semantic cache plugin +cacheConfig := semanticcache.Config{ + // Embedding model configuration (Required) + Provider: schemas.OpenAI, + Keys: []schemas.Key{{Value: "sk-..."}}, + EmbeddingModel: "text-embedding-3-small", + Dimension: 1536, + + // Cache behavior + TTL: 5 * time.Minute, // Time to live for cached responses (default: 5 minutes) + Threshold: 0.8, // Similarity threshold for cache lookup (default: 0.8) + CleanUpOnShutdown: true, // Clean up cache on shutdown (default: false) + + // Conversation behavior + ConversationHistoryThreshold: 5, // Skip caching if conversation has > N messages (default: 3) + ExcludeSystemPrompt: bifrost.Ptr(false), // Exclude system messages from cache key (default: false) + + // Advanced options + CacheByModel: bifrost.Ptr(true), // Include model in cache key (default: true) + CacheByProvider: bifrost.Ptr(true), // Include provider in cache key (default: true) +} + +// Create plugin +plugin, err := semanticcache.Init(context.Background(), cacheConfig, logger, store) +if err != nil { + log.Fatal("Failed to create semantic cache plugin:", err) +} + +// Add to Bifrost config +bifrostConfig := schemas.BifrostConfig{ + Plugins: []schemas.Plugin{plugin}, + // ... other config +} +``` + + + + + +![Semantic Cache Plugin Configuration](../media/ui-semantic-cache-config.png) + +**Note**: Make sure you have a vector store setup (using `config.json`) before configuring the semantic cache plugin. + +1. **Navigate to Settings** + - Open Bifrost UI at `http://localhost:8080` + - Go to Settings. + +2. **Configure Semantic Cache Plugin** + +- Toggle the plugin switch to enable it, and fill in the required fields. + +**Required Fields:** +- **Provider**: The provider to use for caching. +- **Embedding Model**: The embedding model to use for caching. + +**Note**: Changes will need a restart of the Bifrost server to take effect, because the plugin is loaded on startup only. + + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "semantic_cache", + "config": { + "provider": "openai", + "embedding_model": "text-embedding-3-small", + + "cleanup_on_shutdown": true, + "ttl": "5m", + "threshold": 0.8, + + "conversation_history_threshold": 3, + "exclude_system_prompt": false, + + "cache_by_model": true, + "cache_by_provider": true + } + } + ] +} +``` + +> **Note**: All the available keys will be taken from the provider config on initialization, so make sure to add the keys to the provider you have specified in the config. Any updates to the keys will not be reflected until next restart. + +**TTL Format Options:** +- Duration strings: `"30s"`, `"5m"`, `"1h"`, `"24h"` +- Numeric seconds: `300` (5 minutes), `3600` (1 hour) + + + + + +--- + +## Cache Triggering + + +**Cache Key is mandatory**: Semantic caching only activates when a cache key is provided. Without a cache key, requests bypass caching entirely. + + + + + +Must set cache key in request context: + +```go +// This request WILL be cached +ctx = context.WithValue(ctx, semanticcache.CacheKey, "session-123") +response, err := client.ChatCompletionRequest(ctx, request) + +// This request will NOT be cached (no context value) +response, err := client.ChatCompletionRequest(context.Background(), request) +``` + + + + +Must set cache key in request header `x-bf-cache-key`: + +```bash +# This request WILL be cached +curl -H "x-bf-cache-key: session-123" ... + +# This request will NOT be cached (no header) +curl ... +``` + + + + + +## Per-Request Overrides + +Override default TTL and similarity threshold per request: + + + + + +You can set TTL and threshold in the request context, in the keys you configured in the plugin config: + +```go +// Go SDK: Custom TTL and threshold +ctx = context.WithValue(ctx, semanticcache.CacheKey, "session-123") +ctx = context.WithValue(ctx, semanticcache.CacheTTLKey, 30*time.Second) +ctx = context.WithValue(ctx, semanticcache.CacheThresholdKey, 0.9) +``` + + + + + +You can set TTL and threshold in the request headers `x-bf-cache-ttl` and `x-bf-cache-threshold`: + +```bash +# HTTP: Custom TTL and threshold +curl -H "x-bf-cache-key: session-123" \ + -H "x-bf-cache-ttl: 30s" \ + -H "x-bf-cache-threshold: 0.9" ... +``` + + + + + +--- + +## Advanced Cache Control + +### Cache Type Control + +Control which caching mechanism to use per request: + + + + + +```go +// Use only direct hash matching (fastest) +ctx = context.WithValue(ctx, semanticcache.CacheKey, "session-123") +ctx = context.WithValue(ctx, semanticcache.CacheTypeKey, semanticcache.CacheTypeDirect) + +// Use only semantic similarity search +ctx = context.WithValue(ctx, semanticcache.CacheKey, "session-123") +ctx = context.WithValue(ctx, semanticcache.CacheTypeKey, semanticcache.CacheTypeSemantic) + +// Default behavior: Direct + semantic fallback (if not specified) +ctx = context.WithValue(ctx, semanticcache.CacheKey, "session-123") +``` + + + + + +```bash +# Direct hash matching only +curl -H "x-bf-cache-key: session-123" \ + -H "x-bf-cache-type: direct" ... + +# Semantic similarity search only +curl -H "x-bf-cache-key: session-123" \ + -H "x-bf-cache-type: semantic" ... + +# Default: Both (if header not specified) +curl -H "x-bf-cache-key: session-123" ... +``` + + + + + +### No-Store Control + +Disable response caching while still allowing cache reads: + + + + + +```go +// Read from cache but don't store the response +ctx = context.WithValue(ctx, semanticcache.CacheKey, "session-123") +ctx = context.WithValue(ctx, semanticcache.CacheNoStoreKey, true) +``` + + + + + +```bash +# Read from cache but don't store response +curl -H "x-bf-cache-key: session-123" \ + -H "x-bf-cache-no-store: true" ... +``` + + + + + +--- + +## Conversation Configuration + +### History Threshold Logic + +The `ConversationHistoryThreshold` setting skips caching for conversations with many messages to prevent false positives: + +**Why this matters:** +- **Semantic False Positives**: Long conversation histories have high probability of semantic matches with unrelated conversations due to topic overlap +- **Direct Cache Inefficiency**: Long conversations rarely have exact hash matches, making direct caching less effective +- **Performance**: Reduces vector store load by filtering out low-value caching scenarios + +```json +{ + "conversation_history_threshold": 3 // Skip caching if > 3 messages in conversation +} +``` + +**Recommended Values:** +- **1-2**: Very conservative (may miss valuable caching opportunities) +- **3-5**: Balanced approach (default: 3) +- **10+**: Cache longer conversations (higher false positive risk) + +### System Prompt Handling + +Control whether system messages are included in cache key generation: + +```json +{ + "exclude_system_prompt": false // Include system messages in cache key (default) +} +``` + +**When to exclude (`true`):** +- System prompts change frequently but content is similar +- Multiple system prompt variations for same use case +- Focus caching on user content similarity + +**When to include (`false`):** +- System prompts significantly change response behavior +- Each system prompt requires distinct cached responses +- Strict response consistency requirements + +--- + +## Cache Management + +### Cache Metadata Location + +When responses are served from semantic cache, 3 key variables are automatically added to the response: + +**Location**: `response.ExtraFields.CacheDebug` (as a JSON object) + +**Fields**: +- `CacheHit` (boolean): `true` if the response was served from the cache, `false` when lookup fails. +- `HitType` (string): `"semantic"` for similarity match, `"direct"` for hash match +- `CacheID` (string): Unique cache entry ID for management operations (present only for cache hits) + + +**Semantic Cache Only**: +- `ProviderUsed` (string): Provider used for the calculating semantic match embedding. (present for both cache hits and misses) +- `ModelUsed` (string): Model used for the calculating semantic match embedding. (present for both cache hits and misses) +- `InputTokens` (number): Number of tokens extracted from the request for the semantic match embedding calculation. (present for both cache hits and misses) +- `Threshold` (number): Similarity threshold used for the match. (present only for cache hits) +- `Similarity` (number): Similarity score for the match. (present only for cache hits) + +Example HTTP Response: + +```json +{ + "extra_fields": { + "cache_debug": { + "cache_hit": true, + "hit_type": "direct", + "cache_id": "550e8500-e29b-41d4-a725-446655440001", + } + } +} + +{ + "extra_fields": { + "cache_debug": { + "cache_hit": true, + "hit_type": "semantic", + "cache_id": "550e8500-e29b-41d4-a725-446655440001", + "threshold": 0.8, + "similarity": 0.95, + "provider_used": "openai", + "model_used": "gpt-4o-mini", + "input_tokens": 100 + } + } +} + +{ + "extra_fields": { + "cache_debug": { + "cache_hit": false, + "provider_used": "openai", + "model_used": "gpt-4o-mini", + "input_tokens": 20 + } + } +} +``` + + +These variables allow you to detect cached responses and get the cache entry ID needed for clearing specific entries. + +### Clear Specific Cache Entry + +Use the request ID from cached responses to clear specific entries: + + + + + +```go +// Clear specific entry by request ID +err := plugin.ClearCacheForRequestID("550e8400-e29b-41d4-a716-446655440000") + +// Clear all entries for a cache key +err := plugin.ClearCacheForKey("support-session-456") +``` + + + + + +```bash +# Clear specific cached entry by request ID +curl -X DELETE http://localhost:8080/api/cache/clear/550e8400-e29b-41d4-a716-446655440000 + +# Clear all entries for a cache key +curl -X DELETE http://localhost:8080/api/cache/clear-by-key/support-session-456 +``` + + + + + +### Cache Lifecycle & Cleanup + +The semantic cache automatically handles cleanup to prevent storage bloat: + +**Automatic Cleanup:** +- **TTL Expiration**: Entries are automatically removed when TTL expires +- **Shutdown Cleanup**: All cache entries are cleared from the vector store namespace and the namespace itself when Bifrost client shuts down +- **Namespace Isolation**: Each Bifrost instance uses isolated vector store namespaces to prevent conflicts + +**Manual Cleanup Options:** +- Clear specific entries by request ID (see examples above) +- Clear all entries for a cache key +- Restart Bifrost to clear all cache data + + +The semantic cache namespace and all its cache entries are deleted when Bifrost client shuts down **only if `cleanup_on_shutdown` is set to `true`**. By default (`cleanup_on_shutdown: false`), cache data persists between restarts. DO NOT use the plugin's namespace for external purposes. + + + +**Dimension Changes**: If you update the `dimension` config, the existing namespace will contain data with mixed dimensions, causing retrieval issues. To avoid this, either use a different `vector_store_namespace` or set `cleanup_on_shutdown: true` before restarting. + + +--- + + +**Vector Store Requirement**: Semantic caching requires a configured vector store (currently Weaviate only). Without vector store setup, the plugin will not function. + \ No newline at end of file diff --git a/docs/features/sso-with-google-github.mdx b/docs/features/sso-with-google-github.mdx new file mode 100644 index 000000000..aee362bc4 --- /dev/null +++ b/docs/features/sso-with-google-github.mdx @@ -0,0 +1,6 @@ +--- +title: "SSO with Google & GitHub" +description: "Secure single sign-on authentication with Google and GitHub OAuth providers." +tag: "Coming soon" +icon: "sign-in-alt" +--- \ No newline at end of file diff --git a/docs/features/telemetry.mdx b/docs/features/telemetry.mdx new file mode 100644 index 000000000..c55f6405c --- /dev/null +++ b/docs/features/telemetry.mdx @@ -0,0 +1,311 @@ +--- +title: "Telemetry" +description: "Comprehensive Prometheus-based monitoring for Bifrost Gateway with custom metrics and labels." +icon: "gauge" +--- + +## Overview + +Bifrost provides built-in telemetry and monitoring capabilities through Prometheus metrics collection. The telemetry system tracks both HTTP-level performance metrics and upstream provider interactions, giving you complete visibility into your AI gateway's performance and usage patterns. + +**Key Features:** +- **Prometheus Integration** - Native metrics collection at `/metrics` endpoint +- **Comprehensive Tracking** - Success/error rates, token usage, costs, and cache performance +- **Custom Labels** - Configurable dimensions for detailed analysis +- **Dynamic Headers** - Runtime label injection via `x-bf-prom-*` headers +- **Cost Monitoring** - Real-time tracking of AI provider costs in USD +- **Cache Analytics** - Direct and semantic cache hit tracking +- **Async Collection** - Zero-latency impact on request processing +- **Multi-Level Tracking** - HTTP transport + upstream provider metrics + +The telemetry plugin operates asynchronously to ensure metrics collection doesn't impact request latency or connection performance. + +--- + +## Default Metrics + +### HTTP Transport Metrics + +These metrics track all incoming HTTP requests to Bifrost: + +| Metric | Type | Description | +|--------|------|-------------| +| `http_requests_total` | Counter | Total number of HTTP requests | +| `http_request_duration_seconds` | Histogram | Duration of HTTP requests | +| `http_request_size_bytes` | Histogram | Size of incoming HTTP requests | +| `http_response_size_bytes` | Histogram | Size of outgoing HTTP responses | + +Labels: +- `path`: HTTP endpoint path +- `method`: HTTP verb (e.g., `GET`, `POST`, `PUT`, `DELETE`) +- `status`: HTTP status code +- custom labels: Custom labels configured in the Bifrost configuration + +### Upstream Provider Metrics + +These metrics track requests forwarded to AI providers: + +| Metric | Type | Description | Labels | +|--------|------|-------------|---------| +| `bifrost_upstream_requests_total` | Counter | Total requests forwarded to upstream providers | Base Labels, custom labels | +| `bifrost_success_requests_total` | Counter | Total successful requests to upstream providers | Base Labels, custom labels | +| `bifrost_error_requests_total` | Counter | Total failed requests to upstream providers | Base Labels, `reason`, custom labels | +| `bifrost_upstream_latency_seconds` | Histogram | Latency of upstream provider requests | Base Labels, `is_success`, custom labels | +| `bifrost_input_tokens_total` | Counter | Total input tokens sent to upstream providers | Base Labels, custom labels | +| `bifrost_output_tokens_total` | Counter | Total output tokens received from upstream providers | Base Labels, custom labels | +| `bifrost_cache_hits_total` | Counter | Total cache hits by type (direct/semantic) | Base Labels, `cache_type`, custom labels | +| `bifrost_cost_total` | Counter | Total cost in USD for upstream provider requests | Base Labels, custom labels | + +Base Labels: +- `provider`: AI provider name (e.g., `openai`, `anthropic`, `azure`) +- `model`: Model name (e.g., `gpt-4o-mini`, `claude-3-sonnet`) +- `method`: Request type (`chat`, `text`, `embedding`, `speech`, `transcription`) +- `virtual_key_id`: Virtual key ID +- `virtual_key_name`: Virtual key name +- `selected_key_id`: Selected key ID +- `selected_key_name`: Selected key name +- `number_of_retries`: Number of retries +- `fallback_index`: Fallback index (0 for first attempt, 1 for second attempt, etc.) +- custom labels: Custom labels configured in the Bifrost configuration + +### Streaming Metrics + +These metrics capture latency characteristics specific to streaming responses: + +| Metric | Type | Description | Labels | +|--------|------|-------------|---------| +| `bifrost_stream_first_token_latency_seconds` | Histogram | Time from request start to first streamed token | Base Labels | +| `bifrost_stream_inter_token_latency_seconds` | Histogram | Latency between subsequent streamed tokens | Base Labels | + +--- + +## Monitoring Examples + +### Success Rate Monitoring +Track the success rate of requests to different providers: + +```promql +# Success rate by provider +rate(bifrost_success_requests_total[5m]) / +rate(bifrost_upstream_requests_total[5m]) * 100 +``` + +### Token Usage Analysis +Monitor token consumption across different models: + +```promql +# Input tokens per minute by model +increase(bifrost_input_tokens_total[1m]) + +# Output tokens per minute by model +increase(bifrost_output_tokens_total[1m]) + +# Token efficiency (output/input ratio) +rate(bifrost_output_tokens_total[5m]) / +rate(bifrost_input_tokens_total[5m]) +``` + +### Cost Tracking +Monitor spending across providers and models: + +```promql +# Cost per second by provider +sum by (provider) (rate(bifrost_cost_total[1m])) + +# Daily cost estimate +sum by (provider) (increase(bifrost_cost_total[1d])) + +# Cost per request by provider and model +sum by (provider, model) (rate(bifrost_cost_total[5m])) / +sum by (provider, model) (rate(bifrost_upstream_requests_total[5m])) +``` + +### Cache Performance +Track cache effectiveness: + +```promql +# Cache hit rate by type +rate(bifrost_cache_hits_total[5m]) / +rate(bifrost_upstream_requests_total[5m]) * 100 + +# Direct vs semantic cache hits +sum by (cache_type) (rate(bifrost_cache_hits_total[5m])) +``` + +### Error Rate Analysis +Monitor error patterns: + +```promql +# Error rate by provider +rate(bifrost_error_requests_total[5m]) / +rate(bifrost_upstream_requests_total[5m]) * 100 + +# Errors by model +sum by (model) (rate(bifrost_error_requests_total[5m])) +``` + +--- + +## Configuration + +Configure custom Prometheus labels to add dimensions for filtering and analysis: + + + + +![Prometheus Labels](../media/ui-prometheus-labels.png) + +1. **Navigate to Configuration** + - Open Bifrost UI at `http://localhost:8080` + - Go to **Config** tab + +2. **Prometheus Labels** + ``` + Custom Labels: team, environment, organization, project + ``` + + + + +```bash +# Update prometheus labels via API +curl -X PATCH http://localhost:8080/config \ + -H "Content-Type: application/json" \ + -d '{ + "client": { + "prometheus_labels": ["team", "environment", "organization", "project"] + } + }' +``` + + + + +```json +{ + "client": { + "prometheus_labels": ["team", "environment", "organization", "project"], + "drop_excess_requests": false, + "initial_pool_size": 300 + } +} +``` + + + + +### Dynamic Label Injection + +Add custom label values at runtime using `x-bf-prom-*` headers: + +```bash +# Add custom labels to specific requests +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "x-bf-prom-team: engineering" \ + -H "x-bf-prom-environment: production" \ + -H "x-bf-prom-organization: my-org" \ + -H "x-bf-prom-project: my-project" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +**Header Format:** +- Prefix: `x-bf-prom-` +- Label name: Any string after the prefix +- Value: String value for the label + +--- + +## Infrastructure Setup + +### Development & Testing + +For local development and testing, use the provided Docker Compose setup: + +```bash +# Navigate to telemetry plugin directory +cd plugins/telemetry + +# Start Prometheus and Grafana +docker-compose up -d + +# Access endpoints +# Prometheus: http://localhost:9090 +# Grafana: http://localhost:3000 (admin/admin) +# Bifrost metrics: http://localhost:8080/metrics +``` + + +**Development Only**: The provided Docker Compose setup is for testing purposes only. Do not use in production without proper security, scaling, and persistence configuration. + + +You can use the Prometheus scraping endpoint to create your own Grafana dashboards. Given below are few examples created using the Docker Compose setup. + +![Grafana Dashboard](../media/ui-grafana-dashboard.png) + +### Production Deployment + +For production environments: + +1. **Deploy Prometheus** with proper persistence, retention, and security +2. **Configure scraping** to target your Bifrost instances at `/metrics` +3. **Set up Grafana** with authentication and dashboards +4. **Configure alerts** based on your SLA requirements + +**Prometheus Scrape Configuration:** +```yaml +scrape_configs: + - job_name: "bifrost-gateway" + static_configs: + - targets: ["bifrost-instance-1:8080", "bifrost-instance-2:8080"] + scrape_interval: 30s + metrics_path: /metrics +``` + +### Production Alerting Examples + +Configure alerts for critical scenarios using the new metrics: + +**High Error Rate Alert:** +```yaml +- alert: BifrostHighErrorRate + expr: sum by (provider) (rate(bifrost_error_requests_total[5m])) / sum by (provider) (rate(bifrost_upstream_requests_total[5m])) > 0.05 + for: 2m + labels: + severity: warning + annotations: + summary: "High error rate detected for provider {{ $labels.provider }} ({{ $value | humanizePercentage }})" +``` + +**High Cost Alert:** +```yaml +- alert: BifrostHighCosts + expr: sum by (provider) (increase(bifrost_cost_total[1d])) > 100 # $100/day threshold + for: 10m + labels: + severity: warning + annotations: + summary: "Daily cost for provider {{ $labels.provider }} exceeds $100 ({{ $value | printf \"%.2f\" }})" +``` + +**Cache Performance Alert:** +```yaml +- alert: BifrostLowCacheHitRate + expr: sum by (provider) (rate(bifrost_cache_hits_total[15m])) / sum by (provider) (rate(bifrost_upstream_requests_total[15m])) < 0.1 + for: 5m + labels: + severity: info + annotations: + summary: "Cache hit rate for provider {{ $labels.provider }} below 10% ({{ $value | humanizePercentage }})" +``` + +--- + +## Next Steps + +- **[Prometheus Documentation](https://prometheus.io/docs/)** - Official Prometheus guides +- **[Grafana Setup](https://grafana.com/docs/)** - Dashboard creation and management +- **[Tracing](./observability/default)** - Request/response logging for detailed analysis diff --git a/docs/features/unified-interface.mdx b/docs/features/unified-interface.mdx new file mode 100644 index 000000000..bb9687f71 --- /dev/null +++ b/docs/features/unified-interface.mdx @@ -0,0 +1,143 @@ +--- +title: "Unified Interface" +description: "Every AI provider returns the same OpenAI-compatible response format, making it seamless to switch between providers without changing your application code." +icon: "layer-group" +--- + +## One Format, All Providers + +The beauty of Bifrost lies in its unified interface: regardless of whether you're using OpenAI, Anthropic, AWS Bedrock, Google Vertex, or any other supported provider, you always get the same response format. This means your application logic never needs to change when switching providers. + +Bifrost standardizes all provider responses to follow the **OpenAI-compatible structure**, so you can write your code once and use it with any provider. + +## How It Works + +When you make a request to any provider through Bifrost, the response always follows the same structure - the familiar OpenAI format that most developers already know. Behind the scenes, Bifrost handles all the complexity of translating between different provider formats. + + + + + +```bash +# Same response format regardless of provider +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# Returns OpenAI-compatible format: +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 9, + "total_tokens": 19 + } +} +``` + + + + + +```go +// Same response structure regardless of provider +type BifrostChatResponse struct { + ID string `json:"id"` + Choices []BifrostResponseChoice `json:"choices"` + Created int `json:"created"` + Model string `json:"model"` + Object string `json:"object"` + ServiceTier string `json:"service_tier"` + SystemFingerprint string `json:"system_fingerprint"` + Usage *BifrostLLMUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +// Works with any provider +response, err := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, // or Anthropic, Bedrock, etc. + Model: "gpt-4o-mini", // or "claude-3-sonnet", etc. + Input: messages, +}) +// Response structure is always the same! +``` + + + + + + +## Provider Support Matrix + +The following table summarizes which operations are supported by each provider via Bifrost’s unified interface. + +| Provider | Models | Text | Text (stream) | Chat | Chat (stream) | Responses | Responses (stream) | Embeddings | TTS | TTS (stream) | STT | STT (stream) | +|----------|--------|------|----------------|------|---------------|-----------|--------------------|------------|-----|-------------|-----|--------------| +| Anthropic (`anthropic/`) | βœ… | βœ… | ❌ | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Azure OpenAI (`azure/`) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | +| Bedrock (`bedrock/`) | βœ… | βœ… | ❌ | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | +| Cerebras (`cerebras/`) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Cohere (`cohere/`) | βœ… | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | +| Gemini (`gemini/`) | βœ… | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | +| Groq (`groq/`) | βœ… | 🟑 | 🟑 | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mistral (`mistral/`) | βœ… | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | +| Ollama (`ollama/`) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | +| OpenAI (`openai/`) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | +| OpenRouter (`openrouter/`) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Parasail (`parasail/`) | βœ… | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| Perplexity (`perplexity/`) | βœ… | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | ❌ | +| SGL (`sgl/`) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | +| Vertex AI (`vertex/`) | βœ… | ❌ | ❌ | βœ… | βœ… | βœ… | βœ… | βœ… | ❌ | ❌ | ❌ | ❌ | + +- 🟑 Not supported by the downstream provider, but internally implemented by Bifrost as a fallback. +- ❌ Not supported by the downstream provider, hence not supported by Bifrost. +- βœ… Fully supported by the downstream provider, or internally implemented by Bifrost. + + + +Some operations are not supported by the downstream provider, and their internal implementation in Bifrost is optional. 🟑 +Like Text completions are not supported by Groq, but Bifrost can emulate them internally using the Chat Completions API. This feature is disabled by default, but it can be enabled by setting the `enable_litellm_fallbacks` flag to `true` in the client configuration. +We do not promote using such fallbacks, since text completions and chat completions are fundamentally different. However, this option is available to help users migrating from LiteLLM (which does support these fallbacks). + + + +Notes: +- β€œModels” refers to the list models operation (`/v1/models`). +- β€œText” refers to the classic text completion interface (`/v1/completions`). +- β€œResponses” refers to the OpenAI-style Responses API (`/v1/responses`). Non-OpenAI providers map this to their native chat API under the hood. +- TTS corresponds to `/v1/audio/speech` and STT to `/v1/audio/transcriptions`. + +## The Power of Consistency + +This unified approach means you can: + +- **Switch providers instantly** without changing application logic +- **Mix and match providers** using fallbacks and load balancing +- **Future-proof your code** as new providers get added +- **Use familiar OpenAI patterns** regardless of the underlying provider + +Whether you're calling OpenAI's GPT-4, Anthropic's Claude, or AWS Bedrock's models, your application sees the exact same response structure. This consistency is what makes Bifrost's advanced features like automatic fallbacks and multi-provider load balancing possible. + +## Provider Transparency + +While the response format stays consistent, Bifrost doesn't hide which provider actually handled your request. Provider information is always available in the `extra_fields` section, along with any provider-specific metadata you might need for debugging or analytics. + +This gives you the best of both worlds: consistent application logic with full transparency into the underlying provider behavior. + +**Learn more about configuring provider transparency:** +- **[Go SDK Provider Configuration](../quickstart/go-sdk/provider-configuration)** - Configure `SendBackRawResponse` and other provider settings +- **[Gateway Provider Configuration](../quickstart/gateway/provider-configuration)** - Configure `send_back_raw_response` via API, UI, or config file diff --git a/docs/googleTag.js b/docs/googleTag.js new file mode 100644 index 000000000..92d932b72 --- /dev/null +++ b/docs/googleTag.js @@ -0,0 +1,41 @@ +(function (w, d, s, l, i) { + w[l] = w[l] || []; w[l].push({ + 'gtm.start': + new Date().getTime(), event: 'gtm.js' + }); var f = d.getElementsByTagName(s)[0], + j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; j.async = true; j.src = + 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f); +})(window, document, 'script', 'dataLayer', 'GTM-PZVSZ6P5'); + + +(function() { + var script = document.createElement('script'); + script.src = "https://g.getmaxim.ai?id=G-Q9GWB3JQM9"; + script.async = true; + document.head.appendChild(script); +})(); + +window.dataLayer = window.dataLayer || []; +function gtag() { dataLayer.push(arguments); } +gtag('js', new Date()); +gtag('config', 'G-Q9GWB3JQM9'); + +// Attach GTM noscript to the top of the body +(function() { + var noscript = document.createElement('noscript'); + var iframe = document.createElement('iframe'); + iframe.src = "https://www.googletagmanager.com/ns.html?id=GTM-PZVSZ6P5"; + iframe.height = "0"; + iframe.width = "0"; + iframe.style.display = "none"; + iframe.style.visibility = "hidden"; + noscript.appendChild(iframe); + + if (document.body) { + document.body.insertBefore(noscript, document.body.firstChild); + } else { + document.addEventListener('DOMContentLoaded', function() { + document.body.insertBefore(noscript, document.body.firstChild); + }); + } +})(); \ No newline at end of file diff --git a/docs/integrations/anthropic-sdk.mdx b/docs/integrations/anthropic-sdk.mdx new file mode 100644 index 000000000..65a6cb7dd --- /dev/null +++ b/docs/integrations/anthropic-sdk.mdx @@ -0,0 +1,342 @@ +--- +title: "Anthropic SDK" +description: "Use Bifrost as a drop-in replacement for Anthropic API with full compatibility and enhanced features." +icon: "a" +--- + +## Overview + +Bifrost provides complete Anthropic API compatibility through protocol adaptation. The integration handles request transformation, response normalization, and error mapping between Anthropic's Messages API specification and Bifrost's internal processing pipeline. + +This integration enables you to utilize Bifrost's features like governance, load balancing, semantic caching, multi-provider support, and more, all while preserving your existing Anthropic SDK-based architecture. + +**Endpoint:** `/anthropic` + +--- + +## Setup + + + + +```python {5} +import anthropic + +# Configure client to use Bifrost +client = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", + api_key="dummy-key" # Keys handled by Bifrost +) + +# Make requests as usual +response = client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello!"}] +) + +print(response.content[0].text) +``` + + + + +```javascript {5} +import Anthropic from "@anthropic-ai/sdk"; + +// Configure client to use Bifrost +const anthropic = new Anthropic({ + baseURL: "http://localhost:8080/anthropic", + apiKey: "dummy-key", // Keys handled by Bifrost +}); + +// Make requests as usual +const response = await anthropic.messages.create({ + model: "claude-3-sonnet-20240229", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello!" }], +}); + +console.log(response.content[0].text); +``` + + + + +--- + +## Provider/Model Usage Examples + +Use multiple providers through the same Anthropic SDK format by prefixing model names with the provider: + + + + +```python +import anthropic + +client = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", + api_key="dummy-key" +) + +# Anthropic models (default) +anthropic_response = client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello from Claude!"}] +) + +# OpenAI models via Anthropic SDK format +openai_response = client.messages.create( + model="openai/gpt-4o-mini", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello from OpenAI!"}] +) + +# Google Vertex models via Anthropic SDK format +vertex_response = client.messages.create( + model="vertex/gemini-pro", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello from Gemini!"}] +) + +# Azure OpenAI models +azure_response = client.messages.create( + model="azure/gpt-4o", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello from Azure!"}] +) + +# Local Ollama models +ollama_response = client.messages.create( + model="ollama/llama3.1:8b", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello from Ollama!"}] +) +``` + + + + +```javascript +import Anthropic from "@anthropic-ai/sdk"; + +const anthropic = new Anthropic({ + baseURL: "http://localhost:8080/anthropic", + apiKey: "dummy-key", +}); + +// Anthropic models (default) +const anthropicResponse = await anthropic.messages.create({ + model: "claude-3-sonnet-20240229", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello from Claude!" }], +}); + +// OpenAI models via Anthropic SDK format +const openaiResponse = await anthropic.messages.create({ + model: "openai/gpt-4o-mini", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello from OpenAI!" }], +}); + +// Google Vertex models via Anthropic SDK format +const vertexResponse = await anthropic.messages.create({ + model: "vertex/gemini-pro", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello from Gemini!" }], +}); + +// Azure OpenAI models +const azureResponse = await anthropic.messages.create({ + model: "azure/gpt-4o", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello from Azure!" }], +}); + +// Local Ollama models +const ollamaResponse = await anthropic.messages.create({ + model: "ollama/llama3.1:8b", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello from Ollama!" }], +}); +``` + + + + +--- + +## Adding Custom Headers + +Pass custom headers required by Bifrost plugins (like governance, telemetry, etc.): + + + + +```python +import anthropic + +client = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", + api_key="dummy-key", + default_headers={ + "x-bf-vk": "vk_12345", # Virtual key for governance + "x-bf-user-id": "user_789", # User identification + "x-bf-team-id": "team_456", # Team identification + "x-bf-trace-id": "trace_abc123", # Request tracing + } +) + +response = client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello with custom headers!"}] +) +``` + + + + +```javascript +import Anthropic from "@anthropic-ai/sdk"; + +const anthropic = new Anthropic({ + baseURL: "http://localhost:8080/anthropic", + apiKey: "dummy-key", + defaultHeaders: { + "x-bf-vk": "vk_12345", // Virtual key for governance + "x-bf-user-id": "user_789", // User identification + "x-bf-team-id": "team_456", // Team identification + "x-bf-trace-id": "trace_abc123", // Request tracing + }, +}); + +const response = await anthropic.messages.create({ + model: "claude-3-sonnet-20240229", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello with custom headers!" }], +}); +``` + + + + +--- + +## Using Direct Keys + +Pass API keys directly in requests to bypass Bifrost's load balancing. You can pass any provider's API key (OpenAI, Anthropic, Mistral, etc.) since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. + +> **Learn more:** See [Quickstart Configuration](../quickstart/README) for enabling direct API key usage. + + + + +```python +import anthropic + +# Using Anthropic's API key directly +client_with_direct_key = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", + api_key="sk-your-anthropic-key" # Anthropic's API key works +) + +anthropic_response = client_with_direct_key.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello from Claude!"}] +) + +# or pass different provider keys per request using headers +client = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", + api_key="dummy-key" +) + +# Use Anthropic key for Claude +anthropic_response = client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello Claude!"}], + extra_headers={ + "x-api-key": "sk-ant-your-anthropic-key" + } +) + +# Use OpenAI key for GPT models +openai_response = client.messages.create( + model="openai/gpt-4o-mini", + max_tokens=1000, + messages=[{"role": "user", "content": "Hello GPT!"}], + extra_headers={ + "Authorization": "Bearer sk-your-openai-key" + } +) +``` + + + + +```javascript +import Anthropic from "@anthropic-ai/sdk"; + +// Using Anthropic's API key directly +const anthropicWithDirectKey = new Anthropic({ + baseURL: "http://localhost:8080/anthropic", + apiKey: "sk-your-anthropic-key", // Anthropic's API key works +}); + + +const anthropicResponse = await anthropicWithDirectKey.messages.create({ + model: "claude-3-sonnet-20240229", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello from Claude!" }], +}); + + +// or pass different provider keys per request using headers +const anthropic = new Anthropic({ + baseURL: "http://localhost:8080/anthropic", + apiKey: "dummy-key", +}); + +// Use Anthropic key for Claude +const anthropicResponse = await anthropic.messages.create({ + model: "claude-3-sonnet-20240229", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello Claude!" }], + headers: { + "x-api-key": "sk-ant-your-anthropic-key", + }, +}); + +// Use OpenAI key for GPT models +const openaiResponseWithHeader = await anthropic.messages.create({ + model: "openai/gpt-4o-mini", + max_tokens: 1000, + messages: [{ role: "user", content: "Hello GPT!" }], + headers: { + "Authorization": "Bearer sk-your-openai-key", + }, +}); +``` + + + + +--- + +## Supported Features + +The Anthropic integration supports all features that are available in both the Anthropic SDK and Bifrost core functionality. If the Anthropic SDK supports a feature and Bifrost supports it, the integration will work seamlessly. πŸ˜„ + +--- + +## Next Steps + +- **[OpenAI SDK](./openai-sdk)** - GPT integration patterns +- **[Google GenAI SDK](./genai-sdk)** - Gemini integration patterns +- **[Configuration](../quickstart/README)** - Bifrost setup and configuration +- **[Core Features](../features/)** - Advanced Bifrost capabilities diff --git a/docs/integrations/genai-sdk.mdx b/docs/integrations/genai-sdk.mdx new file mode 100644 index 000000000..42b893951 --- /dev/null +++ b/docs/integrations/genai-sdk.mdx @@ -0,0 +1,288 @@ +--- +title: "Google GenAI SDK" +description: "Use Bifrost as a drop-in replacement for Google GenAI API with full compatibility and enhanced features." +icon: "g" +--- + +## Overview + +Bifrost provides complete Google GenAI API compatibility through protocol adaptation. The integration handles request transformation, response normalization, and error mapping between Google's GenAI API specification and Bifrost's internal processing pipeline. + +This integration enables you to utilize Bifrost's features like governance, load balancing, semantic caching, multi-provider support, and more, all while preserving your existing Google GenAI SDK-based architecture. + +**Endpoint:** `/genai` + +--- + +## Setup + + + + +```python {7} +from google import genai +from google.genai.types import HttpOptions + +# Configure client to use Bifrost +client = genai.Client( + api_key="dummy-key", # Keys handled by Bifrost + http_options=HttpOptions(base_url="http://localhost:8080/genai") +) + +# Make requests as usual +response = client.models.generate_content( + model="gemini-1.5-flash", + contents="Hello!" +) + +print(response.text) +``` + + + + +```javascript {5} +import { GoogleGenerativeAI } from "@google/generative-ai"; + +// Configure client to use Bifrost +const genAI = new GoogleGenerativeAI("dummy-key", { + baseUrl: "http://localhost:8080/genai", // Keys handled by Bifrost +}); + +// Make requests as usual +const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); +const response = await model.generateContent("Hello!"); + +console.log(response.response.text()); +``` + + + + +--- + +## Provider/Model Usage Examples + +Use multiple providers through the same GenAI SDK format by prefixing model names with the provider: + + + + +```python +from google import genai +from google.genai.types import HttpOptions + +client = genai.Client( + api_key="dummy-key", + http_options=HttpOptions(base_url="http://localhost:8080/genai") +) + +# Google Vertex models (default) +vertex_response = client.models.generate_content( + model="gemini-1.5-flash", + contents="Hello from Gemini!" +) + +# OpenAI models via GenAI SDK format +openai_response = client.models.generate_content( + model="openai/gpt-4o-mini", + contents="Hello from OpenAI!" +) + +# Anthropic models via GenAI SDK format +anthropic_response = client.models.generate_content( + model="anthropic/claude-3-sonnet-20240229", + contents="Hello from Claude!" +) + +# Azure OpenAI models +azure_response = client.models.generate_content( + model="azure/gpt-4o", + contents="Hello from Azure!" +) + +# Local Ollama models +ollama_response = client.models.generate_content( + model="ollama/llama3.1:8b", + contents="Hello from Ollama!" +) +``` + + + + +```javascript +import { GoogleGenerativeAI } from "@google/generative-ai"; + +const genAI = new GoogleGenerativeAI("dummy-key", { + baseUrl: "http://localhost:8080/genai", +}); + +// Google Vertex models (default) +const geminiModel = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); +const vertexResponse = await geminiModel.generateContent("Hello from Gemini!"); + +// OpenAI models via GenAI SDK format +const openaiModel = genAI.getGenerativeModel({ model: "openai/gpt-4o-mini" }); +const openaiResponse = await openaiModel.generateContent("Hello from OpenAI!"); + +// Anthropic models via GenAI SDK format +const anthropicModel = genAI.getGenerativeModel({ model: "anthropic/claude-3-sonnet-20240229" }); +const anthropicResponse = await anthropicModel.generateContent("Hello from Claude!"); + +// Azure OpenAI models +const azureModel = genAI.getGenerativeModel({ model: "azure/gpt-4o" }); +const azureResponse = await azureModel.generateContent("Hello from Azure!"); + +// Local Ollama models +const ollamaModel = genAI.getGenerativeModel({ model: "ollama/llama3.1:8b" }); +const ollamaResponse = await ollamaModel.generateContent("Hello from Ollama!"); +``` + + + + +--- + +## Adding Custom Headers + +Pass custom headers required by Bifrost plugins (like governance, telemetry, etc.): + + + + +```python +from google import genai +from google.genai.types import HttpOptions + +# Configure client with custom headers +client = genai.Client( + api_key="dummy-key", + http_options=HttpOptions( + base_url="http://localhost:8080/genai", + headers={ + "x-bf-vk": "vk_12345", # Virtual key for governance + "x-bf-user-id": "user_789", # User identification + "x-bf-team-id": "team_456", # Team identification + "x-bf-trace-id": "trace_abc123", # Request tracing + } + ) +) + +response = client.models.generate_content( + model="gemini-1.5-flash", + contents="Hello with custom headers!" +) +``` + + + + +```javascript +import { GoogleGenerativeAI } from "@google/generative-ai"; + +// Configure client with custom headers +const genAI = new GoogleGenerativeAI("dummy-key", { + baseUrl: "http://localhost:8080/genai", + customHeaders: { + "x-bf-vk": "vk_12345", // Virtual key for governance + "x-bf-user-id": "user_789", // User identification + "x-bf-team-id": "team_456", // Team identification + "x-bf-trace-id": "trace_abc123", // Request tracing + }, +}); + +const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); +const response = await model.generateContent("Hello with custom headers!"); +``` + + + + +--- + +## Using Direct Keys + +Pass API keys directly in requests to bypass Bifrost's load balancing. You can pass any provider's API key (OpenAI, Anthropic, Mistral, etc.) since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. + +> **Learn more:** See [Quickstart Configuration](../quickstart/README) for enabling direct API key usage. + + + + +```python +from google import genai +from google.genai.types import HttpOptions + +# Pass different provider keys per request using headers +client = genai.Client( + api_key="dummy-key", + http_options=HttpOptions(base_url="http://localhost:8080/genai") +) + +# Use Anthropic key for Claude models +anthropic_response = client.models.generate_content( + model="anthropic/claude-3-sonnet-20240229", + contents="Hello Claude!", + request_options={ + "headers": {"x-api-key": "your-anthropic-api-key"} + } +) + +# Use OpenAI key for GPT models +openai_response = client.models.generate_content( + model="openai/gpt-4o-mini", + contents="Hello GPT!", + request_options={ + "headers": {"Authorization": "Bearer sk-your-openai-key"} + } +) +``` + + + + +```javascript +import { GoogleGenerativeAI } from "@google/generative-ai"; + +// Pass different provider keys per request using headers +const genAI = new GoogleGenerativeAI("dummy-key", { + baseUrl: "http://localhost:8080/genai", +}); + +// Use Anthropic key for Claude models +const anthropicModel = genAI.getGenerativeModel({ + model: "anthropic/claude-3-sonnet-20240229", + requestOptions: { + customHeaders: { "x-api-key": "your-anthropic-api-key" } + } +}); +const anthropicResponse = await anthropicModel.generateContent("Hello Claude!"); + +// Use OpenAI key for GPT models +const gptModel = genAI.getGenerativeModel({ + model: "openai/gpt-4o-mini", + requestOptions: { + customHeaders: { "Authorization": "Bearer sk-your-openai-key" } + } +}); +const gptResponse = await gptModel.generateContent("Hello GPT!"); +``` + + + + +--- + +## Supported Features + +The Google GenAI integration supports all features that are available in both the Google GenAI SDK and Bifrost core functionality. If the Google GenAI SDK supports a feature and Bifrost supports it, the integration will work seamlessly. πŸ˜„ + +--- + +## Next Steps + +- **[OpenAI SDK](./openai-sdk)** - GPT integration patterns +- **[Anthropic SDK](./anthropic-sdk)** - Claude integration patterns +- **[Configuration](../quickstart/README)** - Bifrost setup and configuration +- **[Core Features](../features/)** - Advanced Bifrost capabilities diff --git a/docs/integrations/langchain-sdk.mdx b/docs/integrations/langchain-sdk.mdx new file mode 100644 index 000000000..ea66b0000 --- /dev/null +++ b/docs/integrations/langchain-sdk.mdx @@ -0,0 +1,311 @@ +--- +title: "Langchain SDK" +description: "Use Bifrost as a drop-in proxy for Langchain applications with zero code changes." +icon: "crow" +--- + +Since Langchain already provides multi-provider abstraction and chaining capabilities, Bifrost adds enterprise features like governance, semantic caching, MCP tools, observability, etc, on top of your existing setup. + +**Endpoint:** `/langchain` + + +**Provider Compatibility:** This integration only works for AI providers that both Langchain and Bifrost support. If you're using a provider specific to Langchain that Bifrost doesn't support (or vice versa), those requests will fail. + +--- + +## Setup + + + + +```python {7} +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage + +# Configure client to use Bifrost +llm = ChatOpenAI( + model="gpt-4o-mini", + openai_api_base="http://localhost:8080/langchain", # Point to Bifrost + openai_api_key="dummy-key" # Keys managed by Bifrost +) + +response = llm.invoke([HumanMessage(content="Hello!")]) +print(response.content) +``` + + + + +```javascript {7} +import { ChatOpenAI } from "@langchain/openai"; + +// Configure client to use Bifrost +const llm = new ChatOpenAI({ + model: "gpt-4o-mini", + configuration: { + baseURL: "http://localhost:8080/langchain", // Point to Bifrost + }, + openAIApiKey: "dummy-key" // Keys managed by Bifrost +}); + +const response = await llm.invoke("Hello!"); +console.log(response.content); +``` + + + + + +--- + +## Provider/Model Usage Examples + +Your existing Langchain provider switching works unchanged through Bifrost: + + + + +```python +from langchain_openai import ChatOpenAI +from langchain_anthropic import ChatAnthropic +from langchain_google_genai import ChatGoogleGenerativeAI +from langchain_core.messages import HumanMessage + +base_url = "http://localhost:8080/langchain" + +# OpenAI models via Langchain +openai_llm = ChatOpenAI( + model="gpt-4o-mini", + openai_api_base=base_url +) + +# Anthropic models via Langchain +anthropic_llm = ChatAnthropic( + model="claude-3-sonnet-20240229", + anthropic_api_url=base_url +) + +# Google models via Langchain +google_llm = ChatGoogleGenerativeAI( + model="gemini-1.5-flash", + google_api_base=base_url +) + +# All work the same way +openai_response = openai_llm.invoke([HumanMessage(content="Hello GPT!")]) +anthropic_response = anthropic_llm.invoke([HumanMessage(content="Hello Claude!")]) +google_response = google_llm.invoke([HumanMessage(content="Hello Gemini!")]) +``` + + + + +```javascript +import { ChatOpenAI } from "@langchain/openai"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; + +const baseURL = "http://localhost:8080/langchain"; + +// OpenAI models via Langchain +const openaiLlm = new ChatOpenAI({ + model: "gpt-4o-mini", + configuration: { baseURL } +}); + +// Anthropic models via Langchain +const anthropicLlm = new ChatAnthropic({ + model: "claude-3-sonnet-20240229", + clientOptions: { baseURL } +}); + +// Google models via Langchain +const googleLlm = new ChatGoogleGenerativeAI({ + model: "gemini-1.5-flash", + baseURL +}); + +// All work the same way +const openaiResponse = await openaiLlm.invoke("Hello GPT!"); +const anthropicResponse = await anthropicLlm.invoke("Hello Claude!"); +const googleResponse = await googleLlm.invoke("Hello Gemini!"); +``` + + + + +--- + +## Adding Custom Headers + +Add Bifrost-specific headers for governance and tracking: + + + + +```python +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage + +# Add custom headers for Bifrost features +llm = ChatOpenAI( + model="gpt-4o-mini", + openai_api_base="http://localhost:8080/langchain", + default_headers={ + "x-bf-vk": "your-virtual-key", # Virtual key for governance + "x-bf-user-id": "user123", # User tracking + "x-bf-team-id": "team-ai", # Team tracking + "x-bf-trace-id": "trace-456" # Custom trace ID + } +) + +response = llm.invoke([HumanMessage(content="Hello!")]) +print(response.content) +``` + + + + +```javascript +import { ChatOpenAI } from "@langchain/openai"; + +// Add custom headers for Bifrost features +const llm = new ChatOpenAI({ + model: "gpt-4o-mini", + configuration: { + baseURL: "http://localhost:8080/langchain", + defaultHeaders: { + "x-bf-vk": "your-virtual-key", // Virtual key for governance + "x-bf-user-id": "user123", // User tracking + "x-bf-team-id": "team-ai", // Team tracking + "x-bf-trace-id": "trace-456" // Custom trace ID + } + } +}); + +const response = await llm.invoke("Hello!"); +console.log(response.content); +``` + + + + +--- + +## Using Direct Keys + +Pass API keys directly to bypass Bifrost's key management. You can pass any provider's API key since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. + +> **Learn more:** See [Quickstart Configuration](../quickstart/README) for enabling direct API key usage. + + + + +```python +from langchain_openai import ChatOpenAI +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage + +# Using OpenAI key directly +openai_llm = ChatOpenAI( + model="gpt-4o-mini", + openai_api_base="http://localhost:8080/langchain", + default_headers={ + "Authorization": "Bearer sk-your-openai-key" + } +) + +# Using Anthropic key for Claude models +anthropic_llm = ChatAnthropic( + model="claude-3-sonnet-20240229", + anthropic_api_url="http://localhost:8080/langchain", + default_headers={ + "x-api-key": "sk-ant-your-anthropic-key" + } +) + +# Using Azure OpenAI with direct Azure key +from langchain_openai import AzureChatOpenAI + +azure_llm = AzureChatOpenAI( + deployment_name="gpt-4o-aug", + api_key="your-azure-api-key", + azure_endpoint="http://localhost:8080/langchain", + api_version="2024-05-01-preview", + max_tokens=100, + default_headers={ + "x-bf-azure-endpoint": "https://your-resource.openai.azure.com", + } +) + +openai_response = openai_llm.invoke([HumanMessage(content="Hello GPT!")]) +anthropic_response = anthropic_llm.invoke([HumanMessage(content="Hello Claude!")]) +azure_response = azure_llm.invoke([HumanMessage(content="Hello from Azure!")]) +``` + + + + +```javascript +import { ChatOpenAI } from "@langchain/openai"; +import { ChatAnthropic } from "@langchain/anthropic"; + +// Using OpenAI key directly +const openaiLlm = new ChatOpenAI({ + model: "gpt-4o-mini", + configuration: { + baseURL: "http://localhost:8080/langchain", + defaultHeaders: { + "Authorization": "Bearer sk-your-openai-key" + } + } +}); + +// Using Anthropic key for Claude models +const anthropicLlm = new ChatAnthropic({ + model: "claude-3-sonnet-20240229", + clientOptions: { + baseURL: "http://localhost:8080/langchain", + defaultHeaders: { + "x-api-key": "sk-ant-your-anthropic-key" + } + } +}); + +// Using Azure OpenAI with direct Azure key +import { AzureChatOpenAI } from "@langchain/openai"; + +const azureLlm = new AzureChatOpenAI({ + deploymentName: "gpt-4o-aug", + apiKey: "your-azure-api-key", + azureOpenAIEndpoint: "http://localhost:8080/langchain", + apiVersion: "2024-05-01-preview", + maxTokens: 100, + configuration: { + defaultHeaders: { + "x-bf-azure-endpoint": "https://your-resource.openai.azure.com", + } + } +}); + +const openaiResponse = await openaiLlm.invoke("Hello GPT!"); +const anthropicResponse = await anthropicLlm.invoke("Hello Claude!"); +const azureResponse = await azureLlm.invoke("Hello from Azure!"); +``` + + + + +--- + +## Supported Features + +The Langchain integration supports all features that are available in both the Langchain SDK and Bifrost core functionality. Your existing Langchain chains and workflows work seamlessly with Bifrost's enterprise features. πŸ˜„ + +--- + +## Next Steps + +- **[Governance Features](../features/governance)** - Virtual keys and team management +- **[Semantic Caching](../features/semantic-caching)** - Intelligent response caching +- **[Configuration](../quickstart/README)** - Provider setup and API key management diff --git a/docs/integrations/litellm-sdk.mdx b/docs/integrations/litellm-sdk.mdx new file mode 100644 index 000000000..3d6d1cb2d --- /dev/null +++ b/docs/integrations/litellm-sdk.mdx @@ -0,0 +1,183 @@ +--- +title: "LiteLLM SDK" +description: "Use Bifrost as a drop-in proxy for LiteLLM applications with zero code changes." +icon: "train" +--- + +Since LiteLLM already provides multi-provider abstraction, Bifrost adds enterprise features like governance, semantic caching, MCP tools, observability, etc, on top of your existing setup. + +**Endpoint:** `/litellm` + + + **Provider Compatibility:** This integration only works for AI providers that both LiteLLM and Bifrost support. If you're using a provider specific to LiteLLM that Bifrost doesn't support (or vice versa), those requests will fail. + +--- + +## Setup + + + + +```python {7} +from litellm import completion + +# Configure client to use Bifrost +response = completion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello!"}], + base_url="http://localhost:8080/litellm" # Point to Bifrost +) + +print(response.choices[0].message.content) +``` + + + + +--- + +## Provider/Model Usage Examples + +Your existing LiteLLM provider switching works unchanged through Bifrost: + + + + +```python {4} +from litellm import completion + +# All your existing LiteLLM patterns work the same +base_url = "http://localhost:8080/litellm" + +# OpenAI models +openai_response = completion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello GPT!"}], + base_url=base_url +) + +# Anthropic models +anthropic_response = completion( + model="claude-3-sonnet-20240229", + messages=[{"role": "user", "content": "Hello Claude!"}], + base_url=base_url +) + +# Google models +google_response = completion( + model="gemini/gemini-1.5-flash", + messages=[{"role": "user", "content": "Hello Gemini!"}], + base_url=base_url +) + +# Azure OpenAI models +azure_response = completion( + model="azure/gpt-4o", + messages=[{"role": "user", "content": "Hello Azure!"}], + base_url=base_url +) +``` + + + + +--- + +## Adding Custom Headers + +Add Bifrost-specific headers for governance and tracking: + + + + +```python +from litellm import completion + +# Add custom headers for Bifrost features +response = completion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello!"}], + base_url="http://localhost:8080/litellm", + extra_headers={ + "x-bf-vk": "your-virtual-key", # Virtual key for governance + "x-bf-user-id": "user123", # User tracking + "x-bf-team-id": "team-ai", # Team tracking + "x-bf-trace-id": "trace-456" # Custom trace ID + } +) + +print(response.choices[0].message.content) +``` + + + + +--- + +## Using Direct Keys + +Pass API keys directly to bypass Bifrost's key management. You can pass any provider's API key since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. + +> **Learn more:** See [Quickstart Configuration](../quickstart/README) for enabling direct API key usage. + + + + +```python +from litellm import completion + +# Using OpenAI key directly +openai_response = completion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello GPT!"}], + base_url="http://localhost:8080/litellm", + extra_headers={ + "Authorization": "Bearer sk-your-openai-key" + } +) + +# Using Anthropic key for Claude models +anthropic_response = completion( + model="claude-3-sonnet-20240229", + messages=[{"role": "user", "content": "Hello Claude!"}], + base_url="http://localhost:8080/litellm", + extra_headers={ + "x-api-key": "sk-ant-your-anthropic-key" + } +) + +# Using Azure OpenAI with direct Azure key +import os + +deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "my-azure-deployment") +model = f"azure/{deployment}" + +azure_response = completion( + model=model, + messages=[{"role": "user", "content": "Hello from LiteLLM (Azure demo)!"}], + base_url="http://localhost:8080/litellm", + api_key=os.getenv("AZURE_API_KEY", "your-azure-api-key"), + deployment_id=os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o-aug"), + max_tokens=100, + extra_headers={ + "x-bf-azure-endpoint": "https://your-resource.openai.azure.com", + } +) +``` + + + + +--- + +## Supported Features + +The LiteLLM integration supports all features that are available in both the LiteLLM SDK and Bifrost core functionality. Your existing LiteLLM code works seamlessly with Bifrost's enterprise features. πŸ˜„ + +--- + +## Next Steps + +- **[Governance Features](../features/governance)** - Virtual keys and team management +- **[Semantic Caching](../features/semantic-caching)** - Intelligent response caching +- **[Configuration](../quickstart/README)** - Provider setup and API key management diff --git a/docs/integrations/openai-sdk.mdx b/docs/integrations/openai-sdk.mdx new file mode 100644 index 000000000..889bbdd63 --- /dev/null +++ b/docs/integrations/openai-sdk.mdx @@ -0,0 +1,371 @@ +--- +title: "OpenAI SDK" +description: "Use Bifrost as a drop-in replacement for OpenAI API with full compatibility and enhanced features." +icon: "o" +--- + +## Overview + +Bifrost provides complete OpenAI API compatibility through protocol adaptation. The integration handles request transformation, response normalization, and error mapping between OpenAI's API specification and Bifrost's internal processing pipeline. + +This integration enables you to utilize Bifrost's features like governance, load balancing, semantic caching, multi-provider support, and more, all while preserving your existing OpenAI SDK-based architecture. + +**Endpoint:** `/openai` + +--- + +## Setup + + + + +```python {5} +import openai + +# Configure client to use Bifrost +client = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="dummy-key" # Keys handled by Bifrost +) + +# Make requests as usual +response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello!"}] +) + +print(response.choices[0].message.content) +``` + + + + +```javascript {5} +import OpenAI from "openai"; + +// Configure client to use Bifrost +const openai = new OpenAI({ + baseURL: "http://localhost:8080/openai", + apiKey: "dummy-key", // Keys handled by Bifrost +}); + +// Make requests as usual +const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Hello!" }], +}); + +console.log(response.choices[0].message.content); +``` + + + + +--- + +## Provider/Model Usage Examples + +Use multiple providers through the same OpenAI SDK format by prefixing model names with the provider: + + + + +```python +import openai + +client = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="dummy-key" +) + +# OpenAI models (default) +openai_response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello from OpenAI!"}] +) + +# Anthropic models via OpenAI SDK format +anthropic_response = client.chat.completions.create( + model="anthropic/claude-3-sonnet-20240229", + messages=[{"role": "user", "content": "Hello from Claude!"}] +) + +# Google Vertex models via OpenAI SDK format +vertex_response = client.chat.completions.create( + model="vertex/gemini-pro", + messages=[{"role": "user", "content": "Hello from Gemini!"}] +) + +# Azure OpenAI models +azure_response = client.chat.completions.create( + model="azure/gpt-4o", + messages=[{"role": "user", "content": "Hello from Azure!"}] +) + +# Local Ollama models +ollama_response = client.chat.completions.create( + model="ollama/llama3.1:8b", + messages=[{"role": "user", "content": "Hello from Ollama!"}] +) +``` + + + + +```javascript +import OpenAI from "openai"; + +const openai = new OpenAI({ + baseURL: "http://localhost:8080/openai", + apiKey: "dummy-key", +}); + +// OpenAI models (default) +const openaiResponse = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Hello from OpenAI!" }], +}); + +// Anthropic models via OpenAI SDK format +const anthropicResponse = await openai.chat.completions.create({ + model: "anthropic/claude-3-sonnet-20240229", + messages: [{ role: "user", content: "Hello from Claude!" }], +}); + +// Google Vertex models via OpenAI SDK format +const vertexResponse = await openai.chat.completions.create({ + model: "vertex/gemini-pro", + messages: [{ role: "user", content: "Hello from Gemini!" }], +}); + +// Azure OpenAI models +const azureResponse = await openai.chat.completions.create({ + model: "azure/gpt-4o", + messages: [{ role: "user", content: "Hello from Azure!" }], +}); + +// Local Ollama models +const ollamaResponse = await openai.chat.completions.create({ + model: "ollama/llama3.1:8b", + messages: [{ role: "user", content: "Hello from Ollama!" }], +}); +``` + + + + +--- + +## Adding Custom Headers + +Pass custom headers required by Bifrost plugins (like governance, telemetry, etc.): + + + + +```python +import openai + +client = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="dummy-key", + default_headers={ + "x-bf-vk": "vk_12345", # Virtual key for governance + "x-bf-user-id": "user_789", # User identification + "x-bf-team-id": "team_456", # Team identification + "x-bf-trace-id": "trace_abc123", # Request tracing + } +) + +response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello with custom headers!"}] +) +``` + + + + +```javascript +import OpenAI from "openai"; + +const openai = new OpenAI({ + baseURL: "http://localhost:8080/openai", + apiKey: "dummy-key", + defaultHeaders: { + "x-bf-vk": "vk_12345", // Virtual key for governance + "x-bf-user-id": "user_789", // User identification + "x-bf-team-id": "team_456", // Team identification + "x-bf-trace-id": "trace_abc123", // Request tracing + }, +}); + +const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Hello with custom headers!" }], +}); +``` + + + + +--- + +## Using Direct Keys + +Pass API keys directly in requests to bypass Bifrost's load balancing. You can pass any provider's API key (OpenAI, Anthropic, Mistral, etc.) since Bifrost only looks for `Authorization` or `x-api-key` headers. This requires the **Allow Direct API keys** option to be enabled in Bifrost configuration. + +> **Learn more:** See [Quickstart Configuration](../quickstart/README) for enabling direct API key usage. + + + + +```python +import openai + +# Using OpenAI's API key directly +client_with_direct_key = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="sk-your-openai-key" # OpenAI's API key works +) + +openai_response = client_with_direct_key.chat.completions.create( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello from GPT!"}] +) + +# Or pass different provider keys per request +client = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="dummy-key" +) + +# Use OpenAI key for GPT models +openai_response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello GPT!"}], + extra_headers={ + "Authorization": "Bearer sk-your-openai-key" + } +) + +# Use Anthropic key for Claude models +anthropic_response = client.chat.completions.create( + model="anthropic/claude-3-sonnet-20240229", + messages=[{"role": "user", "content": "Hello Claude!"}], + extra_headers={ + "x-api-key": "sk-ant-your-anthropic-key" + } +) +``` + + + + +```javascript +import OpenAI from "openai"; + +// Using OpenAI's API key directly +const openaiWithDirectKey = new OpenAI({ + baseURL: "http://localhost:8080/openai", + apiKey: "sk-your-openai-key", // OpenAI's API key works +}); + +const openaiResponse = await openaiWithDirectKey.chat.completions.create({ + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "Hello from GPT!" }], +}); + +// Or pass different provider keys per request +const openai = new OpenAI({ + baseURL: "http://localhost:8080/openai", + apiKey: "dummy-key", +}); + +// Use OpenAI key for GPT models +const openaiResponse = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Hello GPT!" }], + headers: { + "Authorization": "Bearer sk-your-openai-key", + }, +}); + +// Use Anthropic key for Claude models +const anthropicResponseWithHeader = await openai.chat.completions.create({ + model: "anthropic/claude-3-sonnet-20240229", + messages: [{ role: "user", content: "Hello Claude!" }], + headers: { + "x-api-key": "sk-ant-your-anthropic-key", + }, +}); +``` + + + + +For Azure OpenAI, you can use the AzureOpenAI client and point it to Bifrost integration endpoint. The `x-bf-azure-endpoint` header is required to specify your Azure OpenAI resource endpoint. + + + + +```python +from openai import AzureOpenAI + +azure_client = AzureOpenAI( + api_key="your-azure-api-key", + api_version="2024-02-01", + azure_endpoint="http://localhost:8080/openai", # Point to Bifrost + default_headers={ + "x-bf-azure-endpoint": "https://your-resource.openai.azure.com" + } +) + +azure_response = azure_client.chat.completions.create( + model="gpt-4-deployment", # Your deployment name + messages=[{"role": "user", "content": "Hello from Azure!"}] +) + +print(azure_response.choices[0].message.content) +``` + + + + +```javascript +import { AzureOpenAI } from "openai"; + +const azureClient = new AzureOpenAI({ + apiKey: "your-azure-api-key", + apiVersion: "2024-02-01", + baseURL: "http://localhost:8080/openai", // Point to Bifrost + defaultHeaders: { + "x-bf-azure-endpoint": "https://your-resource.openai.azure.com" + } +}); + +const azureResponse = await azureClient.chat.completions.create({ + model: "gpt-4-deployment", // Your deployment name + messages: [{ role: "user", content: "Hello from Azure!" }], +}); + +console.log(azureResponse.choices[0].message.content); +``` + + + + +--- + +## Supported Features + +The OpenAI integration supports all features that are available in both the OpenAI SDK and Bifrost core functionality. If the OpenAI SDK supports a feature and Bifrost supports it, the integration will work seamlessly. πŸ˜„ + +--- + +## Next Steps + +- **[Anthropic SDK](./anthropic-sdk)** - Claude integration patterns +- **[Google GenAI SDK](./genai-sdk)** - Gemini integration patterns +- **[Configuration](../quickstart/README)** - Bifrost setup and configuration +- **[Core Features](../features/)** - Advanced Bifrost capabilities \ No newline at end of file diff --git a/docs/integrations/what-is-an-integration.mdx b/docs/integrations/what-is-an-integration.mdx new file mode 100644 index 000000000..bb4c87306 --- /dev/null +++ b/docs/integrations/what-is-an-integration.mdx @@ -0,0 +1,231 @@ +--- +title: "What is an integration?" +description: "Protocol adapters that translate between Bifrost's unified API and provider-specific API formats like OpenAI, Anthropic, and Google GenAI." +icon: "box" +--- + +## Overview + +An integration is a protocol adapter that translates between Bifrost's unified API and provider-specific API formats. Each integration handles request transformation, response normalization, and error mapping between the external API contract and Bifrost's internal processing pipeline. + +Integrations enable you to utilize Bifrost's features like governance, MCP tools, load balancing, semantic caching, multi-provider support, and more, all while preserving your existing SDK-based architecture. Bifrost handles all the overhead of structure conversion, requiring only a single URL change to switch from direct provider APIs to Bifrost's gateway. + +Bifrost converts the request/response format of the provider API to the Bifrost API format based on the integration used, so you don't have to. + +--- + +## Quick Migration + +### **Before (Direct Provider)** + +```python +import openai + +client = openai.OpenAI( + api_key="your-openai-key" +) +``` + +### **After (Bifrost)** + +```python {4} +import openai + +client = openai.OpenAI( + base_url="http://localhost:8080/openai", # Point to Bifrost + api_key="dummy-key" # Keys are handled in Bifrost now +) +``` + +**That's it!** Your application now benefits from Bifrost's features with no other changes. + +--- + +## Supported Integrations + +1. [OpenAI](./openai-sdk) +2. [Anthropic](./anthropic-sdk) +3. [Google GenAI](./genai-sdk) +4. [LiteLLM](./litellm-sdk) +5. [Langchain](./langchain-sdk) + +--- + +## Provider-Prefixed Models + +Use multiple providers seamlessly by prefixing model names with the provider: + + + +```python +import openai + +# Single client, multiple providers +client = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="dummy" # API keys configured in Bifrost +) + +# OpenAI models +response1 = client.chat.completions.create( + model="gpt-4o-mini", # (default OpenAI since it's OpenAI's SDK) + messages=[{"role": "user", "content": "Hello!"}] +) +``` + + +```python +import openai + +# Anthropic models using OpenAI SDK format +response2 = client.chat.completions.create( + model="anthropic/claude-3-sonnet-20240229", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + + +```python +import openai + +# Azure OpenAI models +response4 = client.chat.completions.create( + model="azure/gpt-4o", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + + +```python +import openai + +# Google Vertex models +response3 = client.chat.completions.create( + model="vertex/gemini-pro", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + + +```python +import openai + +# Local Ollama models +response5 = client.chat.completions.create( + model="ollama/llama3.1:8b", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + + + +--- + +## Direct API Usage + +For custom HTTP clients or when you have existing provider-specific setup and want to use Bifrost gateway without restructuring your codebase: + +```python {5,18,31,} +import requests + +# Fully OpenAI compatible endpoint +response = requests.post( + "http://localhost:8080/openai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {openai_key}", + "Content-Type": "application/json" + }, + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + } +) + +# Fully Anthropic compatible endpoint +response = requests.post( + "http://localhost:8080/anthropic/v1/messages", + headers={ + "Content-Type": "application/json", + }, + json={ + "model": "claude-3-sonnet-20240229", + "max_tokens": 1000, + "messages": [{"role": "user", "content": "Hello!"}] + } +) + +# Fully Google GenAI compatible endpoint +response = requests.post( + "http://localhost:8080/genai/v1beta/models/gemini-1.5-flash/generateContent", + headers={ + "Content-Type": "application/json", + }, + json={ + "contents": [ + {"parts": [{"text": "Hello!"}]} + ], + "generation_config": { + "max_output_tokens": 1000, + "temperature": 1 + } + } +) +``` + +--- + + +## Migration Strategies + +### **Gradual Migration** + +1. **Start with development** - Test Bifrost in dev environment +2. **Canary deployment** - Route 5% of traffic through Bifrost +3. **Feature-by-feature** - Migrate specific endpoints gradually +4. **Full migration** - Switch all traffic to Bifrost + +### **Blue-Green Migration** + +```python +import os +import random + +# Route traffic based on feature flag +def get_base_url(provider: str) -> str: + if os.getenv("USE_BIFROST", "false") == "true": + return f"http://bifrost:8080/{provider}" + else: + return f"https://api.{provider}.com" + +# Gradual rollout +def should_use_bifrost() -> bool: + rollout_percentage = int(os.getenv("BIFROST_ROLLOUT", "0")) + return random.randint(1, 100) <= rollout_percentage +``` + +### **Feature Flag Integration** + +```python +# Using feature flags for safe migration +import openai +from feature_flags import get_flag + +def create_client(): + if get_flag("use_bifrost_openai"): + base_url = "http://bifrost:8080/openai" + else: + base_url = "https://api.openai.com" + + return openai.OpenAI( + base_url=base_url, + api_key=os.getenv("OPENAI_API_KEY") + ) +``` + +--- + +## Next Steps + +- **[HTTP Transport Overview](../quickstart/gateway/setting-up)** - Main HTTP transport guide +- **[Endpoints](../apis/openapi.json)** - Complete API reference +- **[Configuration](../quickstart/gateway/provider-configuration)** - Provider setup and config diff --git a/docs/intercom.js b/docs/intercom.js new file mode 100644 index 000000000..2ec5006ee --- /dev/null +++ b/docs/intercom.js @@ -0,0 +1,8 @@ +window.intercomSettings = { + api_base: "https://api-iam.intercom.io", + app_id: "glx5mihe", +}; + + +// We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/glx5mihe' +(function () { var w = window; var ic = w.Intercom; if (typeof ic === "function") { ic('reattach_activator'); ic('update', w.intercomSettings); } else { var d = document; var i = function () { i.c(arguments); }; i.q = []; i.c = function (args) { i.q.push(args); }; w.Intercom = i; var l = function () { var s = d.createElement('script'); s.type = 'text/javascript'; s.async = true; s.src = 'https://widget.intercom.io/widget/glx5mihe'; var x = d.getElementsByTagName('script')[0]; x.parentNode.insertBefore(s, x); }; if (document.readyState === 'complete') { l(); } else if (w.attachEvent) { w.attachEvent('onload', l); } else { w.addEventListener('load', l, false); } } })(); diff --git a/docs/jsonLd.js b/docs/jsonLd.js new file mode 100644 index 000000000..7b8be576e --- /dev/null +++ b/docs/jsonLd.js @@ -0,0 +1,55 @@ +const jsonLd = { + "@context": "https://schema.org", + "@type": "WebPage", + url: "https://www.getmaxim.ai/bifrost/docs", + name: "Bifrost Documentation", + description: + "Comprehensive documentation for Maxim's end-to-end platform for AI simulation, evaluation, and observability. Learn how to build, evaluate, and monitor GenAI workflows at scale.", + publisher: { + "@type": "Organization", + name: "Bifrost", + url: "https://www.getmaxim.ai/bifrost", + logo: { + "@type": "ImageObject", + url: "https://bifrost.getmaxim.ai/logo-full.svg", + width: 300, + height: 60, + }, + sameAs: ["https://twitter.com/getmaximai", "https://www.linkedin.com/company/maxim-ai", "https://www.youtube.com/@getmaximai"], + }, + mainEntity: { + "@type": "TechArticle", + name: "Bifrost Documentation", + url: "https://www.getmaxim.ai/bifrost", + headline: "Bifrost Docs", + description: + "Bifrost is the fastest LLM gateway in the market, 90x faster than LiteLLM (P99 latency).", + inLanguage: "en", + }, +}; + +function injectJsonLd() { + const script = document.createElement("script"); + script.type = "application/ld+json"; + script.text = JSON.stringify(jsonLd); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + document.head.appendChild(script); + }); + } else { + document.head.appendChild(script); + } + + return () => { + if (script.parentNode) { + script.parentNode.removeChild(script); + } + }; +} + +// Call the function to inject JSON-LD +const cleanup = injectJsonLd(); + +// Cleanup when needed +// cleanup() \ No newline at end of file diff --git a/docs/media/aws-icon.png b/docs/media/aws-icon.png new file mode 100644 index 000000000..627547c13 Binary files /dev/null and b/docs/media/aws-icon.png differ diff --git a/docs/media/azure-icon.png b/docs/media/azure-icon.png new file mode 100644 index 000000000..7c750318d Binary files /dev/null and b/docs/media/azure-icon.png differ diff --git a/docs/media/bifrost-logo-dark.png b/docs/media/bifrost-logo-dark.png new file mode 100644 index 000000000..5049cb85f Binary files /dev/null and b/docs/media/bifrost-logo-dark.png differ diff --git a/docs/media/bifrost-logo.png b/docs/media/bifrost-logo.png new file mode 100644 index 000000000..b47319dc4 Binary files /dev/null and b/docs/media/bifrost-logo.png differ diff --git a/docs/media/cloudflare-icon.png b/docs/media/cloudflare-icon.png new file mode 100644 index 000000000..21f809aed Binary files /dev/null and b/docs/media/cloudflare-icon.png differ diff --git a/docs/media/clustering-diagram.png b/docs/media/clustering-diagram.png new file mode 100644 index 000000000..5b3a5d764 Binary files /dev/null and b/docs/media/clustering-diagram.png differ diff --git a/docs/media/cover.png b/docs/media/cover.png new file mode 100644 index 000000000..b19c328ca Binary files /dev/null and b/docs/media/cover.png differ diff --git a/docs/media/dynamic-plugins-architecture.png b/docs/media/dynamic-plugins-architecture.png new file mode 100644 index 000000000..17ac8b28b Binary files /dev/null and b/docs/media/dynamic-plugins-architecture.png differ diff --git a/docs/media/gcp-icon.png b/docs/media/gcp-icon.png new file mode 100644 index 000000000..2adedff32 Binary files /dev/null and b/docs/media/gcp-icon.png differ diff --git a/docs/media/gcp-icon.svg b/docs/media/gcp-icon.svg new file mode 100644 index 000000000..cb7a2aa70 --- /dev/null +++ b/docs/media/gcp-icon.svg @@ -0,0 +1,11 @@ + + + + + Error 404 (Not Found)!!1 + + +

404. That’s an error. +

The requested URL /devrel-devsite/prod/v2210deb8920cd4a55bd580441aa58e7853afc04b39a9d9ac4798e1aa28e803c49/cloud/images/cloud-logo.svg was not found on this server. That’s all we know. diff --git a/docs/media/getting-started.png b/docs/media/getting-started.png new file mode 100644 index 000000000..c7b2d1d8b Binary files /dev/null and b/docs/media/getting-started.png differ diff --git a/docs/media/grafana-otel-traces.png b/docs/media/grafana-otel-traces.png new file mode 100644 index 000000000..957b5c0f8 Binary files /dev/null and b/docs/media/grafana-otel-traces.png differ diff --git a/docs/media/maxim-logs.png b/docs/media/maxim-logs.png new file mode 100644 index 000000000..c738f8067 Binary files /dev/null and b/docs/media/maxim-logs.png differ diff --git a/docs/media/observability-dashboard.png b/docs/media/observability-dashboard.png new file mode 100644 index 000000000..67b91edcb Binary files /dev/null and b/docs/media/observability-dashboard.png differ diff --git a/docs/media/observability-filters-and-search.png b/docs/media/observability-filters-and-search.png new file mode 100644 index 000000000..26d58f1ab Binary files /dev/null and b/docs/media/observability-filters-and-search.png differ diff --git a/docs/media/opencode-model-selection.png b/docs/media/opencode-model-selection.png new file mode 100644 index 000000000..f962e599c Binary files /dev/null and b/docs/media/opencode-model-selection.png differ diff --git a/docs/media/opencode-with-bifrost.png b/docs/media/opencode-with-bifrost.png new file mode 100644 index 000000000..71d43ec65 Binary files /dev/null and b/docs/media/opencode-with-bifrost.png differ diff --git a/docs/media/otel-ui-setup.png b/docs/media/otel-ui-setup.png new file mode 100644 index 000000000..c65eb3a59 Binary files /dev/null and b/docs/media/otel-ui-setup.png differ diff --git a/docs/media/package-demo.mp4 b/docs/media/package-demo.mp4 new file mode 100644 index 000000000..a7651c07c Binary files /dev/null and b/docs/media/package-demo.mp4 differ diff --git a/docs/media/provider-configs.png b/docs/media/provider-configs.png new file mode 100644 index 000000000..8112b35ac Binary files /dev/null and b/docs/media/provider-configs.png differ diff --git a/docs/media/run-npx.mp4 b/docs/media/run-npx.mp4 new file mode 100644 index 000000000..3521738e6 Binary files /dev/null and b/docs/media/run-npx.mp4 differ diff --git a/docs/media/setting-up-bifrost-for-cursor.png b/docs/media/setting-up-bifrost-for-cursor.png new file mode 100644 index 000000000..542b8bfc9 Binary files /dev/null and b/docs/media/setting-up-bifrost-for-cursor.png differ diff --git a/docs/media/setting-up-dashboard-auth.png b/docs/media/setting-up-dashboard-auth.png new file mode 100644 index 000000000..3fb3ccb25 Binary files /dev/null and b/docs/media/setting-up-dashboard-auth.png differ diff --git a/docs/media/traffic-redistribution.png b/docs/media/traffic-redistribution.png new file mode 100644 index 000000000..fa8278690 Binary files /dev/null and b/docs/media/traffic-redistribution.png differ diff --git a/docs/media/ui-azure-config.png b/docs/media/ui-azure-config.png new file mode 100644 index 000000000..10d31d3c0 Binary files /dev/null and b/docs/media/ui-azure-config.png differ diff --git a/docs/media/ui-bedrock-config.png b/docs/media/ui-bedrock-config.png new file mode 100644 index 000000000..96dfe7c2d Binary files /dev/null and b/docs/media/ui-bedrock-config.png differ diff --git a/docs/media/ui-concurrency-buffer-size.png b/docs/media/ui-concurrency-buffer-size.png new file mode 100644 index 000000000..695223f9d Binary files /dev/null and b/docs/media/ui-concurrency-buffer-size.png differ diff --git a/docs/media/ui-concurrency-timeout.png b/docs/media/ui-concurrency-timeout.png new file mode 100644 index 000000000..a019fe855 Binary files /dev/null and b/docs/media/ui-concurrency-timeout.png differ diff --git a/docs/media/ui-config-direct-keys.png b/docs/media/ui-config-direct-keys.png new file mode 100644 index 000000000..315cc1c8b Binary files /dev/null and b/docs/media/ui-config-direct-keys.png differ diff --git a/docs/media/ui-config.png b/docs/media/ui-config.png new file mode 100644 index 000000000..8ea6ffe88 Binary files /dev/null and b/docs/media/ui-config.png differ diff --git a/docs/media/ui-create-customer.png b/docs/media/ui-create-customer.png new file mode 100644 index 000000000..2f000b063 Binary files /dev/null and b/docs/media/ui-create-customer.png differ diff --git a/docs/media/ui-create-teams.png b/docs/media/ui-create-teams.png new file mode 100644 index 000000000..31860e7bd Binary files /dev/null and b/docs/media/ui-create-teams.png differ diff --git a/docs/media/ui-custom-provider.png b/docs/media/ui-custom-provider.png new file mode 100644 index 000000000..dc075bcf9 Binary files /dev/null and b/docs/media/ui-custom-provider.png differ diff --git a/docs/media/ui-grafana-dashboard.png b/docs/media/ui-grafana-dashboard.png new file mode 100644 index 000000000..88b8a98de Binary files /dev/null and b/docs/media/ui-grafana-dashboard.png differ diff --git a/docs/media/ui-live-log-stream.gif b/docs/media/ui-live-log-stream.gif new file mode 100644 index 000000000..883da06d7 Binary files /dev/null and b/docs/media/ui-live-log-stream.gif differ diff --git a/docs/media/ui-log-filtering.gif b/docs/media/ui-log-filtering.gif new file mode 100644 index 000000000..1cb93a5d4 Binary files /dev/null and b/docs/media/ui-log-filtering.gif differ diff --git a/docs/media/ui-mcp-config.png b/docs/media/ui-mcp-config.png new file mode 100644 index 000000000..df77c9b61 Binary files /dev/null and b/docs/media/ui-mcp-config.png differ diff --git a/docs/media/ui-mcp-tool-config.png b/docs/media/ui-mcp-tool-config.png new file mode 100644 index 000000000..430f9a361 Binary files /dev/null and b/docs/media/ui-mcp-tool-config.png differ diff --git a/docs/media/ui-multi-key-for-models.png b/docs/media/ui-multi-key-for-models.png new file mode 100644 index 000000000..2a049ca4b Binary files /dev/null and b/docs/media/ui-multi-key-for-models.png differ diff --git a/docs/media/ui-multimodal-tracing.png b/docs/media/ui-multimodal-tracing.png new file mode 100644 index 000000000..281a7c0df Binary files /dev/null and b/docs/media/ui-multimodal-tracing.png differ diff --git a/docs/media/ui-observability-maxim.png b/docs/media/ui-observability-maxim.png new file mode 100644 index 000000000..b4f46cbb7 Binary files /dev/null and b/docs/media/ui-observability-maxim.png differ diff --git a/docs/media/ui-observability-otel.png b/docs/media/ui-observability-otel.png new file mode 100644 index 000000000..d7c26626b Binary files /dev/null and b/docs/media/ui-observability-otel.png differ diff --git a/docs/media/ui-prometheus-labels.png b/docs/media/ui-prometheus-labels.png new file mode 100644 index 000000000..57f6db68b Binary files /dev/null and b/docs/media/ui-prometheus-labels.png differ diff --git a/docs/media/ui-proxy-setup.png b/docs/media/ui-proxy-setup.png new file mode 100644 index 000000000..28ee1db59 Binary files /dev/null and b/docs/media/ui-proxy-setup.png differ diff --git a/docs/media/ui-raw-response.png b/docs/media/ui-raw-response.png new file mode 100644 index 000000000..e77e381b0 Binary files /dev/null and b/docs/media/ui-raw-response.png differ diff --git a/docs/media/ui-request-tracing-overview.png b/docs/media/ui-request-tracing-overview.png new file mode 100644 index 000000000..8f88f6b1f Binary files /dev/null and b/docs/media/ui-request-tracing-overview.png differ diff --git a/docs/media/ui-semantic-cache-config.png b/docs/media/ui-semantic-cache-config.png new file mode 100644 index 000000000..b1b2ba6a7 Binary files /dev/null and b/docs/media/ui-semantic-cache-config.png differ diff --git a/docs/media/ui-tracing-config.png b/docs/media/ui-tracing-config.png new file mode 100644 index 000000000..6b2d03a56 Binary files /dev/null and b/docs/media/ui-tracing-config.png differ diff --git a/docs/media/ui-vertex-config.png b/docs/media/ui-vertex-config.png new file mode 100644 index 000000000..8ccec9c3d Binary files /dev/null and b/docs/media/ui-vertex-config.png differ diff --git a/docs/media/ui-virtual-key-keys-filter.png b/docs/media/ui-virtual-key-keys-filter.png new file mode 100644 index 000000000..7df281349 Binary files /dev/null and b/docs/media/ui-virtual-key-keys-filter.png differ diff --git a/docs/media/ui-virtual-key-mcp-filter.png b/docs/media/ui-virtual-key-mcp-filter.png new file mode 100644 index 000000000..cc33515ee Binary files /dev/null and b/docs/media/ui-virtual-key-mcp-filter.png differ diff --git a/docs/media/ui-virtual-key-provider-config.png b/docs/media/ui-virtual-key-provider-config.png new file mode 100644 index 000000000..f7557b2c5 Binary files /dev/null and b/docs/media/ui-virtual-key-provider-config.png differ diff --git a/docs/media/ui-virtual-key-provider-usage-sheet.png b/docs/media/ui-virtual-key-provider-usage-sheet.png new file mode 100644 index 000000000..c562eae23 Binary files /dev/null and b/docs/media/ui-virtual-key-provider-usage-sheet.png differ diff --git a/docs/media/ui-virtual-key-routing.png b/docs/media/ui-virtual-key-routing.png new file mode 100644 index 000000000..8bec0b5cc Binary files /dev/null and b/docs/media/ui-virtual-key-routing.png differ diff --git a/docs/media/ui-virtual-key.png b/docs/media/ui-virtual-key.png new file mode 100644 index 000000000..45365c800 Binary files /dev/null and b/docs/media/ui-virtual-key.png differ diff --git a/docs/media/vercel-icon.png b/docs/media/vercel-icon.png new file mode 100644 index 000000000..7bdcd2a19 Binary files /dev/null and b/docs/media/vercel-icon.png differ diff --git a/docs/media/zed-editor-integration.png b/docs/media/zed-editor-integration.png new file mode 100644 index 000000000..a68446aec Binary files /dev/null and b/docs/media/zed-editor-integration.png differ diff --git a/docs/models-catalog/list.mdx b/docs/models-catalog/list.mdx new file mode 100644 index 000000000..a405a3ba6 --- /dev/null +++ b/docs/models-catalog/list.mdx @@ -0,0 +1,297 @@ +--- +title: "List of Supported Models" +description: "Comprehensive catalog of supported AI models with detailed specifications, capabilities, and costs" +icon: "list" +mode: "wide" +--- + +export const ModelDialog = React.memo(({ model, onClose }) => { + const modelString = `model: "${model.model || 'unknown'}"` + const jsonString = JSON.stringify(model, null, 2) + + const copyModelString = useCallback(() => { + navigator.clipboard.writeText(modelString) + }, [modelString]) + + const copyJson = useCallback(() => { + navigator.clipboard.writeText(jsonString) + }, [jsonString]) + + return ( +

+
e.stopPropagation()} + > +
+

+ Model Details: {model.model || 'Unknown'} +

+ +
+
+
+
+ Use on Bifrost +
+
+ + {modelString} + + +
+
+ +
+
+ Full Configuration (JSON) +
+
+              {jsonString}
+            
+
+
+
+ + +
+
+
+ ) +}) + +export const ModelsCatalog = () => { + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [selectedProvider, setSelectedProvider] = useState('all') + const [selectedModel, setSelectedModel] = useState(null) + const [showDialog, setShowDialog] = useState(false) + + useEffect(() => { + async function fetchModels () { + try { + const response = await fetch('https://getbifrost.ai/datasheet') + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.status}`) + } + const data = await response.json() + + // Convert object format {modelName: {config}} to array format + if (data && typeof data === 'object' && !Array.isArray(data)) { + const modelsArray = Object.entries(data).map(([modelName, config]) => ({ + model: modelName, + ...config + })) + + if (modelsArray.length === 0) { + throw new Error('No models data received') + } + + setModels(modelsArray) + } else if (Array.isArray(data)) { + setModels(data) + } else { + throw new Error('Invalid data format') + } + } catch (err) { + console.error('Fetch error:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + fetchModels() + }, []) + + const providers = useMemo(() => { + const uniqueProviders = new Set() + models.forEach(model => { + if (model.provider) { + uniqueProviders.add(model.provider) + } + }) + return Array.from(uniqueProviders).sort() + }, [models]) + + const filteredModels = useMemo(() => { + let filtered = models + + // Filter by provider + if (selectedProvider !== 'all') { + filtered = filtered.filter(model => model.provider === selectedProvider) + } + + // Filter by search term + if (searchTerm) { + const term = searchTerm.toLowerCase() + filtered = filtered.filter(model => + Object.values(model).some(value => + String(value).toLowerCase().includes(term) + ) + ) + } + + return filtered + }, [models, searchTerm, selectedProvider]) + + const formatColumnName = useCallback((name) => { + // Handle snake_case: replace underscores with spaces + let formatted = name.replace(/_/g, ' ') + // Handle camelCase: add space before capital letters + formatted = formatted.replace(/([A-Z])/g, ' $1') + // Capitalize first letter of each word and trim + return formatted + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') + .trim() + }, []) + + const handleRowClick = useCallback((model) => { + setSelectedModel(model) + setShowDialog(true) + }, []) + + if (loading) { + return ( +
+
Loading models...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+
Check console for details
+
+ ) + } + + if (!models || models.length === 0) { + return ( +
+
No models available
+
+ ) + } + + + return ( +
+
+
+ setSearchTerm(e.target.value)} + className="flex-1 px-3 py-2 text-base border-2 border-zinc-950/20 dark:border-white/20 rounded-lg focus:outline-none focus:border-[#0C3B43] dark:focus:border-[#07C983] transition-colors bg-white dark:bg-zinc-950 text-zinc-950 dark:text-white" + /> + +
+
+ Showing {filteredModels.length} of {models.length} models +
+
+ +
+ + + + + + + + + + {filteredModels.length === 0 && searchTerm ? ( + + + + ) : ( + filteredModels.map((model, idx) => ( + + + + + + )) + )} + +
+ Provider + + Model + + Details +
+ No models found matching "{searchTerm}" +
+ {model.provider || 'β€”'} + + {model.model || 'β€”'} + + +
+
+ + {showDialog && selectedModel && setShowDialog(false)} />} +
+ ) +} + + + diff --git a/docs/models-catalog/table.html b/docs/models-catalog/table.html new file mode 100644 index 000000000..1b3f23a6c --- /dev/null +++ b/docs/models-catalog/table.html @@ -0,0 +1,640 @@ + + + + + + Models Catalog + + + +
+
+
+
Loading models...
+
+
+ + + + + diff --git a/docs/plugins/getting-started.mdx b/docs/plugins/getting-started.mdx new file mode 100644 index 000000000..ae6d6a21b --- /dev/null +++ b/docs/plugins/getting-started.mdx @@ -0,0 +1,80 @@ +--- +title: "Getting Started" +description: "Learn how to extend Bifrost's functionality by creating custom plugins that intercept and modify requests and responses." +icon: "book" +--- + +## What are Bifrost Plugins? + +Bifrost plugins allow you to extend the gateway's functionality by intercepting requests and responses. Plugins can modify, log, validate, or enrich data as it flows through the system, giving you powerful hooks into Bifrost's request lifecycle. + +## Use Cases + +Custom plugins enable you to: + +- **Transform requests and responses** - Modify data before it reaches providers or after it returns +- **Add custom validation** - Enforce business rules on incoming requests +- **Implement custom caching** - Cache responses based on custom logic +- **Integrate with external systems** - Send data to logging, monitoring, or analytics platforms +- **Apply custom transformations** - Parse, filter, or enrich LLM responses + +## Plugin Architecture + +![architecture](../media/dynamic-plugins-architecture.png) + +Bifrost leverages **Go's native plugin system** to enable dynamic extensibility. Plugins are built as **shared object files** (`.so` files) that are loaded at runtime by the Bifrost gateway. + +### How Go Plugins Work + +Go plugins use the `plugin` package from the standard library, which allows Go programs to dynamically load code at runtime. Here's what makes this approach powerful: + +- **Native Go Integration** - Plugins are written in Go and have full access to Bifrost's type system and interfaces +- **Dynamic Loading** - Plugins can be loaded, unloaded, and reloaded without restarting Bifrost +- **Type Safety** - Go's type system ensures plugin methods match expected signatures +- **Performance** - No IPC overhead; plugins run in the same process as Bifrost + +### Building Shared Objects + +Plugins must be compiled as shared objects using Go's `-buildmode=plugin` flag: + +```bash +go build -buildmode=plugin -o myplugin.so main.go +``` + +This generates a `.so` file that exports specific functions matching Bifrost's plugin interface: + +- `Init(config any) error` - Initialize the plugin with configuration +- `GetName() string` - Return the plugin name +- `PreHook()` - Intercept requests before they reach providers +- `PostHook()` - Process responses after provider calls +- `TransportInterceptor()` - Modify raw HTTP headers/body (HTTP transport only) +- `Cleanup() error` - Clean up resources on shutdown + +### Platform Requirements + +**Important Limitations:** + +- **Supported Platforms**: Linux and macOS (Darwin) only +- **No Cross-Compilation**: Plugins must be built on the target platform +- **Architecture Matching**: Plugin and Bifrost must use the same architecture (amd64, arm64) +- **Go Version Compatibility**: Plugin must be built with the same Go version as Bifrost + +This means if you're running Bifrost on Linux AMD64, you must build your plugin on Linux AMD64 with the same Go version. + +### Plugin Lifecycle + +1. **Load** - Bifrost loads the `.so` file using Go's `plugin.Open()` +2. **Initialize** - Calls `Init()` with configuration from `config.json` +3. **Hook Execution** - Calls `PreHook()` and `PostHook()` for each request +4. **Cleanup** - Calls `Cleanup()` when Bifrost shuts down + +Plugins execute in a specific order: +1. `TransportInterceptor` - Modifies raw HTTP requests (HTTP transport only) +2. `PreHook` - Executes in registration order, can short-circuit requests +3. Provider call (if not short-circuited) +4. `PostHook` - Executes in reverse order of PreHooks + +## Next Steps + +Ready to build your first plugin? Continue to [Writing Plugins](./writing-plugin) to learn how to create, build, and deploy custom plugins for Bifrost. + diff --git a/docs/plugins/writing-plugin.mdx b/docs/plugins/writing-plugin.mdx new file mode 100644 index 000000000..75aa975c2 --- /dev/null +++ b/docs/plugins/writing-plugin.mdx @@ -0,0 +1,701 @@ +--- +title: "Writing Plugins" +description: "Step-by-step guide to creating custom plugins for Bifrost using the hello-world example" +icon: "code" +--- + +## Overview + +This guide walks you through creating a custom plugin for Bifrost using our [hello-world example](https://github.com/maximhq/bifrost/tree/main/examples/plugins/hello-world) as a reference. You'll learn how to structure your plugin, implement required functions, build the shared object, and integrate it with Bifrost. + +## Prerequisites + +Before you start, ensure you have: + +- **Go 1.24+** installed (must match Bifrost's Go version) +- **Linux or macOS** (Go plugins are not supported on Windows) +- **Bifrost** installed and configured +- Basic understanding of Go programming + +## Project Structure + +A minimal plugin project should have the following structure: + +``` +hello-world/ +β”œβ”€β”€ main.go # Plugin implementation +β”œβ”€β”€ go.mod # Go module definition +β”œβ”€β”€ go.sum # Dependency checksums +β”œβ”€β”€ Makefile # Build automation +└── .gitignore # Git ignore patterns +``` + +## Step 1: Initialize Your Plugin Project + +Create a new directory and initialize a Go module: + +```bash +mkdir my-plugin +cd my-plugin +go mod init github.com/yourusername/my-plugin +``` + +Add Bifrost as a dependency: + +```bash +go get github.com/maximhq/bifrost/core@latest +``` + +Your `go.mod` should look like this: + +```go +module github.com/yourusername/my-plugin + +go 1.24.0 + +require github.com/maximhq/bifrost/core v1.2.17 +``` + +## Step 2: Implement the Plugin Interface + +Create `main.go` with the required plugin functions. Here's the complete hello-world example: + +```go +package main + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" +) + +// Init is called when the plugin is loaded +// config contains the plugin configuration from config.json +func Init(config any) error { + fmt.Println("Init called") + // Initialize your plugin here (database connections, API clients, etc.) + return nil +} + +// GetName returns the plugin's unique identifier +func GetName() string { + return "Hello World Plugin" +} + +// TransportInterceptor modifies raw HTTP headers and body +// Only called when using HTTP transport (bifrost-http) +func TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + fmt.Println("TransportInterceptor called") + // Modify headers or body before they enter Bifrost core + return headers, body, nil +} + +// PreHook is called before the request is sent to the provider +// This is where you can modify requests or short-circuit the flow +func PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + fmt.Println("PreHook called") + // Modify the request or return a short-circuit to skip provider call + return req, nil, nil +} + +// PostHook is called after receiving a response from the provider +// This is where you can modify responses or handle errors +func PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + fmt.Println("PostHook called") + // Modify the response or error before returning to caller + return resp, bifrostErr, nil +} + +// Cleanup is called when Bifrost shuts down +func Cleanup() error { + fmt.Println("Cleanup called") + // Clean up resources (close connections, flush buffers, etc.) + return nil +} +``` + +### Understanding Each Function + +#### `Init(config any) error` + +Called once when the plugin is loaded. Use this to: +- Parse plugin configuration +- Initialize database connections +- Set up API clients +- Validate required environment variables + +```go +func Init(config any) error { + // Parse configuration + cfg, ok := config.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid config format") + } + + apiKey := cfg["api_key"].(string) + // Initialize your resources + return nil +} +``` + +#### `GetName() string` + +Returns a unique identifier for your plugin. This name appears in logs and status reports. + +#### `TransportInterceptor(...)` + +**HTTP transport only.** Called before requests enter Bifrost core. Use this to: +- Add or modify HTTP headers +- Transform request body +- Implement authentication at the transport layer + + +This function is **only called** when using `bifrost-http`. It's **not invoked** when using Bifrost as a Go SDK. + + +#### `PreHook(...)` + +Called before each provider request. Use this to: +- Modify request parameters +- Add logging or monitoring +- Implement caching (check cache, return cached response) +- Apply governance rules (rate limiting, budget checks) +- **Short-circuit** to skip provider calls + +**Short-Circuiting Example:** + +```go +func PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + // Return cached response without calling provider + if cachedResponse := checkCache(req) { + return req, &schemas.PluginShortCircuit{ + Response: cachedResponse, + }, nil + } + return req, nil, nil +} +``` + +#### `PostHook(...)` + +Called after provider responses (or short-circuits). Use this to: +- Transform responses +- Log response data +- Store responses in cache +- Handle errors or implement fallback logic +- Add custom metadata + +**Response Transformation Example:** + +```go +func PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + if resp != nil && resp.ChatResponse != nil { + // Add custom metadata + resp.ChatResponse.ExtraFields.RawResponse = map[string]interface{}{ + "plugin_processed": true, + "timestamp": time.Now().Unix(), + } + } + return resp, bifrostErr, nil +} +``` + +#### `Cleanup() error` + +Called on Bifrost shutdown. Use this to: +- Close database connections +- Flush buffers +- Save state +- Release resources + +## Step 3: Create a Makefile + +Create a `Makefile` to automate building your plugin: + +```makefile +.PHONY: all build clean install help + +PLUGIN_NAME = my-plugin +OUTPUT_DIR = build + +# Platform detection +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Linux) + PLUGIN_EXT = .so + PLATFORM = linux +endif +ifeq ($(UNAME_S),Darwin) + PLUGIN_EXT = .so + PLATFORM = darwin +endif + +# Architecture detection +UNAME_M := $(shell uname -m) +ifeq ($(UNAME_M),x86_64) + ARCH = amd64 +endif +ifeq ($(UNAME_M),arm64) + ARCH = arm64 +endif + +OUTPUT = $(OUTPUT_DIR)/$(PLUGIN_NAME)$(PLUGIN_EXT) + +build: ## Build the plugin for current platform + @echo "Building plugin for $(PLATFORM)/$(ARCH)..." + @mkdir -p $(OUTPUT_DIR) + go build -buildmode=plugin -o $(OUTPUT) main.go + @echo "Plugin built successfully: $(OUTPUT)" + +clean: ## Remove build artifacts + @rm -rf $(OUTPUT_DIR) + +install: build ## Build and install to Bifrost plugins directory + @mkdir -p ~/.bifrost/plugins + @cp $(OUTPUT) ~/.bifrost/plugins/ + @echo "Plugin installed to ~/.bifrost/plugins/" +``` + +## Step 4: Build Your Plugin + +Build the plugin using the Makefile: + +```bash +make build +``` + +This creates `build/my-plugin.so` in your project directory. + +For production, you may need to build for specific platforms: + +```bash +# Build for Linux AMD64 +GOOS=linux GOARCH=amd64 go build -buildmode=plugin -o my-plugin-linux-amd64.so main.go + +# Build for Linux ARM64 +GOOS=linux GOARCH=arm64 go build -buildmode=plugin -o my-plugin-linux-arm64.so main.go + +# Build for macOS ARM64 (M1/M2) +GOOS=darwin GOARCH=arm64 go build -buildmode=plugin -o my-plugin-darwin-arm64.so main.go +``` + + +**Cross-compilation doesn't work for plugins!** You must build on the target platform. If you need a Linux plugin, build it on a Linux machine or use Docker. + + +## Step 5: Configure Bifrost to Load Your Plugin + +Add your plugin to Bifrost's `config.json`: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "my-plugin", + "path": "/path/to/my-plugin.so", + "config": { + "api_key": "your-api-key", + "custom_setting": "value" + } + } + ] +} +``` + +### Plugin Configuration Options + +- `enabled` - Set to `true` to load the plugin +- `name` - Plugin identifier (used in logs) +- `path` - Absolute or relative path to the `.so` file +- `config` - Plugin-specific configuration passed to `Init()` + +## Step 6: Test Your Plugin + +Start Bifrost and verify your plugin loads: + +```bash +./bifrost-http +``` + +You should see output like: + +``` +Init called +[INFO] Plugin loaded: Hello World Plugin +``` + +Make a test request: + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +Check the logs for plugin hook calls: + +``` +TransportInterceptor called +PreHook called +PostHook called +``` + +## Advanced Plugin Patterns + +### Stateful Plugins + +For plugins that need to maintain state across requests: + +```go +package main + +import ( + "context" + "sync" + "github.com/maximhq/bifrost/core/schemas" +) + +var ( + requestCount int64 + mu sync.Mutex +) + +func PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + mu.Lock() + requestCount++ + count := requestCount + mu.Unlock() + + // Use count for rate limiting, metrics, etc. + return req, nil, nil +} +``` + +### Error Handling with Fallbacks + +Control whether Bifrost should try fallback providers: + +```go +func PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + if bifrostErr != nil { + // Allow fallbacks for rate limit errors + if bifrostErr.Error.Type != nil && *bifrostErr.Error.Type == "rate_limit" { + allowFallbacks := true + bifrostErr.AllowFallbacks = &allowFallbacks + } else { + // Don't try fallbacks for auth errors + allowFallbacks := false + bifrostErr.AllowFallbacks = &allowFallbacks + } + } + return resp, bifrostErr, nil +} +``` + +### Caching Plugin Example + +```go +var cache sync.Map + +func PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + // Generate cache key from request + key := generateCacheKey(req) + + // Check cache + if cached, ok := cache.Load(key); ok { + return req, &schemas.PluginShortCircuit{ + Response: cached.(*schemas.BifrostResponse), + }, nil + } + + return req, nil, nil +} + +func PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + if resp != nil && bifrostErr == nil { + // Store in cache + key := generateCacheKeyFromResponse(resp) + cache.Store(key, resp) + } + return resp, bifrostErr, nil +} +``` + +## Troubleshooting + +### Plugin Fails to Load + +**Error**: `plugin: not a plugin file` + +**Solution**: Ensure you built with `-buildmode=plugin`: +```bash +go build -buildmode=plugin -o plugin.so main.go +``` + +### Version Mismatch Errors + +**Error**: `plugin was built with a different version of package` + +**Solution**: Rebuild your plugin with the exact same Go version as Bifrost: +```bash +go version # Check your Go version +# Rebuild with matching version +``` + +### Platform/Architecture Mismatch + +**Error**: `cannot load plugin built for GOOS=linux on darwin` + +**Solution**: Build on the target platform or use the correct GOOS/GOARCH for your system. + +### Function Not Found + +**Error**: `plugin: symbol Init not found` + +**Solution**: Ensure all required functions are exported (start with capital letter) and have the correct signature. + +## Source Code Reference + +The complete hello-world example is available in the Bifrost repository: + +- **Full Example**: [examples/plugins/hello-world](https://github.com/maximhq/bifrost/tree/main/examples/plugins/hello-world) +- **main.go**: [Plugin implementation](https://github.com/maximhq/bifrost/blob/main/examples/plugins/hello-world/main.go) +- **Makefile**: [Build configuration](https://github.com/maximhq/bifrost/blob/main/examples/plugins/hello-world/Makefile) +- **go.mod**: [Dependencies](https://github.com/maximhq/bifrost/blob/main/examples/plugins/hello-world/go.mod) + +## Real-World Plugin Examples + +Explore production-ready plugins in the Bifrost repository: + +- **[Mocker Plugin](https://github.com/maximhq/bifrost/tree/main/plugins/mocker)** - Mock responses for testing +- **[Logging Plugin](https://github.com/maximhq/bifrost/tree/main/plugins/logging)** - Advanced request/response logging +- **[Semantic Cache Plugin](https://github.com/maximhq/bifrost/tree/main/plugins/semanticcache)** - Cache based on semantic similarity +- **[Governance Plugin](https://github.com/maximhq/bifrost/tree/main/plugins/governance)** - Rate limiting and budget controls +- **[JSON Parser Plugin](https://github.com/maximhq/bifrost/tree/main/plugins/jsonparser)** - Parse and validate JSON responses + +## Frequently Asked Questions + +### Do I need to rebuild my plugin when upgrading Bifrost? + +**Yes, absolutely.** Plugins must be compiled against the exact same version of `github.com/maximhq/bifrost/core` that Bifrost is using. This is a fundamental requirement of Go's plugin system. + +When you upgrade Bifrost, you must: +1. Update your plugin's `go.mod` to use the matching core version +2. Rebuild the plugin with the same Go version +3. Redeploy the plugin alongside the new Bifrost version + +**Example:** + +If upgrading from Bifrost v1.2.17 to v1.3.0: + +```bash +# Update your plugin dependency +go get github.com/maximhq/bifrost/core@v1.3.0 +go mod tidy + +# Rebuild the plugin +go build -buildmode=plugin -o my-plugin.so main.go +``` + + +**Version mismatch will cause runtime errors!** If your plugin is compiled with v1.2.17 but Bifrost is running v1.3.0, the plugin will fail to load with cryptic errors about package versions. + + +### Should plugin builds be part of my deployment pipeline? + +**Yes, strongly recommended.** Your plugin build and deployment should be tightly coupled with your Bifrost deployment. + +**Recommended CI/CD Workflow:** + +```yaml +# Example GitHub Actions workflow +name: Deploy Bifrost with Plugins + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # 1. Checkout code + - uses: actions/checkout@v3 + + # 2. Setup Go + - uses: actions/setup-go@v4 + with: + go-version: '1.24' + + # 3. Build Bifrost + - name: Build Bifrost + run: | + cd transports/bifrost-http + go build -o bifrost-http + + # 4. Build ALL plugins with matching version + - name: Build Plugins + run: | + cd plugins/my-plugin + # Ensure plugin uses same core version as Bifrost + go get github.com/maximhq/bifrost/core@${{ env.BIFROST_VERSION }} + go mod tidy + go build -buildmode=plugin -o my-plugin.so main.go + + # 5. Bundle everything together + - name: Create deployment bundle + run: | + mkdir -p deploy/plugins + cp transports/bifrost-http/bifrost-http deploy/ + cp plugins/my-plugin/my-plugin.so deploy/plugins/ + cp config.json deploy/ + + # 6. Deploy bundle to your infrastructure + - name: Deploy to Production + run: | + # Upload to S3, copy to servers, deploy to K8s, etc. + ./deploy.sh +``` + +**Key Principles:** + +1. **Version Lock** - Pin your plugin dependencies to specific Bifrost versions +2. **Atomic Deployment** - Deploy Bifrost and plugins together as a single unit +3. **Build Verification** - Test plugin loading as part of CI +4. **Rollback Strategy** - Keep previous plugin versions for rollbacks + +### How do I handle plugin versioning in production? + +Organize your plugin deployments by version: + +``` +/opt/bifrost/ +β”œβ”€β”€ v1.3.0/ +β”‚ β”œβ”€β”€ bifrost-http +β”‚ └── plugins/ +β”‚ β”œβ”€β”€ my-plugin.so +β”‚ └── cache-plugin.so +β”œβ”€β”€ v1.2.17/ +β”‚ β”œβ”€β”€ bifrost-http +β”‚ └── plugins/ +β”‚ β”œβ”€β”€ my-plugin.so +β”‚ └── cache-plugin.so +└── current -> v1.3.0/ # Symlink to active version +``` + +This allows easy rollbacks: + +```bash +# Rollback to previous version +ln -sfn /opt/bifrost/v1.2.17 /opt/bifrost/current +systemctl restart bifrost +``` + +### Can I use different plugin versions for different Bifrost instances? + +**No.** Each plugin must match the exact core version of the Bifrost instance loading it. If you're running multiple Bifrost versions (e.g., staging vs production), you need separate plugin builds for each version. + +``` +staging/ + bifrost-http (v1.3.0) + plugins/ + my-plugin-v1.3.0.so + +production/ + bifrost-http (v1.2.17) + plugins/ + my-plugin-v1.2.17.so +``` + +### What happens if I forget to rebuild a plugin? + +You'll see errors like: + +``` +plugin: symbol Init not found in plugin github.com/you/plugin +plugin was built with a different version of package github.com/maximhq/bifrost/core +``` + +**Solution:** Rebuild the plugin with the correct core version. + +### How do I test plugins before production deployment? + +**Multi-stage testing approach:** + +1. **Unit Tests** - Test plugin logic in isolation + ```go + func TestPreHook(t *testing.T) { + req := &schemas.BifrostRequest{...} + modifiedReq, shortCircuit, err := PreHook(&ctx, req) + assert.NoError(t, err) + assert.Nil(t, shortCircuit) + } + ``` + +2. **Integration Tests** - Load plugin in test Bifrost instance + ```bash + # Start test Bifrost with plugin + ./bifrost-http --config test-config.json + + # Run test requests + curl -X POST http://localhost:8080/v1/chat/completions ... + ``` + +3. **Staging Environment** - Deploy to staging with production-like load + +4. **Canary Deployment** - Gradually roll out to production + +### Can I hot-reload plugins without restarting Bifrost? + +**Yes!** Bifrost supports hot-reloading plugins at runtime. You can update plugin configurations or reload plugin code without restarting the entire Bifrost instance. + +### How do I debug plugin loading issues? + +**Enable verbose logging:** + +```json +{ + "log_level": "debug", + "plugins": [ + { + "enabled": true, + "name": "my-plugin", + "path": "./plugins/my-plugin.so", + "config": {} + } + ] +} +``` + +**Check plugin symbols:** + +```bash +# List symbols exported by plugin +go tool nm my-plugin.so | grep -E 'Init|GetName|PreHook' +``` + +**Verify Go version:** + +```bash +# Check Go version used to build plugin +go version -m my-plugin.so +``` + +**Common debugging steps:** + +1. Verify file exists and has correct permissions +2. Check Go version matches Bifrost +3. Confirm core package version matches +4. Ensure all required symbols are exported +5. Review Bifrost logs for detailed error messages + +## Need Help? + +- **Discord Community**: [Join our Discord](https://getmax.im/bifrost-discord) +- **GitHub Issues**: [Report bugs or request features](https://github.com/maximhq/bifrost/issues) +- **Documentation**: [Browse all docs](/) + diff --git a/docs/quickstart/README.mdx b/docs/quickstart/README.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/quickstart/gateway/cli-agents.mdx b/docs/quickstart/gateway/cli-agents.mdx new file mode 100644 index 000000000..11f0076e1 --- /dev/null +++ b/docs/quickstart/gateway/cli-agents.mdx @@ -0,0 +1,265 @@ +--- +title: "Tools, Editors & CLI Agents" +description: "Use Bifrost with tools like LibreChat, Claude Code, Codex CLI and Qwen Code by just changing the base URL and unlock advanced features." +icon: "robot" +--- + +## Overview + +Bifrost provides **100% compatible endpoints** for OpenAI, Anthropic, and Gemini APIs, making it seamless to integrate with any agent that uses these providers. By simply pointing your agent's base URL to Bifrost, you unlock powerful features like: + +- **Universal Model Access**: Use **any provider/model** configured in Bifrost with any agent (e.g., use GPT-5 with Claude Code, or Claude Sonnet 4.5 with Codex CLI) +- **MCP Tools Integration**: All Model Context Protocol tools configured in Bifrost become available to your agents +- **Built-in Observability**: Monitor all agent interactions in real-time through Bifrost's logging dashboard +- **Load Balancing**: Automatically distribute requests across multiple providers and regions +- **Advanced Features**: Governance, caching, failover, and more - all transparent to your agent + +## Example Integrations + +### [LibreChat](https://github.com/danny-avila/LibreChat) + +It is a modern, open-source chat client that supports multiple providers. + +**Setup:** + +1. **Install LibreChat:** There are multiple ways of local setup, please follow the [LibreChat documentation](https://www.librechat.ai/docs/local) for more details. + +2. **Add Bifrost as a custom provider**: Now that you have LibreChat installed, you can add Bifrost as a custom provider. + + Add the following to your `librechat.yaml` file: + ```yaml + custom: + - name: "Bifrost" + apiKey: "dummy" # Add the authentication key if login is enabled, otherwise add a placeholder + baseURL: "http://host.docker.internal:8080/v1" # Or localhost:8080 if running locally, or {your-bifrost-container}:8080 if running in the same docker network + models: + default: ["openai/gpt-4o"] # Replace with the model you want to use + fetch: true + titleConvo: true + titleModel: "openai/gpt-4o" # Replace with the model you want to use for chat title generation + summarize: false # Set to true if you want to enable chat summary generation + summaryModel: "openai/gpt-4o" # Replace with the model you want to use for chat summary generation + forcePrompt: false # Set to true if you want to enable force prompt generation + modelDisplayLabel: "Bifrost" + iconURL: https://getbifrost.ai/bifrost-logo.png + ``` + + + If you're running LibreChat in a docker container, LibreChat does not automatically use the `librechat.yaml` file, please check the Step 1 of the [LibreChat documentation](https://www.librechat.ai/docs/quick_start/custom_endpoints#step-1-create-or-edit-a-docker-override-file) for more details. + + +3. **Run LibreChat** + + Now you can start using Bifrost as a provider in LibreChat, with all the features of Bifrost. + +### [Claude Code](https://www.claude.com/product/claude-code) + +It brings Sonnet 4.5 directly to your terminal with powerful coding capabilities. + +**Setup:** + +1. **Install Claude Code** + ```bash + npm install -g @anthropic-ai/claude-code + ``` + +2. **Configure Environment Variables** + ```bash + export ANTHROPIC_API_KEY=dummy-key # Handled by Bifrost (only set when using API key authentication) + export ANTHROPIC_BASE_URL=http://localhost:8080/anthropic + ``` + +3. **Run Claude Code** + ```bash + claude + ``` + +Now all Claude Code traffic flows through Bifrost, giving you access to any provider/model configured in your Bifrost setup, plus MCP tools and observability. + + +This setup automatically detects if you're using Anthropic MAX account instead of a regular API key authentication :) + + +### [Codex CLI](https://developers.openai.com/codex/cli/) + +It provides powerful code generation and completion capabilities. + +**Setup:** + +1. **Install Codex CLI** + ```bash + npm install -g @openai/codex + ``` + +2. **Configure Base URL** + ```bash + export OPENAI_BASE_URL=http://localhost:8080/openai + ``` + +3. **Run Codex** + ```bash + codex + ``` + +### [Qwen Code](https://github.com/QwenLM/qwen-code) + +It is Alibaba's powerful coding assistant with advanced reasoning capabilities. + +**Setup:** + +1. **Install Qwen Code** + ```bash + npm install -g @qwen-code/qwen-code + ``` + +2. **Configure Base URL** + ```bash + export OPENAI_BASE_URL=http://localhost:8080/openai + ``` + +3. **Run Qwen Code** + ```bash + qwen + ``` + +### [Opencode](https://github.com/sst/opencode) + +![opencode with Bifrost](../../media/opencode-with-bifrost.png) + + +**Setup** + +1. **Configure Bifrost** + +```json +{ + "$schema": "https://opencode.ai/config.json", + // Theme configuration + "theme": "opencode", + "autoupdate": true, + "provider": { + "openai": { + "name": "Bifrost", + "options": { + "baseURL": "http://localhost:8080/openai", + "apiKey": "{{virtual-key-if-enabled}}" + }, + "models": { + "openai/gpt-5": { + "options": { + "reasoningEffort": "high", + "textVerbosity": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ], + }, + }, + "anthropic/claude-sonnet-4-5-20250929": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000, + }, + }, + }, + }, + } + } +} +``` + +2. Select Bifrost models using ctrl+p + +![Opencode model selection](../../media/opencode-model-selection.png) + +## Editors + +### [Zed editor](https://zed.dev/) + +![Zed editor](../../media/zed-editor-integration.png) + +1. **Configure Bifrost provider.** + +```json {4} + "language_models": { + "openai_compatible": { + "Bifrost": { + "api_url": "{{bifrost-base-url}}/openai", + "available_models": [ + { + "name": "anthropic/claude-sonnet-4.5", + "max_tokens": 200000, + "max_output_tokens": 4096, + "capabilities": { + "tools": true, + "images": true, + "parallel_tool_calls": true, + "prompt_cache_key": false + } + }, + { + "name": "openai/gpt-4o", + "max_tokens": 128000, + "max_output_tokens": 4096, + "capabilities": { + "tools": true, + "images": true, + "parallel_tool_calls": true, + "prompt_cache_key": false + } + }, + { + "name": "openai/gpt-5", + "max_tokens": 256000, + "max_output_tokens": 4096, + "capabilities": { + "tools": true, + "images": true, + "parallel_tool_calls": true, + "prompt_cache_key": false + } + } + ] + } + } + } +``` + +2. **Reload workspace** to make sure Zed editor recognizes and reloads the provider list. + +## Configuration + +Agent integrations work with your existing Bifrost configuration. Ensure you have: + +- **Providers configured**: See [Provider Configuration](./provider-configuration) for setup details +- **Optional: MCP tools**: See [MCP Integration](../../features/mcp) to enhance agent capabilities + +## Monitoring Agent Traffic + +All agent interactions are automatically logged and can be monitored at `http://localhost:8080/logs`. You can filter by provider, model, or search through conversation content to track your agents' performance. + +![Agent Monitoring](../../media/ui-live-log-stream.gif) +For complete monitoring capabilities, see [Built-in Observability](../../features/observability/default). + +## MCP Tools Integration + +Bifrost automatically sends all configured MCP tools to your agents. This means your agents can access filesystem operations, database queries, web search, and more without any additional configuration. + + +**Important: MCP Tool Execution Behavior** + +When using Bifrost as a gateway, MCP tool calls require manual approval and execution for security reasons. Bifrost returns the tool call information but doesn't automatically execute it. You need to handle the approval and execution logic by calling the `v1/mcp/tool/execute` endpoint. + +**Gateway-on-Gateway Limitations**: If your agent/editor (like Zed) has its own gateway that routes through Bifrost, the agent's gateway may not handle MCP tool approvals that come from Bifrost. In such cases, we recommend configuring MCP tools directly in your agent/editor instead of relying on Bifrost's MCP integration. + +We intentionally avoid supporting "gateway-on-gateway" MCP setups because handling tool approvals across multiple gateways introduces unnecessary complexity and falls outside the scope of what an LLM gateway should manage. While we're working on an agentic mode that will allow Bifrost to automatically execute certain tool calls, the current design prioritizes security and clear responsibility boundaries. + + +For setup and available tools, see [MCP Integration](../../features/mcp). + +## Next Steps + +- **[Provider Configuration](./provider-configuration)** - Configure AI providers for your agents +- **[Governance](../../features/governance)** - Set usage limits and policies for your agents +- **[Integrations](../../integrations/what-is-an-integration)** - Understand how Bifrost works with existing AI provider SDKs \ No newline at end of file diff --git a/docs/quickstart/gateway/integrations.mdx b/docs/quickstart/gateway/integrations.mdx new file mode 100644 index 000000000..03ea69f23 --- /dev/null +++ b/docs/quickstart/gateway/integrations.mdx @@ -0,0 +1,69 @@ +--- +title: "Integrations" +description: "Use Bifrost as a drop-in replacement for existing AI provider SDKs with zero code changes. Just change the base URL and unlock advanced features." +icon: "plug" +--- + +## What are Integrations? + +Integrations are protocol adapters that make Bifrost **100% compatible** with existing AI provider SDKs. They translate between provider-specific API formats (OpenAI, Anthropic, Google GenAI) and Bifrost's unified API, enabling you to: + +- **Drop-in replacement** - Change only the base URL in your existing code +- **Zero migration effort** - Keep your current SDK and request/response handling +- **Instant feature access** - Get governance, caching, fallbacks, and monitoring without code changes + +## Quick Example + +### Before (Direct Provider) +```python +import openai + +client = openai.OpenAI( + api_key="your-openai-key" +) +``` + +### After (Bifrost Integration) +```python +import openai + +client = openai.OpenAI( + base_url="http://localhost:8080/openai", # Point to Bifrost + api_key="dummy-key" # Keys handled by Bifrost +) +``` + +**That's it!** Your application now has automatic fallbacks, governance, monitoring, and all Bifrost features. + +## Available Integrations + +Bifrost provides complete compatibility with these popular AI SDKs: + +- **[OpenAI SDK](../../integrations/openai-sdk)** +- **[Anthropic SDK](../../integrations/anthropic-sdk)** +- **[Google GenAI SDK](../../integrations/genai-sdk)** +- **[LiteLLM](../../integrations/litellm-sdk)** +- **[LangChain](../../integrations/langchain-sdk)** + +## Learn More + +For detailed setup guides, compatibility information, and advanced usage: + +**➜ [Complete Integration Documentation](../../integrations/what-is-an-integration)** + +## Next Steps + +Now that you understand integrations, explore these related topics: + +### Essential Topics + +- **[Provider Configuration](./provider-configuration)** - Set up multiple AI providers for redundancy +- **[Tool Calling](./tool-calling)** - Enable AI models to use external functions +- **[Streaming Responses](./streaming)** - Real-time response generation +- **[Multimodal AI](./multimodal)** - Process images, audio, and multimedia content + +### Advanced Topics + +- **[Core Features](../../features/)** - Governance, caching, and observability +- **[Architecture](../../architecture/)** - How Bifrost works internally +- **[Deployment](../../deployment/)** - Production setup and scaling diff --git a/docs/quickstart/gateway/multimodal.mdx b/docs/quickstart/gateway/multimodal.mdx new file mode 100644 index 000000000..6a260b485 --- /dev/null +++ b/docs/quickstart/gateway/multimodal.mdx @@ -0,0 +1,314 @@ +--- +title: "Multimodal Support" +description: "Process multiple types of content including images, audio, and text with AI models. Bifrost supports vision analysis, speech synthesis, and audio transcription across various providers." +icon: "images" +--- + +## Vision: Analyzing Images with AI + +Send images to vision-capable models for analysis, description, and understanding. This example shows how to analyze an image from a URL using GPT-4o with high detail processing for better accuracy. + +```bash +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What do you see in this image? Please describe it in detail." + }, + { + "type": "image_url", + "image_url": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + "detail": "high" + } + } + ] + } + ] +}' +``` + +**Response includes detailed image analysis:** +```json +{ + "choices": [{ + "message": { + "role": "assistant", + "content": "I can see a beautiful wooden boardwalk extending through a natural landscape..." + } + }] +} +``` + +## Audio Understanding: Analyzing Audio with AI + +If your chat application supports text input, you can add audio input and outputβ€”just include audio in the modalities array and use an audio model, like gpt-4o-audio-preview. + +### Audio Input to Model + +```bash +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-audio-preview", + "modalities": ["text"], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Please analyze this audio recording and summarize what was discussed." + }, + { + "type": "input_audio", + "input_audio": { + "data": "", + "format": "wav" + } + } + ] + } + ] +}' +``` + +### Audio Output from Model + +```bash +{ + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "The audio recording captured a brief segment where a speaker simply said \"Affirmative\" in response. There wasn't any detailed discussion or context provided beyond that one-word affirmation. If you have more audio or specific questions, feel free to share!" + } + } + ] +} +``` + +## Text-to-Speech: Converting Text to Audio + +Convert text into natural-sounding speech using AI voice models. This example demonstrates generating an MP3 audio file from text using the "alloy" voice. The result is returned as binary audio data. + +```bash +curl --location 'http://localhost:8080/v1/audio/speech' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/tts-1", + "input": "Hello! This is a sample text that will be converted to speech using Bifrost speech synthesis capabilities. The weather today is wonderful, and I hope you are having a great day!", + "voice": "alloy", + "response_format": "mp3" +}' \ +--output "output.mp3" +``` + +**Save audio to file:** +```bash +# The --output flag saves the binary audio data directly to a file +# File size will vary based on input text length +``` + +## Speech-to-Text: Transcribing Audio Files + +Convert audio files into text using AI transcription models. This example shows how to transcribe an MP3 file using OpenAI's Whisper model, with an optional context prompt to improve accuracy. + +```bash +curl --location 'http://localhost:8080/v1/audio/transcriptions' \ +--form 'file=@"output.mp3"' \ +--form 'model="openai/whisper-1"' \ +--form 'prompt="This is a sample audio transcription from Bifrost speech synthesis."' +``` + +**Response format:** +```json +{ + "text": "Hello! This is a sample text that will be converted to speech using Bifrost speech synthesis capabilities. The weather today is wonderful, and I hope you are having a great day!" +} +``` + +## Advanced Vision Examples + +### Multiple Images + +Send multiple images in a single request for comparison or analysis. This is useful for comparing products, analyzing changes over time, or understanding relationships between different visual elements. + +```bash +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Compare these two images. What are the differences?" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/image1.jpg" + } + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/image2.jpg" + } + } + ] + } + ] +}' +``` + +### Base64 Images + +Process local images by encoding them as base64 data URLs. This approach is ideal when you need to analyze images stored locally on your system without uploading them to external URLs first. + +```bash +# First, encode your local image to base64 +base64_image=$(base64 -i local_image.jpg) +data_url="data:image/jpeg;base64,$base64_image" + +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Analyze this image and describe what you see." + }, + { + "type": "image_url", + "image_url": { + "url": "'$data_url'", + "detail": "high" + } + } + ] + } + ] +}' +``` + +## Audio Configuration Options + +### Voice Selection for Speech Synthesis + +OpenAI provides six distinct voice options, each with different characteristics: + +- `alloy` - Balanced, natural voice +- `echo` - Deep, resonant voice +- `fable` - Expressive, storytelling voice +- `onyx` - Strong, confident voice +- `nova` - Bright, energetic voice +- `shimmer` - Gentle, soothing voice + +```bash +# Example with different voice +curl --location 'http://localhost:8080/v1/audio/speech' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/tts-1", + "input": "This is the nova voice speaking.", + "voice": "nova", + "response_format": "mp3" +}' \ +--output "sample_nova.mp3" +``` + +### Audio Formats + +Generate audio in different formats depending on your use case. MP3 for general use, Opus for web streaming, AAC for mobile apps, and FLAC for high-quality audio applications. + +```bash +# MP3 format (default) +"response_format": "mp3" + +# Opus format for web streaming +"response_format": "opus" + +# AAC format for mobile apps +"response_format": "aac" + +# FLAC format for high-quality audio +"response_format": "flac" +``` + +## Transcription Options + +### Language Specification + +Improve transcription accuracy by specifying the source language. This is particularly helpful for non-English audio or when the audio contains technical terms or specific domain vocabulary. + +```bash +curl --location 'http://localhost:8080/v1/audio/transcriptions' \ +--form 'file=@"spanish_audio.mp3"' \ +--form 'model="openai/whisper-1"' \ +--form 'language="es"' \ +--form 'prompt="This is a Spanish audio recording about technology."' +``` + +### Response Formats + +Choose between simple text output or detailed JSON responses with timestamps. The verbose JSON format provides word-level and segment-level timing information, useful for creating subtitles or analyzing speech patterns. + +```bash +# Text only response +curl --location 'http://localhost:8080/v1/audio/transcriptions' \ +--form 'file=@"audio.mp3"' \ +--form 'model="openai/whisper-1"' \ +--form 'response_format="text"' + +# JSON with timestamps +curl --location 'http://localhost:8080/v1/audio/transcriptions' \ +--form 'file=@"audio.mp3"' \ +--form 'model="openai/whisper-1"' \ +--form 'response_format="verbose_json"' \ +--form 'timestamp_granularities[]=word' \ +--form 'timestamp_granularities[]=segment' +``` + +## Provider Support + +Different providers support different multimodal capabilities: + +| Provider | Vision | Text-to-Speech | Speech-to-Text | +|----------|--------|----------------|----------------| +| OpenAI | βœ… GPT-4V, GPT-4o | βœ… TTS-1, TTS-1-HD | βœ… Whisper | +| Anthropic | βœ… Claude 3 Sonnet/Opus | ❌ | ❌ | +| Google Vertex | βœ… Gemini Pro Vision | βœ… | βœ… | +| Azure OpenAI | βœ… GPT-4V | βœ… | βœ… Whisper | + +## Next Steps + +Now that you understand multimodal capabilities, explore these related topics: + +### Essential Topics + +- **[Streaming Responses](./streaming)** - Real-time multimodal processing +- **[Tool Calling](./tool-calling)** - Combine with external tools +- **[Provider Configuration](./provider-configuration)** - Multiple providers for different capabilities +- **[Integrations](./integrations)** - Drop-in compatibility with existing SDKs + +### Advanced Topics + +- **[Core Features](../../features/)** - Advanced Bifrost capabilities +- **[Architecture](../../architecture/)** - How Bifrost works internally +- **[Deployment](../../deployment/)** - Production setup and scaling diff --git a/docs/quickstart/gateway/provider-configuration.mdx b/docs/quickstart/gateway/provider-configuration.mdx new file mode 100644 index 000000000..420f17784 --- /dev/null +++ b/docs/quickstart/gateway/provider-configuration.mdx @@ -0,0 +1,928 @@ +--- +title: "Provider Configuration" +description: "Configure multiple AI providers for custom concurrency, queue sizes, proxy settings, and more." +icon: "sliders" +--- + +## Multi-Provider Setup + +Configure multiple providers to seamlessly switch between them. This example shows how to configure OpenAI, Anthropic, and Mistral providers. + + + + + +![Provider Configuration Interface](../../media/provider-configs.png) + +1. Go to **http://localhost:8080** +2. Navigate to **"Providers"** in the sidebar +3. Click **"Add Provider"** +4. Select provider and configure keys +5. Save configuration + + + + + +```bash +# Add OpenAI provider +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ] +}' + +# Add Anthropic provider +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "anthropic", + "keys": [ + { + "value": "env.ANTHROPIC_API_KEY", + "models": [], + "weight": 1.0 + } + ] +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ] + }, + "anthropic": { + "keys": [ + { + "value": "env.ANTHROPIC_API_KEY", + "models": [], + "weight": 1.0 + } + ] + } + } +} +``` + + + + + +## Making Requests + +Once providers are configured, you can make requests to any specific provider. This example shows how to send a request directly to OpenAI's GPT-4o Mini model. Bifrost handles the provider-specific API formatting automatically. + +```bash +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Hello!"} + ] +}' +``` + +## Environment Variables + +Set up your API keys for the providers you want to use. Bifrost supports both direct key values and environment variable references with the `env.` prefix: + +```bash +export OPENAI_API_KEY="your-openai-api-key" +export ANTHROPIC_API_KEY="your-anthropic-api-key" +export MISTRAL_API_KEY="your-mistral-api-key" +export CEREBRA_API_KEY="your-cerebras-api-key" +export GROQ_API_KEY="your-groq-api-key" +export COHERE_API_KEY="your-cohere-api-key" +``` + +**Environment Variable Handling:** +- Use `"value": "env.VARIABLE_NAME"` to reference environment variables +- Use `"value": "sk-proj-xxxxxxxxx"` to pass keys directly +- All sensitive data is automatically redacted in GET requests and UI responses for security + +## Advanced Configuration + +### Weighted Load Balancing + +Distribute requests across multiple API keys or providers based on custom weights. This example shows how to split traffic 70/30 between two OpenAI keys, useful for managing rate limits or costs across different accounts. + + + + + +![Weighted Load Balancing Interface](../../media/ui-multi-key-for-models.png) + +1. Navigate to **"Providers"** β†’ **"OpenAI"** +2. Click **"Add Key"** to add multiple keys +3. Set weight values (0.7 and 0.3) +4. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY_1", + "models": [], + "weight": 0.7 + }, + { + "value": "env.OPENAI_API_KEY_2", + "models": [], + "weight": 0.3 + } + ] +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY_1", + "models": [], + "weight": 0.7 + }, + { + "value": "env.OPENAI_API_KEY_2", + "models": [], + "weight": 0.3 + } + ] + } + } +} +``` + + + + + +### Model-Specific Keys + +Use different API keys for specific models, allowing you to manage access controls and billing separately. This example uses a premium key for advanced reasoning models (o1-preview, o1-mini) and a standard key for regular GPT models. + + + + + +![Model-Specific Keys Interface](../../media/ui-multi-key-for-models.png) + +1. Navigate to **"Providers"** β†’ **"OpenAI"** +2. Add first key with models: `["gpt-4o", "gpt-4o-mini"]` +3. Add premium key with models: `["o1-preview", "o1-mini"]` +4. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": ["gpt-4o", "gpt-4o-mini"], + "weight": 1.0 + }, + { + "value": "env.OPENAI_API_KEY_PREMIUM", + "models": ["o1-preview", "o1-mini"], + "weight": 1.0 + } + ] +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": ["gpt-4o", "gpt-4o-mini"], + "weight": 1.0 + }, + { + "value": "env.OPENAI_API_KEY_PREMIUM", + "models": ["o1-preview", "o1-mini"], + "weight": 1.0 + } + ] + } + } +} +``` + + + + + +### Custom Network Settings + +Customize the network configuration for each provider, including custom base URLs, extra headers, and timeout settings. This example shows how to use a local OpenAI-compatible server with custom headers for user identification. + + + + + +![Network Configuration Interface](../../media/ui-proxy-setup.png) + +1. Navigate to **"Providers"** β†’ **"OpenAI"** β†’ **"Advanced"** +2. Set **Base URL**: `http://localhost:8000/v1` +3. Set **Timeout**: `30` seconds +4. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "network_config": { + "base_url": "http://localhost:8000", + "extra_headers": { + "x-user-id": "123" + }, + "default_request_timeout_in_seconds": 30 + } +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "network_config": { + "base_url": "http://localhost:8000", + "extra_headers": { + "x-user-id": "123" + }, + "default_request_timeout_in_seconds": 30 + } + } + } +} +``` + + + + + +### Managing Retries + +Configure retry behavior for handling temporary failures and rate limits. This example sets up exponential backoff with up to 5 retries, starting with 1ms delay and capping at 10 seconds - ideal for handling transient network issues. + + + + + +![Retry Configuration Interface](../../media/ui-concurrency-timeout.png) + +1. Navigate to **"Providers"** β†’ **"OpenAI"** β†’ **"Advanced"** +2. Set **Max Retries**: `5` +3. Set **Initial Backoff**: `1` ms +4. Set **Max Backoff**: `10000` ms +5. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "network_config": { + "max_retries": 5, + "retry_backoff_initial_ms": 1, + "retry_backoff_max_ms": 10000 + } +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "network_config": { + "max_retries": 5, + "retry_backoff_initial_ms": 1, + "retry_backoff_max_ms": 10000 + } + } + } +} +``` + + + + + +### Custom Concurrency and Buffer Size + +Fine-tune performance by adjusting worker concurrency and queue sizes per provider (defaults are 1000 workers and 5000 queue size). This example gives OpenAI higher limits (100 workers, 500 queue) for high throughput, while Anthropic gets conservative limits to respect their rate limits. + + + + + +![Concurrency Configuration Interface](../../media/ui-concurrency-buffer-size.png) + +1. Navigate to **"Providers"** β†’ **Provider** β†’ **"Performance"** +2. Set **Concurrency**: Worker count (100 for OpenAI, 25 for Anthropic) +3. Set **Buffer Size**: Queue size (500 for OpenAI, 100 for Anthropic) +4. Save configuration + + + + + +```bash +# OpenAI with high throughput settings +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "concurrency_and_buffer_size": { + "concurrency": 100, + "buffer_size": 500 + } +}' + +# Anthropic with conservative settings +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "anthropic", + "keys": [ + { + "value": "env.ANTHROPIC_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "concurrency_and_buffer_size": { + "concurrency": 25, + "buffer_size": 100 + } +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "concurrency_and_buffer_size": { + "concurrency": 100, + "buffer_size": 500 + } + }, + "anthropic": { + "keys": [ + { + "value": "env.ANTHROPIC_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "concurrency_and_buffer_size": { + "concurrency": 25, + "buffer_size": 100 + } + } + } +} +``` + + + + + +### Setting Up a Proxy + +Route requests through proxies for compliance, security, or geographic requirements. This example shows both HTTP proxy for OpenAI and authenticated SOCKS5 proxy for Anthropic, useful for corporate environments or regional access. + + + + + +![Proxy Configuration Interface](../../media/ui-proxy-setup.png) + +1. Navigate to **"Providers"** β†’ **Provider** β†’ **"Proxy"** +2. Select **Proxy Type**: HTTP or SOCKS5 +3. Set **Proxy URL**: `http://localhost:8000` +4. Add credentials if needed (username/password) +5. Save configuration + + + + + +```bash +# HTTP proxy for OpenAI +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "proxy_config": { + "type": "http", + "url": "http://localhost:8000" + } +}' + +# SOCKS5 proxy with authentication for Anthropic +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "anthropic", + "keys": [ + { + "value": "env.ANTHROPIC_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "proxy_config": { + "type": "socks5", + "url": "http://localhost:8000", + "username": "user", + "password": "password" + } +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "proxy_config": { + "type": "http", + "url": "http://localhost:8000" + } + }, + "anthropic": { + "keys": [ + { + "value": "env.ANTHROPIC_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "proxy_config": { + "type": "socks5", + "url": "http://localhost:8000", + "username": "user", + "password": "password" + } + } + } +} +``` + + + + + +### Send Back Raw Response + +Include the original provider response alongside Bifrost's standardized response format. Useful for debugging and accessing provider-specific metadata. + + + + + +![Raw Response Configuration Interface](../../media/ui-raw-response.png) + +1. Navigate to **"Providers"** β†’ **Provider** β†’ **"Advanced"** +2. Toggle **"Include Raw Response"** to enabled +3. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "openai", + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "send_back_raw_response": true +}' +``` + + + + + +```json +{ + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": [], + "weight": 1.0 + } + ], + "send_back_raw_response": true + } + } +} +``` + + + + + +When enabled, the raw provider response appears in `extra_fields.raw_response`: + +```json +{ + "choices": [...], + "usage": {...}, + "extra_fields": { + "provider": "openai", + "raw_response": { + // Original OpenAI response here + } + } +} +``` + +## Provider-Specific Authentication + +Enterprise cloud providers require additional configuration beyond API keys. Configure Azure OpenAI, AWS Bedrock, and Google Vertex with platform-specific authentication details. + +### Azure OpenAI + +Azure OpenAI requires endpoint URLs, deployment mappings, and API version configuration: + + + + + +![Azure OpenAI Configuration Interface](../../media/ui-azure-config.png) + +1. Navigate to **"Providers"** β†’ **"Azure OpenAI"** +2. Set **API Key**: Your Azure API key +3. Set **Endpoint**: Your Azure endpoint URL +4. Configure **Deployments**: Map model names to deployment names +5. Set **API Version**: e.g., `2024-08-01-preview` +6. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "azure", + "keys": [ + { + "value": "env.AZURE_API_KEY", + "models": ["gpt-4o", "gpt-4o-mini"], + "weight": 1.0, + "azure_key_config": { + "endpoint": "env.AZURE_ENDPOINT", + "deployments": { + "gpt-4o": "gpt-4o-deployment", + "gpt-4o-mini": "gpt-4o-mini-deployment" + }, + "api_version": "2024-08-01-preview" + } + } + ] +}' +``` + + + + + +```json +{ + "providers": { + "azure": { + "keys": [ + { + "value": "env.AZURE_API_KEY", + "models": ["gpt-4o", "gpt-4o-mini"], + "weight": 1.0, + "azure_key_config": { + "endpoint": "env.AZURE_ENDPOINT", + "deployments": { + "gpt-4o": "gpt-4o-deployment", + "gpt-4o-mini": "gpt-4o-mini-deployment" + }, + "api_version": "2024-08-01-preview" + } + } + ] + } + } +} +``` + + + + + +### AWS Bedrock + +AWS Bedrock supports both explicit credentials and IAM role authentication: + + + + + +![AWS Bedrock Configuration Interface](../../media/ui-bedrock-config.png) + +1. Navigate to **"Providers"** β†’ **"AWS Bedrock"** +2. Set **API Key**: AWS API Key (or leave empty if using IAM role authentication) +3. Set **Access Key**: AWS Access Key ID (or leave empty to use IAM in environment) +4. Set **Secret Key**: AWS Secret Access Key (or leave empty to use IAM in environment) +5. Set **Region**: e.g., `us-east-1` +6. Configure **Deployments**: Map model names to inference profiles +7. Set **ARN**: Required for deployments mapping +8. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "bedrock", + "keys": [ + { + "models": ["anthropic.claude-3-sonnet-20240229-v1:0", "anthropic.claude-v2:1"], + "weight": 1.0, + "bedrock_key_config": { + "access_key": "env.AWS_ACCESS_KEY_ID", + "secret_key": "env.AWS_SECRET_ACCESS_KEY", + "session_token": "env.AWS_SESSION_TOKEN", + "region": "us-east-1", + "deployments": { + "claude-3-sonnet": "us.anthropic.claude-3-sonnet-20240229-v1:0" + }, + "arn": "arn:aws:bedrock:us-east-1:123456789012:inference-profile" + } + } + ] +}' +``` + + + + + +```json +{ + "providers": { + "bedrock": { + "keys": [ + { + "models": ["anthropic.claude-3-sonnet-20240229-v1:0", "anthropic.claude-v2:1"], + "weight": 1.0, + "bedrock_key_config": { + "access_key": "env.AWS_ACCESS_KEY_ID", + "secret_key": "env.AWS_SECRET_ACCESS_KEY", + "session_token": "env.AWS_SESSION_TOKEN", + "region": "us-east-1", + "deployments": { + "claude-3-sonnet": "us.anthropic.claude-3-sonnet-20240229-v1:0" + }, + "arn": "arn:aws:bedrock:us-east-1:123456789012:inference-profile" + } + } + ] + } + } +} +``` + + + + + +**Notes:** +- If using API Key authentication, set `value` field to the API key, else leave it empty for IAM role authentication. +- In IAM role authentication, if both `access_key` and `secret_key` are empty, Bifrost uses IAM role authentication from the environment. +- `arn` is required for URL formation - `deployments` mapping is ignored without it. +- When using `arn` + `deployments`, Bifrost uses model profiles; otherwise forms path with incoming model name directly. + +### Google Vertex + +Google Vertex requires project configuration and authentication credentials: + + + + + +![Google Vertex Configuration Interface](../../media/ui-vertex-config.png) + +1. Navigate to **"Providers"** β†’ **"Google Vertex"** +2. Set **API Key**: Your Vertex API key +3. Set **Project ID**: Your Google Cloud project ID +4. Set **Region**: e.g., `us-central1` +5. Set **Auth Credentials**: Service account credentials JSON +6. Save configuration + + + + + +```bash +curl --location 'http://localhost:8080/api/providers' \ +--header 'Content-Type: application/json' \ +--data '{ + "provider": "vertex", + "keys": [ + { + "value": "env.VERTEX_API_KEY", + "models": ["gemini-pro", "gemini-pro-vision"], + "weight": 1.0, + "vertex_key_config": { + "project_id": "env.VERTEX_PROJECT_ID", + "region": "us-central1", + "auth_credentials": "env.VERTEX_CREDENTIALS" + } + } + ] +}' +``` + + + + + +```json +{ + "providers": { + "vertex": { + "keys": [ + { + "value": "env.VERTEX_API_KEY", + "models": ["gemini-pro", "gemini-pro-vision"], + "weight": 1.0, + "vertex_key_config": { + "project_id": "env.VERTEX_PROJECT_ID", + "region": "us-central1", + "auth_credentials": "env.VERTEX_CREDENTIALS" + } + } + ] + } + } +} +``` + + + + + +## Next Steps + +Now that you understand provider configuration, explore these related topics: + +### Essential Topics + +- **[Streaming Responses](./streaming)** - Real-time response generation +- **[Tool Calling](./tool-calling)** - Enable AI to use external functions +- **[Multimodal AI](./multimodal)** - Process images, audio, and text +- **[Integrations](./integrations)** - Drop-in compatibility with existing SDKs + +### Advanced Topics + +- **[Core Features](../../features/)** - Advanced Bifrost capabilities +- **[Architecture](../../architecture/)** - How Bifrost works internally +- **[Deployment](../../deployment/)** - Production setup and scaling diff --git a/docs/quickstart/gateway/setting-up-auth.mdx b/docs/quickstart/gateway/setting-up-auth.mdx new file mode 100644 index 000000000..7ed35f283 --- /dev/null +++ b/docs/quickstart/gateway/setting-up-auth.mdx @@ -0,0 +1,107 @@ +--- +title: "Setting up auth" +description: "Learn how to enable basic authentication for the Bifrost dashboard to secure your admin interface and API endpoints." +icon: "lock" +--- + +## Overview + +Bifrost provides built-in authentication to protect your dashboard and admin API endpoints. When enabled, users must log in with credentials before accessing the dashboard or making admin API calls. This feature helps secure your Bifrost instance, especially when deployed in production environments. + +## Enabling Authentication + +### Step 1: Navigate to Security Settings + +1. Open your Bifrost dashboard +2. Go to **Workspace** β†’ **Config** β†’ **Security** tab +3. Scroll to the **Password protect the dashboard** section + +![Setting up auth](../../media/setting-up-dashboard-auth.png) + +### Step 2: Enable Authentication + +1. Toggle the **Password protect the dashboard** switch to enable authentication +2. Enter your **Username** in the admin username field +3. Enter your **Password** in the admin password field + + +The username and password fields are only enabled when the authentication toggle is turned on. Make sure to use a strong password for security. + + +### Step 3: Configure Inference Call Authentication (Optional) + +By default, when authentication is enabled, all API calls (including inference calls) require authentication. You can optionally disable authentication for inference calls while keeping it enabled for the dashboard and admin API: + +1. Enable the **Disable authentication on inference calls** toggle +2. When enabled: + - Dashboard and admin API calls will still require authentication + - Inference API calls (chat completions, embeddings, etc.) will not require authentication + - MCP tool execution calls will still require authentication + + +This option is useful if you want to protect your dashboard and admin functions while allowing public access to inference endpoints. + + +### Step 4: Save Changes + +1. Click **Save Changes** to apply your authentication settings +2. You may need to **restart Bifrost** for the changes to take effect (a warning will be displayed if a restart is required) + +## Logging In + +Once authentication is enabled: + +1. Navigate to your Bifrost dashboard URL +2. You will be automatically redirected to the login page +3. Enter your configured username and password +4. Click **Sign in** + +After successful login, you'll be redirected to the dashboard. Your session will remain active for 30 days, and you'll need to log in again after the session expires. + +## Authentication Methods + +Bifrost supports different authentication methods depending on the type of request: + +### Dashboard Access + +- **Bearer Token Authentication**: The dashboard uses Bearer token authentication +- Tokens are automatically managed through the login session +- Tokens are stored in browser localStorage and sent with each API request + +### API Calls + +When authentication is enabled, API calls can use: + +- **Basic Authentication**: For inference calls, you can use HTTP Basic Auth with your username and password +- **Bearer Token**: For admin API calls, use the Bearer token obtained from the login session + +### Example: Using Basic Auth for API Calls + +```bash +# Using curl with Basic Auth +curl -X POST http://localhost:8080/v1/chat/completions \ + -u "your-username:your-password" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +## Important Notes + +- **Restart Required**: After enabling or changing authentication settings, you may need to restart Bifrost for changes to take effect +- **Session Duration**: Login sessions last for 30 days +- **Password Security**: Passwords are hashed and stored securely in the database +- **Inference Calls**: If you disable authentication on inference calls, only dashboard and admin API endpoints will be protected + +## Disabling Authentication + +To disable authentication: + +1. Navigate to **Workspace** β†’ **Config** β†’ **Security** +2. Toggle off the **Password protect the dashboard** switch +3. Click **Save Changes** +4. Restart Bifrost if prompted + +After disabling, the dashboard will be accessible without authentication. \ No newline at end of file diff --git a/docs/quickstart/gateway/setting-up.mdx b/docs/quickstart/gateway/setting-up.mdx new file mode 100644 index 000000000..e16078d06 --- /dev/null +++ b/docs/quickstart/gateway/setting-up.mdx @@ -0,0 +1,211 @@ +--- +title: "Setting Up" +description: "Get Bifrost running as an HTTP API gateway in 30 seconds with zero configuration. Perfect for any programming language." +icon: "play" +--- + +![Bifrost Gateway Installation](../../media/getting-started.png) + +## 30-Second Setup + +Get Bifrost running as a blazing-fast HTTP API gateway with **zero configuration**. Connect to any AI provider (OpenAI, Anthropic, Bedrock, and more) through a unified API that follows **OpenAI request/response format**. + +### 1. Choose Your Setup Method + +Both options work perfectly - choose what fits your workflow: + +#### NPX Binary + + + +```bash +# Install and run locally +npx -y @maximhq/bifrost + +# Install a specific version +npx -y @maximhq/bifrost --transport-version v1.3.9 +``` + +#### Docker + +```bash +# Pull and run Bifrost HTTP API +docker pull maximhq/bifrost +docker run -p 8080:8080 maximhq/bifrost + +# Pull a specific version +docker pull maximhq/bifrost:v1.3.9 +docker pull maximhq/bifrost:v1.3.9-amd64 +docker pull maximhq/bifrost:v1.3.9-arm64 +``` + +**For Data Persistence** + +```bash +# For configuration persistence across restarts +docker run -p 8080:8080 -v $(pwd)/data:/app/data maximhq/bifrost +``` +### 2. Configuration Flags + +| Flag | Default | NPX | Docker | Description | +|------|---------|-----|--------|-------------| +| port | 8080 | `-port 8080` | `-e APP_PORT=8080 -p 8080:8080` | HTTP server port | +| host | localhost | `-host 0.0.0.0` | `-e APP_HOST=0.0.0.0` | Host to bind server to | +| log-level | info | `-log-level info` | `-e LOG_LEVEL=info` | Log level (debug, info, warn, error) | +| log-style | json | `-log-style json` | `-e LOG_STYLE=json` | Log style (pretty, json) | + + +**Understanding App Directory** + +The `-app-dir` flag determines where Bifrost stores all its data: + +```bash +# Specify custom directory +npx -y @maximhq/bifrost -app-dir ./my-bifrost-data + +# If not specified, creates in your OS config directory: +# β€’ Linux/macOS: ~/.config/bifrost +# β€’ Windows: %APPDATA%\bifrost +``` + +**What's stored in app-dir:** +- `config.json` - Configuration file (optional) +- `config.db` - SQLite database for UI configuration +- `logs.db` - Request logs database + +**Note:** When using Bifrost via Docker, the volume you mount will be used as the app-dir. + +### 3. Open the Web Interface + +Navigate to **http://localhost:8080** in your browser: + +```bash +# macOS +open http://localhost:8080 + +# Linux +xdg-open http://localhost:8080 + +# Windows +start http://localhost:8080 +``` + +πŸ–₯️ **The Web UI provides:** +- **Visual provider setup** - Add API keys with clicks, not code +- **Real-time configuration** - Changes apply immediately +- **Live monitoring** - Request logs, metrics, and analytics +- **Governance management** - Virtual keys, usage budgets, and more + +### 4. Test Your First API Call + +```bash +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello, Bifrost!"}] + }' +``` + +**πŸŽ‰ That's it!** Bifrost is running and ready to route AI requests. + +### What Just Happened? + +1. **Zero Configuration Start**: Bifrost launched without any config files - everything can be configured through the Web UI or API +2. **OpenAI-Compatible API**: All Bifrost APIs follow OpenAI request/response format for seamless integration +3. **Unified API Endpoint**: `/v1/chat/completions` works with any provider (OpenAI, Anthropic, Bedrock, etc.) +4. **Provider Resolution**: `openai/gpt-4o-mini` tells Bifrost to use OpenAI's GPT-4o Mini model +5. **Automatic Routing**: Bifrost handles authentication, rate limiting, and request routing automatically + +--- + +## Two Configuration Modes + +Bifrost supports **two configuration approaches** - you cannot use both simultaneously: + +### Mode 1: Web UI Configuration + +![Configuration via UI](../../media/ui-config.png) + +**When the UI is available:** +- No `config.json` file exists (Bifrost auto-creates SQLite database) +- `config.json` exists with `config_store` configured + +### Mode 2: File-based Configuration + +**When to use:** Advanced setups, GitOps workflows, or when UI is not needed + +Create `config.json` in your app directory: + +```json +{ + "client": { + "drop_excess_requests": false + }, + "providers": { + "openai": { + "keys": [ + { + "value": "env.OPENAI_API_KEY", + "models": ["gpt-4o-mini", "gpt-4o"], + "weight": 1.0 + } + ] + } + }, + "config_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "./config.db" + } + } +} +``` + +**Without `config_store` in `config.json`:** +- **UI is disabled** - no real-time configuration possible +- **Read-only mode** - `config.json` is never modified +- **Memory-only** - all configurations loaded into memory at startup +- **Restart required** - changes to `config.json` only apply after restart + +**With `config_store` in `config.json`:** +- **UI is enabled** - full real-time configuration via web interface +- **Database check** - Bifrost checks if config store database exists and has data + - **Empty DB**: Bootstraps database with `config.json` settings, then uses DB exclusively + - **Existing DB**: Uses database directly, **ignores** `config.json` configurations +- **Persistent storage** - all changes saved to database immediately + +**Important for Advanced Users:** +If you want database persistence but prefer not to use the UI, note that modifying `config.json` after initial bootstrap has no effect when `config_store` is enabled. Use the public HTTP APIs to make configuration changes instead. + +**The Three Stores Explained:** +- **Config Store**: Stores provider configs, API keys, MCP settings - Required for UI functionality +- **Logs Store**: Stores request logs shown in UI - Optional, can be disabled +- **Vector Store**: Used for semantic caching - Optional, can be disabled + +--- + +## Next Steps + +Now that you have Bifrost running, explore these focused guides: + +### Essential Topics + +- **[Provider Configuration](./provider-configuration)** - Multiple providers, automatic failovers & load balancing +- **[Integrations](../../integrations/what-is-an-integration)** - Drop-in replacements for OpenAI, Anthropic, and GenAI SDKs +- **[Multimodal Support](./multimodal)** - Support for text, images, audio, and streaming, all behind a common interface. + +### Advanced Topics + +- **[Tracing](../../features/observability/default)** - Logging requests for monitoring and debugging +- **[MCP Tools](../../features/mcp)** - Enable AI models to use external tools (filesystem, web search, databases) +- **[Governance](../../features/governance)** - Usage tracking, rate limiting, and cost control +- **[Deployment](../../deployment/docker-setup)** - Production setup and scaling + +--- + +**Happy building with Bifrost!** πŸš€ diff --git a/docs/quickstart/gateway/streaming.mdx b/docs/quickstart/gateway/streaming.mdx new file mode 100644 index 000000000..c3c352511 --- /dev/null +++ b/docs/quickstart/gateway/streaming.mdx @@ -0,0 +1,174 @@ +--- +title: "Streaming Responses" +description: "Receive AI responses in real-time via Server-Sent Events. Perfect for chat applications, audio processing, and real-time transcription where you want immediate results." +icon: "water" +--- + + +## Streaming Text Completion + +Request text completions with streaming enabled to receive partial `text` chunks as they are generated. + +```bash +curl --location 'http://localhost:8080/v1/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "prompt": "Write a short haiku about the ocean", + "stream": true +}' +``` + +**Response Format (Server-Sent Events):** +``` +data: {"choices":[{"text":"Waves whisper soft"}],"model":"gpt-4o-mini"} + +data: {"choices":[{"text":" on distant shores, the moon calls"}],"model":"gpt-4o-mini"} + +data: {"choices":[{"text":" tides to rise."}],"model":"gpt-4o-mini"} + +data: [DONE] +``` + +## Streaming Chat Responses + +Receive AI responses in real-time as they're generated. Perfect for chat applications where you want to show responses as they're being typed, improving user experience. + +```bash +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Tell me a story about a robot learning to paint"} + ], + "stream": true +}' +``` + +**Response Format (Server-Sent Events):** +``` +data: {"choices":[{"delta":{"content":"Once"}}],"model":"gpt-4o-mini"} + +data: {"choices":[{"delta":{"content":" upon"}}],"model":"gpt-4o-mini"} + +data: {"choices":[{"delta":{"content":" a"}}],"model":"gpt-4o-mini"} + +data: [DONE] +``` + +Each chunk contains partial content that you can append to build the complete response in real-time. + +> **Note:** Streaming requests also follow the default timeout setting defined in provider configuration, which defaults to **30 seconds**. + + +Bifrost standardizes all stream responses to send usage and finish reason only in the last chunk, and content in the previous chunks. + + +## Responses API Streaming + +Stream the OpenAI-style Responses API with event-based SSE. This includes `event:` lines and does not use the `[DONE]` marker; the stream ends when the connection closes. + +```bash +curl --location 'http://localhost:8080/v1/responses' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "input": "Tell me one interesting fact about Mars", + "stream": true +}' +``` + +**Response Format (Server-Sent Events):** +``` +event: response.created +data: {"type":"response.created"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","delta": /* partial text delta payload */ } + +event: response.output_text.delta +data: {"type":"response.output_text.delta","delta": * more text delta */ } + +event: response.completed +data: {"type":"response.completed","response":{ /* usage, finish_reason, etc. */ }} +``` + +## Text-to-Speech Streaming: Real-time Audio Generation + +Stream audio generation in real-time as text is converted to speech. Ideal for long texts or when you need immediate audio playback. + +```bash +curl --location 'http://localhost:8080/v1/audio/speech' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini-tts", + "input": "Hello this is a sample test, respond with hello for my Bifrost", + "voice": "alloy", + "stream_format": "sse" +}' +``` + +**Response:** Audio chunks are delivered via Server-Sent Events. Each chunk contains base64-encoded audio data that you can decode and play or save progressively. + +``` +data: {"audio":"UklGRigAAABXQVZFZm10IBAAAAABAAEA..."} + +data: {"audio":"AKlFQVZFZm10IBAAAAABAAEAq..."} + +data: [DONE] +``` + +**To save the stream:** Add `> audio_stream.txt` to redirect output to a file. + +## Speech-to-Text Streaming: Real-time Audio Transcription + +Stream audio transcription results as they're processed. Get immediate text output for real-time applications or long audio files. + +```bash +curl --location 'http://localhost:8080/v1/audio/transcriptions' \ +--form 'file=@"/path/to/your/audio.mp3"' \ +--form 'model="openai/gpt-4o-transcribe"' \ +--form 'stream="true"' \ +--form 'response_format="json"' +``` + +**Response Format:** +``` +data: {"text":"Hello"} + +data: {"text":" this"} + +data: {"text":" is"} + +data: {"text":" a sample"} + +data: [DONE] +``` + +**Additional options:** Add `--form 'language="en"'` or `--form 'prompt="context hint"'` for better accuracy. + +## Audio Format Support + +**Speech Synthesis:** Supports `"response_format": "mp3"` (default) and `"response_format": "wav"` + +**Transcription Input:** Accepts MP3, WAV, M4A, and other common audio formats + +> **Note:** Streaming capabilities vary by provider and model. Check each provider's documentation for specific streaming support and limitations. + +## Next Steps + +Now that you understand streaming responses, explore these related topics: + +### Essential Topics + +- **[Tool Calling](./tool-calling)** - Enable AI models to use external tools and functions +- **[Multimodal AI](./multimodal)** - Process images, audio, and multimedia content +- **[Provider Configuration](./provider-configuration)** - Multiple providers for redundancy +- **[Integrations](./integrations)** - Drop-in compatibility with existing SDKs + +### Advanced Topics + +- **[Core Features](../../features/)** - Advanced Bifrost capabilities +- **[Architecture](../../architecture/)** - How Bifrost works internally +- **[Deployment](../../deployment/)** - Production setup and scaling diff --git a/docs/quickstart/gateway/tool-calling.mdx b/docs/quickstart/gateway/tool-calling.mdx new file mode 100644 index 000000000..9117559a2 --- /dev/null +++ b/docs/quickstart/gateway/tool-calling.mdx @@ -0,0 +1,165 @@ +--- +title: "Tool Calling" +description: "Enable AI models to use external functions and services by defining tool schemas or connecting to Model Context Protocol (MCP) servers. This allows AI to interact with databases, APIs, file systems, and more." +icon: "wrench" +--- + +## Function Calling with Custom Tools + +Enable AI models to use external functions by defining tool schemas using OpenAI format. Models can then call these functions automatically based on user requests. + +```bash +curl --location 'http://localhost:8080/v1/chat/completions' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "messages": [ + {"role": "user", "content": "What is 15 + 27? Use the calculator tool."} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "calculator", + "description": "A calculator tool for basic arithmetic operations", + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "The operation to perform", + "enum": ["add", "subtract", "multiply", "divide"] + }, + "a": { + "type": "number", + "description": "The first number" + }, + "b": { + "type": "number", + "description": "The second number" + } + }, + "required": ["operation", "a", "b"] + } + } + } + ], + "tool_choice": "auto" +}' +``` + +**Response includes tool calls:** +```json +{ + "choices": [{ + "message": { + "role": "assistant", + "tool_calls": [{ + "id": "call_abc123", + "type": "function", + "function": { + "name": "calculator", + "arguments": "{\"operation\":\"add\",\"a\":15,\"b\":27}" + } + }] + } + }] +} +``` + +## Connecting to MCP Servers + +Connect to Model Context Protocol (MCP) servers to give AI models access to external tools and services without manually defining each function. + + + +![MCP Configuration Interface](../../media/ui-mcp-config.png) + +1. Go to **http://localhost:8080** +2. Navigate to **"MCP Clients"** in the sidebar +3. Click **"Add MCP Client"** +4. Enter server details and save + + + +```bash +curl --location 'http://localhost:8080/api/mcp/client' \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "filesystem", + "connection_type": "stdio", + "stdio_config": { + "command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"], + "args": [] + } +}' +``` + +**List configured MCP clients:** +```bash +curl --location 'http://localhost:8080/api/mcp/clients' +``` + + + +```json +{ + "mcp": { + "client_configs": [ + { + "name": "filesystem", + "connection_type": "stdio", + "stdio_config": { + "command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"], + "args": [] + } + }, + { + "name": "youtube-search", + "connection_type": "http", + "connection_string": "http://your-youtube-mcp-url" + } + ] + } +} +``` + + + + +Read more about MCP connections and advanced end to end tool execution in the [MCP Features](../../features/mcp) section. + +## Tool Choice Options + +Control how the AI uses tools: + +```bash +# Force use of specific tool +"tool_choice": { + "type": "function", + "function": {"name": "calculator"} +} + +# Let AI decide automatically (default) +"tool_choice": "auto" + +# Disable tool usage +"tool_choice": "none" +``` + +## Next Steps + +Now that you understand tool calling, explore these related topics: + +### Essential Topics + +- **[Multimodal AI](./multimodal)** - Process images, audio, and multimedia content +- **[Streaming Responses](./streaming)** - Real-time response generation with tool calls +- **[Provider Configuration](./provider-configuration)** - Multiple providers for redundancy +- **[Integrations](./integrations)** - Drop-in compatibility with existing SDKs + +### Advanced Topics + +- **[MCP Features](../../features/mcp)** - Advanced MCP server management and configuration +- **[Core Features](../../features/)** - Advanced Bifrost capabilities +- **[Architecture](../../architecture/)** - How Bifrost works internally diff --git a/docs/quickstart/go-sdk/logger.mdx b/docs/quickstart/go-sdk/logger.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/quickstart/go-sdk/multimodal.mdx b/docs/quickstart/go-sdk/multimodal.mdx new file mode 100644 index 000000000..be0964dae --- /dev/null +++ b/docs/quickstart/go-sdk/multimodal.mdx @@ -0,0 +1,351 @@ +--- +title: "Multimodal Support" +description: "Process multiple types of content including images, audio, and text with AI models. Bifrost supports vision analysis, speech synthesis, and audio transcription across various providers." +icon: "images" +--- + +## Vision: Analyzing Images with AI + +Send images to vision-capable models for analysis, description, and understanding. This example shows how to analyze an image from a URL using GPT-4o with high detail processing for better accuracy. + +```go +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", // Using vision-capable model + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: schemas.Ptr("What do you see in this image? Please describe it in detail."), + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + Detail: schemas.Ptr("high"), // Optional: can be "low", "high", or "auto" + }, + }, + }, + }, + }, + }, +}) + +if err != nil { + panic(err) +} + +fmt.Println("Response:", *response.Choices[0].Message.Content.ContentStr) +``` + +## Audio Understanding: Analyzing Audio with AI + +If your chat application supports text input, you can add audio input and outputβ€”just include audio in the modalities array and use an audio model, like gpt-4o-audio-preview. + +### Audio Input to Model + +```go +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-audio-preview", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: schemas.Ptr("Please analyze this audio recording and summarize what was discussed."), + }, + { + Type: schemas.ChatContentBlockTypeInputAudio, + InputAudio: &schemas.ChatInputAudio{ + Data: []byte("base64-encoded audio data containing the word 'Affirmative'"), + Format: "wav", + }, + }, + }, + }, + }, + }, +}) +``` + +## Text-to-Speech: Converting Text to Audio + +Convert text into natural-sounding speech using AI voice models. This example demonstrates generating an MP3 audio file from text using the "alloy" voice. The result is saved to a local file for playback. + +```go +response, err := client.SpeechRequest(context.Background(), &schemas.BifrostSpeechRequest{ + Provider: schemas.OpenAI, + Model: "tts-1", // Using text-to-speech model + Input: &schemas.SpeechInput{ + Input: "Hello! This is a sample text that will be converted to speech using Bifrost's speech synthesis capabilities. The weather today is wonderful, and I hope you're having a great day!", + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: schemas.Ptr("alloy"), + }, + ResponseFormat: schemas.Ptr("mp3"), + }, +}) + +if err != nil { + panic(err) +} + +// Handle speech synthesis response +if response.Speech != nil && len(response.Speech.Audio) > 0 { + // Save the audio to a file + filename := "output.mp3" + err := os.WriteFile("output.mp3", response.Speech.Audio, 0644) + if err != nil { + panic(fmt.Sprintf("Failed to save audio file: %v", err)) + } + + fmt.Printf("Speech synthesis successful! Audio saved to %s, file size: %d bytes\n", filename, len(response.Speech.Audio)) +} +``` + +## Speech-to-Text: Transcribing Audio Files + +Convert audio files into text using AI transcription models. This example shows how to transcribe an MP3 file using OpenAI's Whisper model, with an optional context prompt to improve accuracy. + +```go +// Read the audio file for transcription +audioFilename := "output.mp3" +audioData, err := os.ReadFile(audioFilename) +if err != nil { + panic(fmt.Sprintf("Failed to read audio file %s: %v. Please make sure the file exists.", audioFilename, err)) +} + +fmt.Printf("Loaded audio file %s (%d bytes) for transcription...\n", audioFilename, len(audioData)) + +response, err := client.TranscriptionRequest(context.Background(), &schemas.BifrostTranscriptionRequest{ + Provider: schemas.OpenAI, + Model: "whisper-1", // Using Whisper model for transcription + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Prompt: schemas.Ptr("This is a sample audio transcription from Bifrost speech synthesis."), // Optional: provide context + }, +}) + +if err != nil { + panic(err) +} + +fmt.Printf("Transcription Result: %s\n", response.Transcribe.Text) +``` + +## Advanced Vision Examples + +### Multiple Images + +Send multiple images in a single request for comparison or analysis. This is useful for comparing products, analyzing changes over time, or understanding relationships between different visual elements. + +```go +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: schemas.Ptr("Compare these two images. What are the differences?"), + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: "https://example.com/image1.jpg", + }, + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: "https://example.com/image2.jpg", + }, + }, + }, + }, + }, + }, +}) +``` + +### Base64 Images + +Process local images by encoding them as base64 data URLs. This approach is ideal when you need to analyze images stored locally on your system without uploading them to external URLs first. + +```go +// Read and encode image +imageData, err := os.ReadFile("local_image.jpg") +if err != nil { + panic(err) +} +base64Image := base64.StdEncoding.EncodeToString(imageData) +dataURL := fmt.Sprintf("data:image/jpeg;base64,%s", base64Image) + +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: schemas.Ptr("Analyze this image and describe what you see."), + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: dataURL, + Detail: schemas.Ptr("high"), + }, + }, + }, + }, + }, + }, +}) +``` + +## Audio Configuration Options + +### Voice Selection for Speech Synthesis + +OpenAI provides six distinct voice options, each with different characteristics. This example generates sample audio files for each voice so you can compare and choose the one that best fits your application. + +```go +// Available voices: alloy, echo, fable, onyx, nova, shimmer +voices := []string{"alloy", "echo", "fable", "onyx", "nova", "shimmer"} + +for _, voice := range voices { + response, err := client.SpeechRequest(context.Background(), &schemas.BifrostSpeechRequest{ + Provider: schemas.OpenAI, + Model: "tts-1", + Input: &schemas.SpeechInput{ + Input: fmt.Sprintf("This is the %s voice speaking.", voice), + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: schemas.Ptr(voice), + }, + ResponseFormat: schemas.Ptr("mp3"), + }, + }) + + if err == nil && response.Speech != nil { + filename := fmt.Sprintf("sample_%s.mp3", voice) + os.WriteFile(filename, response.Speech.Audio, 0644) + fmt.Printf("Generated %s\n", filename) + } +} +``` + +### Audio Formats + +Generate audio in different formats depending on your use case. MP3 for general use, Opus for web streaming, AAC for mobile apps, and FLAC for high-quality audio applications. + +```go +formats := []string{"mp3", "opus", "aac", "flac"} + +for _, format := range formats { + response, err := client.SpeechRequest(context.Background(), &schemas.BifrostSpeechRequest{ + Provider: schemas.OpenAI, + Model: "tts-1", + Input: &schemas.SpeechInput{ + Input: "Testing different audio formats.", + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: schemas.Ptr("alloy"), + }, + ResponseFormat: schemas.Ptr(format), + } + }) + + if err == nil && response.Speech != nil { + filename := fmt.Sprintf("output.%s", format) + os.WriteFile(filename, response.Speech.Audio, 0644) + } +} +``` + +## Transcription Options + +### Language Specification + +Improve transcription accuracy by specifying the source language. This is particularly helpful for non-English audio or when the audio contains technical terms or specific domain vocabulary. + +```go +response, err := client.TranscriptionRequest(context.Background(), &schemas.BifrostTranscriptionRequest{ + Provider: schemas.OpenAI, + Model: "whisper-1", + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: schemas.Ptr("es"), // Spanish + Prompt: schemas.Ptr("This is a Spanish audio recording about technology."), + }, +}) +``` + +### Response Formats + +Choose between simple text output or detailed JSON responses with timestamps. The verbose JSON format provides word-level and segment-level timing information, useful for creating subtitles or analyzing speech patterns. + +```go +// Text only +response, err := client.TranscriptionRequest(context.Background(), &schemas.BifrostTranscriptionRequest{ + Provider: schemas.OpenAI, + Model: "whisper-1", + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + ResponseFormat: schemas.Ptr("text"), + }, +}) + +// JSON with timestamps +response, err := client.TranscriptionRequest(context.Background(), &schemas.BifrostTranscriptionRequest{ + Provider: schemas.OpenAI, + Model: "whisper-1", + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + ResponseFormat: schemas.Ptr("verbose_json"), + TimestampGranularities: []string{"word", "segment"}, + }, +}) +``` + +## Provider Support + +Different providers support different multimodal capabilities: + +| Provider | Vision | Text-to-Speech | Speech-to-Text | +|----------|--------|----------------|----------------| +| OpenAI | βœ… GPT-4V, GPT-4o | βœ… TTS-1, TTS-1-HD | βœ… Whisper | +| Anthropic | βœ… Claude 3 Sonnet/Opus | ❌ | ❌ | +| Google Vertex | βœ… Gemini Pro Vision | βœ… | βœ… | +| Azure OpenAI | βœ… GPT-4V | βœ… | βœ… Whisper | + +## Next Steps + +- **[Streaming Responses](./streaming)** - Real-time multimodal processing +- **[Tool Calling](./tool-calling)** - Combine with external tools +- **[Provider Configuration](./provider-configuration)** - Multiple providers for different capabilities +- **[Core Features](../../features/)** - Advanced Bifrost capabilities diff --git a/docs/quickstart/go-sdk/provider-configuration.mdx b/docs/quickstart/go-sdk/provider-configuration.mdx new file mode 100644 index 000000000..5920c8906 --- /dev/null +++ b/docs/quickstart/go-sdk/provider-configuration.mdx @@ -0,0 +1,411 @@ +--- +title: "Provider Configuration" +description: "Configure multiple AI providers for custom concurrency, queue sizes, proxy settings, and more." +icon: "sliders" +--- + +## Multi-Provider Setup + +Configure multiple providers to seamlessly switch between them. This example shows how to configure OpenAI, Anthropic, and Mistral providers. + +```go +type MyAccount struct{} + +func (a *MyAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{schemas.OpenAI, schemas.Anthropic, schemas.Mistral}, nil +} + +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.OpenAI: + return []schemas.Key{{ + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }}, nil + case schemas.Anthropic: + return []schemas.Key{{ + Value: os.Getenv("ANTHROPIC_API_KEY"), + Models: []string{}, + Weight: 1.0, + }}, nil + case schemas.Mistral: + return []schemas.Key{{ + Value: os.Getenv("MISTRAL_API_KEY"), + Models: []string{}, + Weight: 1.0, + }}, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} + +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + // Return same config for all providers + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil +} +``` + +> If Bifrost receives a new provider at runtime (i.e., one that is not returned by `GetConfiguredProviders()` initially on `bifrost.Init()`), it will set up the provider at runtime using `GetConfigForProvider()`, which may cause a delay in the first request to that provider. + +## Making Requests + +Once providers are configured, you can make requests to any specific provider. This example shows how to send a request directly to Mistral's latest vision model. Bifrost handles the provider-specific API formatting automatically. + +```go +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.Mistral, + Model: "pixtral-12b-latest", + Input: messages, +}) +``` + +## Environment Variables + +Set up your API keys for the providers you want to use: + +```bash +export OPENAI_API_KEY="your-openai-api-key" +export ANTHROPIC_API_KEY="your-anthropic-api-key" +export CEREBRA_API_KEY="your-cerebras-api-key" +export MISTRAL_API_KEY="your-mistral-api-key" +export GROQ_API_KEY="your-groq-api-key" +export COHERE_API_KEY="your-cohere-api-key" +``` + +## Advanced Configuration + +### Weighted Load Balancing + +Distribute requests across multiple API keys or providers based on custom weights. This example shows how to split traffic 70/30 between two OpenAI keys, useful for managing rate limits or costs across different accounts. + +```go +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.OpenAI: + return []schemas.Key{{ + Value: os.Getenv("OPENAI_API_KEY_1"), + Models: []string{}, + Weight: 0.7, // 70% of requests + }, + { + Value: os.Getenv("OPENAI_API_KEY_2"), + Models: []string{}, + Weight: 0.3, // 30% of requests + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +### Model-Specific Keys + +Use different API keys for specific models, allowing you to manage access controls and billing separately. This example uses a premium key for advanced reasoning models (o1-preview, o1-mini) and a standard key for regular GPT models. + +```go +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.OpenAI: + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{"gpt-4o", "gpt-4o-mini"}, + Weight: 1.0, + }, + { + Value: os.Getenv("OPENAI_API_KEY_PREMIUM"), + Models: []string{"o1-preview", "o1-mini"}, + Weight: 1.0, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +### Custom Network Settings + +Customize the network configuration for each provider, including custom base URLs, extra headers, and timeout settings. This example shows how to use a local OpenAI-compatible server with custom headers for user identification. + +```go +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch provider { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: "http://localhost:8000/v1", // Custom openai setup + ExtraHeaders: map[string]string{ // Will be included in the request headers + "x-user-id": "123", + }, + DefaultRequestTimeoutInSeconds: 30, + }, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` +### Managing Retries + +Configure retry behavior for handling temporary failures and rate limits. This example sets up exponential backoff with up to 5 retries, starting with 1ms delay and capping at 10 seconds - ideal for handling transient network issues. + +```go +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch provider { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + MaxRetries: 5, + RetryBackoffInitial: 1 * time.Millisecond, + RetryBackoffMax: 10 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +### Custom Concurrency and Buffer Size + +Fine-tune performance by adjusting worker concurrency and queue sizes per provider (defaults are 1000 workers and 5000 queue size). This example gives OpenAI higher limits (100 workers, 500 queue) for high throughput, while Anthropic gets conservative limits to respect their rate limits. + +```go +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch provider { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + MaxConcurrency: 100, // Max number of concurrent requests (no of workers) + BufferSize: 500, // Max number of requests in the buffer (queue size) + }, + }, nil + case schemas.Anthropic: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + MaxConcurrency: 25, + BufferSize: 100, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +### Setting Up a Proxy + +Route requests through proxies for compliance, security, or geographic requirements. This example shows both HTTP proxy for OpenAI and authenticated SOCKS5 proxy for Anthropic, useful for corporate environments or regional access. + +```go +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch provider { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + ProxyConfig: &schemas.ProxyConfig{ + Type: schemas.HttpProxy, + URL: "http://localhost:8000", // Proxy URL + }, + }, nil + case schemas.Anthropic: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + ProxyConfig: &schemas.ProxyConfig{ + Type: schemas.Socks5Proxy, + URL: "http://localhost:8000", // Proxy URL + Username: "user", + Password: "password", + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +### Send Back Raw Response + +Include the original provider response alongside Bifrost's standardized response format. Useful for debugging and accessing provider-specific metadata. + +```go +func (a *MyAccount) GetConfigForProvider(ctx *context.Context, provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + SendBackRawResponse: true, // Include raw provider response + }, nil +} +``` + +When enabled, the raw provider response appears in `ExtraFields.RawResponse`: + +```go +type BifrostChatResponse struct { + ID string `json:"id"` + Choices []BifrostResponseChoice `json:"choices"` + Created int `json:"created"` // The Unix timestamp (in seconds). + Model string `json:"model"` + Object string `json:"object"` // "chat.completion" or "chat.completion.chunk" + ServiceTier string `json:"service_tier"` + SystemFingerprint string `json:"system_fingerprint"` + Usage *BifrostLLMUsage `json:"usage"` + ExtraFields BifrostResponseExtraFields `json:"extra_fields"` +} + +type BifrostResponseExtraFields struct { + RequestType RequestType `json:"request_type"` + Provider ModelProvider `json:"provider"` + ModelRequested string `json:"model_requested"` + Latency int64 `json:"latency"` // in milliseconds (for streaming responses this will be each chunk latency, and the last chunk latency will be the total latency) + ChunkIndex int `json:"chunk_index"` // used for streaming responses to identify the chunk index, will be 0 for non-streaming responses + RawResponse interface{} `json:"raw_response,omitempty"` + CacheDebug *BifrostCacheDebug `json:"cache_debug,omitempty"` +} +``` + +## Provider-Specific Authentication + +Enterprise cloud providers require additional configuration beyond API keys. Configure Azure OpenAI, AWS Bedrock, and Google Vertex with platform-specific authentication details. + + + + + +Azure OpenAI requires endpoint URLs, deployment mappings, and API version configuration: + +```go +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.Azure: + return []schemas.Key{ + { + Value: os.Getenv("AZURE_API_KEY"), + Models: []string{"gpt-4o", "gpt-4o-mini"}, + Weight: 1.0, + AzureKeyConfig: &schemas.AzureKeyConfig{ + Endpoint: os.Getenv("AZURE_ENDPOINT"), // e.g., "https://your-resource.openai.azure.com" + Deployments: map[string]string{ + "gpt-4o": "gpt-4o-deployment", + "gpt-4o-mini": "gpt-4o-mini-deployment", + }, + APIVersion: bifrost.Ptr("2024-08-01-preview"), // Azure API version + }, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + + + + + +AWS Bedrock supports both explicit credentials and IAM role authentication: + +```go +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.Bedrock: + return []schemas.Key{ + { + Models: []string{"anthropic.claude-3-sonnet-20240229-v1:0", "anthropic.claude-v2:1"}, + Weight: 1.0, + Value: os.Getenv("AWS_API_KEY"), // Leave empty for IAM role authentication + BedrockKeyConfig: &schemas.BedrockKeyConfig{ + AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), // Leave empty for API Key authentication or system's IAM pickup + SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), // Leave empty for API Key authentication or system's IAM pickup + SessionToken: bifrost.Ptr(os.Getenv("AWS_SESSION_TOKEN")), // Optional + Region: bifrost.Ptr("us-east-1"), + // For model profiles (inference profiles) + Deployments: map[string]string{ + "claude-3-sonnet": "us.anthropic.claude-3-sonnet-20240229-v1:0", + }, + // For direct model access without profiles + ARN: bifrost.Ptr("arn:aws:bedrock:us-east-1:123456789012:inference-profile"), + }, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +**Notes:** +- If using API Key authentication, set `Value` field to the API key, else leave it empty for IAM role authentication. +- In IAM role authentication, if both `AccessKey` and `SecretKey` are empty, Bifrost uses IAM from the environment. +- `ARN` is required for URL formation - `Deployments` mapping is ignored without it. +- When using `ARN` + `Deployments`, Bifrost uses model profiles; otherwise forms path with incoming model name directly. + + + + + +Google Vertex requires project configuration and authentication credentials: + +```go +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + switch provider { + case schemas.Vertex: + return []schemas.Key{ + { + Value: os.Getenv("VERTEX_API_KEY"), // Optional if using service account + Models: []string{"gemini-pro", "gemini-pro-vision"}, + Weight: 1.0, + VertexKeyConfig: &schemas.VertexKeyConfig{ + ProjectID: os.Getenv("VERTEX_PROJECT_ID"), // GCP project ID + Region: "us-central1", // GCP region + AuthCredentials: os.Getenv("VERTEX_CREDENTIALS"), // Service account JSON or path + }, + }, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + + + + + +## Best Practices + +### Performance Considerations + +Keys are fetched from your `GetKeysForProvider` implementation on every request. Ensure your implementation is optimized for speed to avoid adding latency: + +```go +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + // βœ… Good: Fast in-memory lookup + switch provider { + case schemas.OpenAI: + return a.cachedOpenAIKeys, nil // Pre-cached keys + } + + // ❌ Avoid: Database queries, API calls, complex algorithms + // This will add latency to every AI request + // keys := fetchKeysFromDatabase(provider) // Too slow! + // return processWithComplexLogic(keys) // Too slow! + + return nil, fmt.Errorf("provider %s not supported", provider) +} +``` + +**Recommendations:** +- Cache keys in memory during application startup +- Use simple switch statements or map lookups +- Avoid database queries, file I/O, or network calls +- Keep complex key processing logic outside the request path + +## Next Steps + +- **[Streaming Responses](./streaming)** - Real-time response generation +- **[Tool Calling](./tool-calling)** - Enable AI to use external functions +- **[Multimodal AI](./multimodal)** - Process images, audio, and text +- **[Core Features](../../features/)** - Advanced Bifrost capabilities diff --git a/docs/quickstart/go-sdk/setting-up.mdx b/docs/quickstart/go-sdk/setting-up.mdx new file mode 100644 index 000000000..d708e3f9a --- /dev/null +++ b/docs/quickstart/go-sdk/setting-up.mdx @@ -0,0 +1,144 @@ +--- +title: "Setting Up" +description: "Get Bifrost running in your Go application in 30 seconds with minimal setup and direct code integration." +icon: "play" +--- + + + + +## 30-Second Setup + +Get Bifrost running in your Go application with minimal setup. This guide shows you how to integrate multiple AI providers through a single, unified interface. + +### 1. Install Package + +```bash +go mod init my-bifrost-app +go get github.com/maximhq/bifrost/core +``` + +### 2. Set Environment Variable + +```bash +export OPENAI_API_KEY="your-openai-api-key" +``` + +### 3. Create `main.go` + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +type MyAccount struct{} + +// Account interface needs to implement these 3 methods +func (a *MyAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{schemas.OpenAI}, nil +} + +func (a *MyAccount) GetKeysForProvider(ctx *context.Context, provider schemas.ModelProvider) ([]schemas.Key, error) { + if provider == schemas.OpenAI { + return []schemas.Key{{ + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, // Keep Models empty to use any model + Weight: 1.0, + }}, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} + +func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) { + if provider == schemas.OpenAI { + // Return default config (can be customized for advanced use cases) + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + } + return nil, fmt.Errorf("provider %s not supported", provider) +} + +// Main function implement to initialize bifrost and make a request +func main() { + client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &MyAccount{}, + }) + if initErr != nil { + panic(initErr) + } + defer client.Shutdown() + + messages := []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("Hello, Bifrost!"), + }, + }, + } + + response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: messages, + }) + + if err != nil { + panic(err) + } + + fmt.Println("Response:", *response.Choices[0].Message.Content.ContentStr) +} +``` + +### 4. Run Your App + +```bash +go run main.go +# Output: Response: Hello! I'm Bifrost, your AI model gateway... +``` + +**πŸŽ‰ That's it!** You're now running Bifrost in your Go application. + +### What Just Happened? + +1. **Account Interface**: `MyAccount` provides API keys and list of providers to Bifrost for initialisation and key lookups. +2. **Provider Resolution**: `schemas.OpenAI` tells Bifrost to use OpenAI as the provider. +3. **Model Selection**: `"gpt-4o-mini"` specifies which model to use. +4. **Unified API**: Same interface works for any provider/model combination (OpenAI, Anthropic, Vertex etc.) + +--- + +## Next Steps + +Now that you have Bifrost running, explore these focused guides: + +### Essential Topics + +- **[Provider Configuration](./provider-configuration)** - Multiple providers & automatic failovers +- **[Streaming Responses](./streaming)** - Real-time chat, audio, and transcription +- **[Tool Calling](./tool-calling)** - Functions & MCP server integration +- **[Multimodal AI](./multimodal)** - Images, speech synthesis, and vision + +### Advanced Topics + +- **[Core Features](../../features/)** - Caching, observability, and governance +- **[Integrations](../../integrations/)** - Drop-in replacements for existing SDKs +- **[Architecture](../../architecture/)** - How Bifrost works internally +- **[Deployment](../../deployment/)** - Production setup and scaling + +--- + +**Happy coding with Bifrost!** πŸš€ diff --git a/docs/quickstart/go-sdk/streaming.mdx b/docs/quickstart/go-sdk/streaming.mdx new file mode 100644 index 000000000..2c42e45d5 --- /dev/null +++ b/docs/quickstart/go-sdk/streaming.mdx @@ -0,0 +1,300 @@ +--- +title: "Streaming Responses" +description: "Receive AI responses in real-time as they're generated. Perfect for chat applications, audio processing, and real-time transcription where you want immediate results." +icon: "water" +--- + +## Streaming Text Completion + +Stream plain text completions as they are generated, ideal for autocomplete, summaries, and single-output generation. + +```go +stream, err := client.TextCompletionStreamRequest(context.Background(), &schemas.BifrostTextCompletionRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: &schemas.TextCompletionInput{ + PromptStr: bifrost.Ptr("A for apple and B for"), + }, +}) + +if err != nil { + log.Printf("Streaming request failed: %v", err) + return +} + +for chunk := range stream { + // Handle errors in stream + if chunk.BifrostError != nil { + log.Printf("Stream error: %v", chunk.BifrostError) + break + } + + // Process response chunks + if chunk.BifrostTextCompletionResponse != nil && len(chunk.BifrostTextCompletionResponse.Choices) > 0 { + choice := chunk.BifrostTextCompletionResponse.Choices[0] + + // Check for streaming content + if choice.TextCompletionResponseChoice != nil && + choice.TextCompletionResponseChoice.Text != nil { + content := *choice.BifrostTextCompletionResponseChoice.Text + fmt.Print(content) // Print content as it arrives + } + } +} +``` + +## Streaming Chat Responses + +Receive incremental chat deltas in real-time. Append delta content to progressively render assistant messages. + +```go +stream, err := client.ChatCompletionStreamRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: messages, +}) + +if err != nil { + log.Printf("Streaming request failed: %v", err) + return +} + +for chunk := range stream { + // Handle errors in stream + if chunk.BifrostError != nil { + log.Printf("Stream error: %v", chunk.BifrostError) + break + } + + // Process response chunks + if chunk.BifrostChatResponse != nil && len(chunk.BifrostChatResponse.Choices) > 0 { + choice := chunk.BifrostChatResponse.Choices[0] + + // Check for streaming content + if choice.ChatStreamResponseChoice != nil && + choice.ChatStreamResponseChoice.Delta != nil && + choice.ChatStreamResponseChoice.Delta.Content != nil { + + content := *choice.ChatStreamResponseChoice.Delta.Content + fmt.Print(content) // Print content as it arrives + } + } +} +``` + +> **Note:** Streaming requests also follow the default timeout setting defined in provider configuration, which defaults to **30 seconds**. + + +Bifrost standardizes all stream responses to send usage and finish reason only in the last chunk, and content in the previous chunks. + + +## Responses API Streaming + +Use the OpenAI-style Responses API with streaming for unified flows. Events arrive via SSE; accumulate text deltas until completion. + +```go +messages := []schemas.ResponsesMessage{ + { + Role: bifrost.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: bifrost.Ptr("Hello, Bifrost!"), + }, + }, +} + +stream, err := client.ResponsesStreamRequest(context.Background(), &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: messages, +}) + +if err != nil { + log.Printf("Streaming request failed: %v", err) + return +} + +for chunk := range stream { + // Handle errors in stream + if chunk.BifrostError != nil { + log.Printf("Stream error: %v", chunk.BifrostError) + break + } + + // Process response chunks + if chunk.BifrostResponsesStreamResponse != nil { + delta := chunk.BifrostResponsesStreamResponse.Delta + + // Check for streaming content + if delta != nil { + fmt.Print(*delta) // Print content as it arrives + } + } +} +``` + +## Text-to-Speech Streaming: Real-time Audio Generation + +Stream audio generation in real-time as text is converted to speech. Ideal for long texts or when you need immediate audio playback. + +```go +stream, err := client.SpeechStreamRequest(context.Background(), &schemas.BifrostSpeechRequest{ + Provider: schemas.OpenAI, + Model: "tts-1", // Using text-to-speech model + Input: &schemas.SpeechInput{ + Input: "Hello! This is a sample text that will be converted to speech using Bifrost's speech synthesis capabilities. The weather today is wonderful, and I hope you're having a great day!", + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: schemas.Ptr("alloy"), + }, + ResponseFormat: schemas.Ptr("mp3"), + }, +}) + +if err != nil { + panic(err) +} + +// Handle speech synthesis stream +var audioData []byte +var totalChunks int +filename := "output.mp3" + +for chunk := range stream { + if chunk.BifrostError != nil { + panic(fmt.Sprintf("Stream error: %s", chunk.BifrostError.Error.Message)) + } + + if chunk.BifrostSpeechStreamResponse != nil { + // Accumulate audio data from each chunk + audioData = append(audioData, chunk.BifrostSpeechStreamResponse.Audio...) + totalChunks++ + fmt.Printf("Received chunk %d, size: %d bytes\n", totalChunks, len(chunk.BifrostSpeechStreamResponse.Audio)) + } +} + +if len(audioData) > 0 { + // Save the accumulated audio to a file + err := os.WriteFile(filename, audioData, 0644) + if err != nil { + panic(fmt.Sprintf("Failed to save audio file: %v", err)) + } + + fmt.Printf("Speech synthesis streaming complete! Audio saved to %s\n", filename) + fmt.Printf("Total chunks received: %d, final file size: %d bytes\n", totalChunks, len(audioData)) +} +``` + +## Speech-to-Text Streaming: Real-time Audio Transcription + +Stream audio transcription results as they're processed. Get immediate text output for real-time applications or long audio files. + +```go +// Read the audio file for transcription +audioFilename := "output.mp3" +audioData, err := os.ReadFile(audioFilename) +if err != nil { + panic(fmt.Sprintf("Failed to read audio file %s: %v. Please make sure the file exists.", audioFilename, err)) +} + +fmt.Printf("Loaded audio file %s (%d bytes) for transcription...\n", audioFilename, len(audioData)) + +stream, err := client.TranscriptionStreamRequest(context.Background(), &schemas.BifrostTranscriptionRequest{ + Provider: schemas.OpenAI, + Model: "whisper-1", // Using Whisper model for transcription + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Prompt: schemas.Ptr("This is a sample audio transcription from Bifrost speech synthesis."), // Optional: provide context + }, +}) + +if err != nil { + panic(err) +} + +for chunk := range stream { + if chunk.BifrostError != nil { + panic(fmt.Sprintf("Stream error: %s", chunk.BifrostError.Error.Message)) + } + + if chunk.BifrostTranscriptionStreamResponse != nil && chunk.BifrostTranscriptionStreamResponse.Delta != nil { + // Print each chunk of text as it arrives + fmt.Print(*chunk.BifrostTranscriptionStreamResponse.Delta) + } +} +``` + +## Streaming Best Practices + +### Buffering for Audio + +For audio streaming, consider buffering chunks before saving: + +```go +const bufferSize = 1024 * 1024 // 1MB buffer + +var audioBuffer bytes.Buffer +var lastSave time.Time + +for chunk := range stream { + if chunk.BifrostSpeechStreamResponse != nil { + audioBuffer.Write(chunk.BifrostSpeechStreamResponse.Audio) + + // Save every second or when buffer is full + if time.Since(lastSave) > time.Second || audioBuffer.Len() > bufferSize { + // Append to file + file, err := os.OpenFile("streaming_audio.mp3", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err == nil { + file.Write(audioBuffer.Bytes()) + file.Close() + audioBuffer.Reset() + lastSave = time.Now() + } + } + } +} +``` + +### Context and Cancellation + +Use context to control streaming duration: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +stream, err := client.ChatCompletionStreamRequest(ctx, &schemas.BifrostChatRequest{ + // ... your request +}) + +// Stream will automatically stop after 30 seconds +``` + +## Voice Options + +OpenAI TTS supports these voices: + +- `alloy` - Balanced, natural voice +- `echo` - Deep, resonant voice +- `fable` - Expressive, storytelling voice +- `onyx` - Strong, confident voice +- `nova` - Bright, energetic voice +- `shimmer` - Gentle, soothing voice + +```go +// Different voice example +VoiceConfig: schemas.SpeechVoiceInput{ + Voice: bifrost.Ptr("nova"), +}, +``` + +> **Note:** Please check each model's documentation to see if it supports the corresponding streaming features. Not all providers support all streaming capabilities. + +## Next Steps + +- **[Tool Calling](./tool-calling)** - Enable AI to use external functions +- **[Multimodal AI](./multimodal)** - Process images and multimedia content +- **[Provider Configuration](./provider-configuration)** - Multiple providers for redundancy +- **[Core Features](../../features/)** - Advanced Bifrost capabilities diff --git a/docs/quickstart/go-sdk/tool-calling.mdx b/docs/quickstart/go-sdk/tool-calling.mdx new file mode 100644 index 000000000..32075755f --- /dev/null +++ b/docs/quickstart/go-sdk/tool-calling.mdx @@ -0,0 +1,268 @@ +--- +title: "Tool Calling" +description: "Enable AI models to use external functions and services by defining tool schemas or connecting to Model Context Protocol (MCP) servers. This allows AI to interact with databases, APIs, file systems, and more." +icon: "wrench" +--- + +## Function Calling with Custom Tools + +Enable AI models to use external functions by defining tool schemas. Models can then call these functions automatically based on user requests. + +```go +// Define a tool for the calculator +calculatorTool := schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: "calculator", + Description: schemas.Ptr("A calculator tool"), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: map[string]interface{}{ + "operation": map[string]interface{}{ + "type": "string", + "description": "The operation to perform", + "enum": []string{"add", "subtract", "multiply", "divide"}, + }, + "a": map[string]interface{}{ + "type": "number", + "description": "The first number", + }, + "b": map[string]interface{}{ + "type": "number", + "description": "The second number", + }, + }, + Required: []string{"operation", "a", "b"}, + }, + }, +} + +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("What is 2+2? Use the calculator tool."), + }, + }, + }, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{calculatorTool}, + }, +}) + +if err != nil { + panic(err) +} + +if response.Choices[0].Message.ChatAssistantMessage != nil && response.Choices[0].Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range response.Choices[0].Message.ChatAssistantMessage.ToolCalls { + fmt.Printf("Tool call in response - %s: %s\n", *toolCall.ID, *toolCall.Function.Name) + fmt.Printf("Tool call arguments - %s\n", toolCall.Function.Arguments) + } +} +``` + +## Connecting to MCP Servers + +Connect to Model Context Protocol (MCP) servers to give AI models access to external tools and services without manually defining each function. + +```go +client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: &MyAccount{}, + MCPConfig: &schemas.MCPConfig{ + ClientConfigs: []schemas.MCPClientConfig{ + // Sample youtube-mcp server + { + Name: "youtube-mcp", + ConnectionType: schemas.MCPConnectionTypeHTTP, + ConnectionString: schemas.Ptr("http://your-youtube-mcp-url"), + ToolsToExecute: []string{"*"}, // Allow all tools from this client + }, + }, + }, +}) +if initErr != nil { + panic(initErr) +} +defer client.Shutdown() + +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("What do you see when you search for 'bifrost' on youtube?"), + }, + }, + }, +}) + +if err != nil { + panic(err) +} + +if response.Choices[0].Message.ChatAssistantMessage != nil && response.Choices[0].Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range response.Choices[0].Message.ChatAssistantMessage.ToolCalls { + fmt.Printf("Tool call in response - %s: %s\n", *toolCall.ID, *toolCall.Function.Name) + fmt.Printf("Tool call arguments - %s\n", toolCall.Function.Arguments) + } +} +``` + +Read more about MCP connections and in-house tool registration via local MCP server in the [MCP Features](../../features/mcp) section. + +## Advanced Tool Examples + +### Weather API Tool + +```go +weatherTool := schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: "get_weather", + Description: schemas.Ptr("Get the current weather for a location"), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: map[string]interface{}{ + "location": map[string]interface{}{ + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "unit": map[string]interface{}{ + "type": "string", + "description": "Temperature unit", + "enum": []string{"celsius", "fahrenheit"}, + }, + }, + Required: []string{"location"}, + }, + }, +} +``` + +### Database Query Tool + +```go +databaseTool := schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: "query_database", + Description: schemas.Ptr("Execute a SQL query on the customer database"), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "The SQL query to execute", + }, + "table": map[string]interface{}{ + "type": "string", + "description": "The table to query", + "enum": []string{"customers", "orders", "products"}, + }, + }, + Required: []string{"query", "table"}, + }, + }, +} +``` + +### File System Tool + +```go +fileSystemTool := schemas.ChatTool{ + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: "read_file", + Description: schemas.Ptr("Read the contents of a file"), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "The file path to read", + }, + "encoding": map[string]interface{}{ + "type": "string", + "description": "File encoding", + "enum": []string{"utf-8", "ascii", "base64"}, + "default": "utf-8", + }, + }, + Required: []string{"path"}, + }, + }, +} +``` + +## Multiple Tool Support + +Use multiple tools in a single request: + +```go +response, err := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("What's the weather in New York and calculate 15% tip for a $50 bill?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{weatherTool, calculatorTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStr: schemas.Ptr("auto"), // Let AI decide which tools to use + }, + }, +}) +``` + +## Tool Choice Options + +Control how the AI uses tools: + +```go +// Force use of a specific tool +Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{calculatorTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStruct: &schemas.ChatToolChoiceStruct{ + Type: schemas.ChatToolChoiceTypeFunction, + Function: &schemas.ChatToolChoiceFunction{ + Name: "calculator", + }, + }, + }, +} + +// Let AI decide automatically +Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{calculatorTool, weatherTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStr: schemas.Ptr("auto"), + }, +} + +// Disable tool usage +Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{calculatorTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStr: schemas.Ptr("none"), + }, +} +``` + +## Next Steps + +- **[Multimodal AI](./multimodal)** - Process images, audio, and multimedia content +- **[Streaming Responses](./streaming)** - Real-time response generation +- **[Provider Configuration](./provider-configuration)** - Multiple providers for redundancy +- **[MCP Features](../../features/mcp)** - Advanced MCP server management diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 000000000..e63a15fe7 --- /dev/null +++ b/docs/style.css @@ -0,0 +1,3 @@ +.nav-logo { + height: 2.75rem; +} \ No newline at end of file diff --git a/examples/plugins/hello-world/.gitignore b/examples/plugins/hello-world/.gitignore new file mode 100644 index 000000000..76de13350 --- /dev/null +++ b/examples/plugins/hello-world/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +build/ +*.so +*.dll +*.dylib + +# Go build cache +*.exe +*.exe~ +*.test +*.out + +# Dependency directories +vendor/ + diff --git a/examples/plugins/hello-world/Makefile b/examples/plugins/hello-world/Makefile new file mode 100644 index 000000000..b6004fd90 --- /dev/null +++ b/examples/plugins/hello-world/Makefile @@ -0,0 +1,92 @@ +.PHONY: all build clean install help test + +# Note: Go plugins only support Linux and macOS (Darwin) +# Cross-compilation is not supported for plugins + +# Plugin name +PLUGIN_NAME = hello-world +OUTPUT_DIR = build + +# Platform detection +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Linux) + PLUGIN_EXT = .so + PLATFORM = linux +endif +ifeq ($(UNAME_S),Darwin) + PLUGIN_EXT = .so + PLATFORM = darwin +endif + +# Architecture detection +UNAME_M := $(shell uname -m) +ifeq ($(UNAME_M),x86_64) + ARCH = amd64 +endif +ifeq ($(UNAME_M),arm64) + ARCH = arm64 +endif +ifeq ($(UNAME_M),aarch64) + ARCH = arm64 +endif + +# Output file +OUTPUT = $(OUTPUT_DIR)/$(PLUGIN_NAME)$(PLUGIN_EXT) + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +build: ## Build the plugin for current platform + @echo "Building plugin for $(PLATFORM)/$(ARCH)..." + @mkdir -p $(OUTPUT_DIR) + go build -buildmode=plugin -o $(OUTPUT) main.go + @echo "Plugin built successfully: $(OUTPUT)" + +build-linux-amd64: ## Build the plugin for Linux AMD64 + @echo "Building plugin for linux/amd64..." + @mkdir -p $(OUTPUT_DIR) + GOOS=linux GOARCH=amd64 go build -buildmode=plugin -o $(OUTPUT_DIR)/$(PLUGIN_NAME)-linux-amd64.so main.go + @echo "Plugin built successfully: $(OUTPUT_DIR)/$(PLUGIN_NAME)-linux-amd64.so" + +build-linux-arm64: ## Build the plugin for Linux ARM64 + @echo "Building plugin for linux/arm64..." + @mkdir -p $(OUTPUT_DIR) + GOOS=linux GOARCH=arm64 go build -buildmode=plugin -o $(OUTPUT_DIR)/$(PLUGIN_NAME)-linux-arm64.so main.go + @echo "Plugin built successfully: $(OUTPUT_DIR)/$(PLUGIN_NAME)-linux-arm64.so" + +build-darwin-amd64: ## Build the plugin for macOS AMD64 + @echo "Building plugin for darwin/amd64..." + @mkdir -p $(OUTPUT_DIR) + GOOS=darwin GOARCH=amd64 go build -buildmode=plugin -o $(OUTPUT_DIR)/$(PLUGIN_NAME)-darwin-amd64.so main.go + @echo "Plugin built successfully: $(OUTPUT_DIR)/$(PLUGIN_NAME)-darwin-amd64.so" + +build-darwin-arm64: ## Build the plugin for macOS ARM64 + @echo "Building plugin for darwin/arm64..." + @mkdir -p $(OUTPUT_DIR) + GOOS=darwin GOARCH=arm64 go build -buildmode=plugin -o $(OUTPUT_DIR)/$(PLUGIN_NAME)-darwin-arm64.so main.go + @echo "Plugin built successfully: $(OUTPUT_DIR)/$(PLUGIN_NAME)-darwin-arm64.so" + +build-all: build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 ## Build for all supported platforms + +clean: ## Remove build artifacts + @echo "Cleaning build artifacts..." + @rm -rf $(OUTPUT_DIR) + @echo "Clean complete" + +install: build ## Build and install the plugin to Bifrost plugins directory + @echo "Installing plugin..." + @mkdir -p ~/.bifrost/plugins + @cp $(OUTPUT) ~/.bifrost/plugins/ + @echo "Plugin installed to ~/.bifrost/plugins/$(PLUGIN_NAME)$(PLUGIN_EXT)" + +test: ## Run tests + go test -v ./... + +deps: ## Download dependencies + go mod download + go mod tidy + +.DEFAULT_GOAL := help diff --git a/examples/plugins/hello-world/go.mod b/examples/plugins/hello-world/go.mod new file mode 100644 index 000000000..99db56613 --- /dev/null +++ b/examples/plugins/hello-world/go.mod @@ -0,0 +1,18 @@ +module github.com/maximhq/bifrost/examples/plugins/hello-world + +go 1.24.0 + +toolchain go1.24.3 + +require github.com/maximhq/bifrost/core v1.2.22 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/sys v0.37.0 // indirect +) diff --git a/examples/plugins/hello-world/go.sum b/examples/plugins/hello-world/go.sum new file mode 100644 index 000000000..0b97e255d --- /dev/null +++ b/examples/plugins/hello-world/go.sum @@ -0,0 +1,37 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/plugins/hello-world/main.go b/examples/plugins/hello-world/main.go new file mode 100644 index 000000000..4a9b7a1e9 --- /dev/null +++ b/examples/plugins/hello-world/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" +) + +func Init(config any) error { + fmt.Println("Init called") + return nil +} + +func GetName() string { + return "Hello World Plugin" +} + +func TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + fmt.Println("TransportInterceptor called") + return headers, body, nil +} + +func PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + fmt.Println("PreHook called") + return req, nil, nil +} + +func PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + fmt.Println("PostHook called") + return resp, bifrostErr, nil +} + +func Cleanup() error { + fmt.Println("Cleanup called") + return nil +} diff --git a/framework/changelog.md b/framework/changelog.md new file mode 100644 index 000000000..48b7f16d2 --- /dev/null +++ b/framework/changelog.md @@ -0,0 +1,2 @@ +- chore: update core version to 1.2.22 +- feat: expose method to get pricing data for a model in model catalog \ No newline at end of file diff --git a/framework/config.go b/framework/config.go new file mode 100644 index 000000000..b9a6bc95f --- /dev/null +++ b/framework/config.go @@ -0,0 +1,8 @@ +package framework + +import "github.com/maximhq/bifrost/framework/modelcatalog" + +// FrameworkConfig represents the configuration for the framework. +type FrameworkConfig struct { + Pricing *modelcatalog.Config `json:"pricing,omitempty"` +} diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go new file mode 100644 index 000000000..8bc711901 --- /dev/null +++ b/framework/configstore/clientconfig.go @@ -0,0 +1,73 @@ +package configstore + +import ( + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore/tables" +) + +type EnvKeyType string + +const ( + EnvKeyTypeAPIKey EnvKeyType = "api_key" + EnvKeyTypeAzureConfig EnvKeyType = "azure_config" + EnvKeyTypeVertexConfig EnvKeyType = "vertex_config" + EnvKeyTypeBedrockConfig EnvKeyType = "bedrock_config" + EnvKeyTypeConnection EnvKeyType = "connection_string" + EnvKeyTypeMCPHeader EnvKeyType = "mcp_header" +) + +// EnvKeyInfo stores information about a key sourced from environment +type EnvKeyInfo struct { + EnvVar string // The environment variable name (without env. prefix) + Provider schemas.ModelProvider // The provider this key belongs to (empty for core/mcp configs) + KeyType EnvKeyType // Type of key (e.g., "api_key", "azure_config", "vertex_config", "bedrock_config", "connection_string", "mcp_header") + ConfigPath string // Path in config where this env var is used + KeyID string // The key ID this env var belongs to (empty for non-key configs like bedrock_config, connection_string) +} + +// ClientConfig represents the core configuration for Bifrost HTTP transport and the Bifrost Client. +// It includes settings for excess request handling, Prometheus metrics, and initial pool size. +type ClientConfig struct { + DropExcessRequests bool `json:"drop_excess_requests"` // Drop excess requests if the provider queue is full + InitialPoolSize int `json:"initial_pool_size"` // The initial pool size for the bifrost client + PrometheusLabels []string `json:"prometheus_labels"` // The labels to be used for prometheus metrics + EnableLogging bool `json:"enable_logging"` // Enable logging of requests and responses + DisableContentLogging bool `json:"disable_content_logging"` // Disable logging of content + EnableGovernance bool `json:"enable_governance"` // Enable governance on all requests + EnforceGovernanceHeader bool `json:"enforce_governance_header"` // Enforce governance on all requests + AllowDirectKeys bool `json:"allow_direct_keys"` // Allow direct keys to be used for requests + AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed) + MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB + EnableLiteLLMFallbacks bool `json:"enable_litellm_fallbacks"` // Enable litellm-specific fallbacks for text completion for Groq +} + +// ProviderConfig represents the configuration for a specific AI model provider. +// It includes API keys, network settings, and concurrency settings. +type ProviderConfig struct { + Keys []schemas.Key `json:"keys"` // API keys for the provider with UUIDs + NetworkConfig *schemas.NetworkConfig `json:"network_config,omitempty"` // Network-related settings + ConcurrencyAndBufferSize *schemas.ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size,omitempty"` // Concurrency settings + ProxyConfig *schemas.ProxyConfig `json:"proxy_config,omitempty"` // Proxy configuration + SendBackRawResponse bool `json:"send_back_raw_response"` // Include raw response in BifrostResponse + CustomProviderConfig *schemas.CustomProviderConfig `json:"custom_provider_config,omitempty"` // Custom provider configuration +} + +// AuthConfig represents configured auth config for Bifrost dashboard +type AuthConfig struct { + AdminUserName string `json:"admin_username"` + AdminPassword string `json:"admin_password"` + IsEnabled bool `json:"is_enabled"` + DisableAuthOnInference bool `json:"disable_auth_on_inference"` +} + +// ConfigMap maps provider names to their configurations. +type ConfigMap map[schemas.ModelProvider]ProviderConfig + +type GovernanceConfig struct { + VirtualKeys []tables.TableVirtualKey `json:"virtual_keys"` + Teams []tables.TableTeam `json:"teams"` + Customers []tables.TableCustomer `json:"customers"` + Budgets []tables.TableBudget `json:"budgets"` + RateLimits []tables.TableRateLimit `json:"rate_limits"` + AuthConfig *AuthConfig `json:"auth_config,omitempty"` +} diff --git a/framework/configstore/config.go b/framework/configstore/config.go new file mode 100644 index 000000000..dd061d744 --- /dev/null +++ b/framework/configstore/config.go @@ -0,0 +1,107 @@ +package configstore + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/maximhq/bifrost/framework/envutils" +) + +// ConfigStoreType represents the type of config store. +type ConfigStoreType string + +// ConfigStoreTypeSQLite is the type of config store for SQLite. +const ( + ConfigStoreTypeSQLite ConfigStoreType = "sqlite" + ConfigStoreTypePostgres ConfigStoreType = "postgres" +) + +// Config represents the configuration for the config store. +type Config struct { + Enabled bool `json:"enabled"` + Type ConfigStoreType `json:"type"` + Config any `json:"config"` +} + +// UnmarshalJSON unmarshals the config from JSON. +func (c *Config) UnmarshalJSON(data []byte) error { + // First, unmarshal into a temporary struct to get the basic fields + type TempConfig struct { + Enabled bool `json:"enabled"` + Type ConfigStoreType `json:"type"` + Config json.RawMessage `json:"config"` // Keep as raw JSON + } + + var temp TempConfig + if err := json.Unmarshal(data, &temp); err != nil { + return fmt.Errorf("failed to unmarshal config store config: %w", err) + } + + // Set basic fields + c.Enabled = temp.Enabled + c.Type = temp.Type + + if !temp.Enabled { + c.Config = nil + return nil + } + + // Parse the config field based on type + switch temp.Type { + case ConfigStoreTypeSQLite: + var sqliteConfig SQLiteConfig + if err := json.Unmarshal(temp.Config, &sqliteConfig); err != nil { + return fmt.Errorf("failed to unmarshal sqlite config: %w", err) + } + c.Config = &sqliteConfig + case ConfigStoreTypePostgres: + var postgresConfig PostgresConfig + var err error + if err = json.Unmarshal(temp.Config, &postgresConfig); err != nil { + return fmt.Errorf("failed to unmarshal postgres config: %w", err) + } + // Checking if any of the values start with env. If so, we need to process them. + if postgresConfig.DBName != "" && strings.HasPrefix(postgresConfig.DBName, "env.") { + postgresConfig.DBName, err = envutils.ProcessEnvValue(postgresConfig.DBName) + if err != nil { + return fmt.Errorf("failed to process env value for db name: %w", err) + } + } + if postgresConfig.Password != "" && strings.HasPrefix(postgresConfig.Password, "env.") { + postgresConfig.Password, err = envutils.ProcessEnvValue(postgresConfig.Password) + if err != nil { + return fmt.Errorf("failed to process env value for password: %w", err) + } + } + if postgresConfig.User != "" && strings.HasPrefix(postgresConfig.User, "env.") { + postgresConfig.User, err = envutils.ProcessEnvValue(postgresConfig.User) + if err != nil { + return fmt.Errorf("failed to process env value for user: %w", err) + } + } + if postgresConfig.Host != "" && strings.HasPrefix(postgresConfig.Host, "env.") { + postgresConfig.Host, err = envutils.ProcessEnvValue(postgresConfig.Host) + if err != nil { + return fmt.Errorf("failed to process env value for host: %w", err) + } + } + if postgresConfig.Port != "" && strings.HasPrefix(postgresConfig.Port, "env.") { + postgresConfig.Port, err = envutils.ProcessEnvValue(postgresConfig.Port) + if err != nil { + return fmt.Errorf("failed to process env value for port: %w", err) + } + } + if postgresConfig.SSLMode != "" && strings.HasPrefix(postgresConfig.SSLMode, "env.") { + postgresConfig.SSLMode, err = envutils.ProcessEnvValue(postgresConfig.SSLMode) + if err != nil { + return fmt.Errorf("failed to process env value for ssl mode: %w", err) + } + } + c.Config = &postgresConfig + default: + return fmt.Errorf("unknown config store type: %s", temp.Type) + } + + return nil +} diff --git a/framework/configstore/errors.go b/framework/configstore/errors.go new file mode 100644 index 000000000..e5b77064d --- /dev/null +++ b/framework/configstore/errors.go @@ -0,0 +1,5 @@ +package configstore + +import "errors" + +var ErrNotFound = errors.New("not found") diff --git a/framework/configstore/logger.go b/framework/configstore/logger.go new file mode 100644 index 000000000..f8f89cf86 --- /dev/null +++ b/framework/configstore/logger.go @@ -0,0 +1,45 @@ +package configstore + +import ( + "context" + "time" + + "github.com/maximhq/bifrost/core/schemas" + gormLibLogger "gorm.io/gorm/logger" +) + +// GormLogger is a logger for GORM. +type gormLogger struct { + logger schemas.Logger +} + +// LogMode sets the log mode for the logger. +func (l *gormLogger) LogMode(level gormLibLogger.LogLevel) gormLibLogger.Interface { + // NOOP + return l +} + +// Info logs an info message. +func (l *gormLogger) Info(ctx context.Context, msg string, data ...interface{}) { + l.logger.Info(msg, data...) +} + +// Warn logs a warning message. +func (l *gormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + l.logger.Warn(msg, data...) +} + +// Error logs an error message. +func (l *gormLogger) Error(ctx context.Context, msg string, data ...interface{}) { + l.logger.Error(msg, data...) +} + +// Trace logs a trace message. +func (l *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + // NOOP +} + +// newGormLogger creates a new GormLogger. +func newGormLogger(l schemas.Logger) *gormLogger { + return &gormLogger{logger: l} +} diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go new file mode 100644 index 000000000..a8f84df88 --- /dev/null +++ b/framework/configstore/migrations.go @@ -0,0 +1,905 @@ +package configstore + +import ( + "context" + "fmt" + "strconv" + + "github.com/google/uuid" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/migrator" + "gorm.io/gorm" +) + +// Migrate performs the necessary database migrations. +func triggerMigrations(ctx context.Context, db *gorm.DB) error { + if err := migrationInit(ctx, db); err != nil { + return err + } + if err := migrationMany2ManyJoinTable(ctx, db); err != nil { + return err + } + if err := migrationAddCustomProviderConfigJSONColumn(ctx, db); err != nil { + return err + } + if err := migrationAddVirtualKeyProviderConfigTable(ctx, db); err != nil { + return err + } + if err := migrationAddAllowedOriginsJSONColumn(ctx, db); err != nil { + return err + } + if err := migrationAddAllowDirectKeysColumn(ctx, db); err != nil { + return err + } + if err := migrationAddEnableLiteLLMFallbacksColumn(ctx, db); err != nil { + return err + } + if err := migrationTeamsTableUpdates(ctx, db); err != nil { + return err + } + if err := migrationAddKeyNameColumn(ctx, db); err != nil { + return err + } + if err := migrationAddFrameworkConfigsTable(ctx, db); err != nil { + return err + } + if err := migrationCleanupMCPClientToolsConfig(ctx, db); err != nil { + return err + } + if err := migrationAddVirtualKeyMCPConfigsTable(ctx, db); err != nil { + return err + } + if err := migrationAddPluginPathColumn(ctx, db); err != nil { + return err + } + if err := migrationAddProviderConfigBudgetRateLimit(ctx, db); err != nil { + return err + } + if err := migrationAddSessionsTable(ctx, db); err != nil { + return err + } + if err := migrationAddHeadersJSONColumnIntoMCPClient(ctx, db); err != nil { + return err + } + if err := migrationAddDisableContentLoggingColumn(ctx, db); err != nil { + return err + } + if err := migrationAddMCPClientIDColumn(ctx, db); err != nil { + return err + } + return nil +} + +// migrationInit is the first migration +func migrationInit(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "init", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasTable(&tables.TableConfigHash{}) { + if err := migrator.CreateTable(&tables.TableConfigHash{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableProvider{}) { + if err := migrator.CreateTable(&tables.TableProvider{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableKey{}) { + if err := migrator.CreateTable(&tables.TableKey{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableModel{}) { + if err := migrator.CreateTable(&tables.TableModel{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableMCPClient{}) { + if err := migrator.CreateTable(&tables.TableMCPClient{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableClientConfig{}) { + if err := migrator.CreateTable(&tables.TableClientConfig{}); err != nil { + return err + } + } else if !migrator.HasColumn(&tables.TableClientConfig{}, "max_request_body_size_mb") { + if err := migrator.AddColumn(&tables.TableClientConfig{}, "max_request_body_size_mb"); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableEnvKey{}) { + if err := migrator.CreateTable(&tables.TableEnvKey{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableVectorStoreConfig{}) { + if err := migrator.CreateTable(&tables.TableVectorStoreConfig{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableLogStoreConfig{}) { + if err := migrator.CreateTable(&tables.TableLogStoreConfig{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableBudget{}) { + if err := migrator.CreateTable(&tables.TableBudget{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableRateLimit{}) { + if err := migrator.CreateTable(&tables.TableRateLimit{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableCustomer{}) { + if err := migrator.CreateTable(&tables.TableCustomer{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableTeam{}) { + if err := migrator.CreateTable(&tables.TableTeam{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableVirtualKey{}) { + if err := migrator.CreateTable(&tables.TableVirtualKey{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableGovernanceConfig{}) { + if err := migrator.CreateTable(&tables.TableGovernanceConfig{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TableModelPricing{}) { + if err := migrator.CreateTable(&tables.TableModelPricing{}); err != nil { + return err + } + } + if !migrator.HasTable(&tables.TablePlugin{}) { + if err := migrator.CreateTable(&tables.TablePlugin{}); err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + // Drop children first, then parents (adjust if your actual FKs differ) + if err := migrator.DropTable(&tables.TableVirtualKey{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableKey{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableTeam{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableProvider{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableCustomer{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableBudget{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableRateLimit{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableModel{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableMCPClient{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableClientConfig{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableEnvKey{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableVectorStoreConfig{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableLogStoreConfig{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableGovernanceConfig{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableModelPricing{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TablePlugin{}); err != nil { + return err + } + if err := migrator.DropTable(&tables.TableConfigHash{}); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// createMany2ManyJoinTable creates a many-to-many join table for the given tables. +func migrationMany2ManyJoinTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "many2manyjoin", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + // create the many-to-many join table for virtual keys and keys + if !migrator.HasTable("governance_virtual_key_keys") { + createJoinTableSQL := ` + CREATE TABLE IF NOT EXISTS governance_virtual_key_keys ( + table_virtual_key_id VARCHAR(255) NOT NULL, + table_key_id INTEGER NOT NULL, + PRIMARY KEY (table_virtual_key_id, table_key_id), + FOREIGN KEY (table_virtual_key_id) REFERENCES governance_virtual_keys(id) ON DELETE CASCADE, + FOREIGN KEY (table_key_id) REFERENCES config_keys(id) ON DELETE CASCADE + ) + ` + if err := tx.Exec(createJoinTableSQL).Error; err != nil { + return fmt.Errorf("failed to create governance_virtual_key_keys table: %w", err) + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Exec("DROP TABLE IF EXISTS governance_virtual_key_keys").Error; err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddCustomProviderConfigJSONColumn adds the custom_provider_config_json column to the provider table +func migrationAddCustomProviderConfigJSONColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "addcustomproviderconfigjsoncolumn", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if !migrator.HasColumn(&tables.TableProvider{}, "custom_provider_config_json") { + if err := migrator.AddColumn(&tables.TableProvider{}, "custom_provider_config_json"); err != nil { + return err + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddVirtualKeyProviderConfigTable adds the virtual_key_provider_config table +func migrationAddVirtualKeyProviderConfigTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "addvirtualkeyproviderconfig", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if !migrator.HasTable(&tables.TableVirtualKeyProviderConfig{}) { + if err := migrator.CreateTable(&tables.TableVirtualKeyProviderConfig{}); err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if err := migrator.DropTable(&tables.TableVirtualKeyProviderConfig{}); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddAllowedOriginsJSONColumn adds the allowed_origins_json column to the client config table +func migrationAddAllowedOriginsJSONColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_allowed_origins_json_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if !migrator.HasColumn(&tables.TableClientConfig{}, "allowed_origins_json") { + if err := migrator.AddColumn(&tables.TableClientConfig{}, "allowed_origins_json"); err != nil { + return err + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddAllowDirectKeysColumn adds the allow_direct_keys column to the client config table +func migrationAddAllowDirectKeysColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_allow_direct_keys_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if !migrator.HasColumn(&tables.TableClientConfig{}, "allow_direct_keys") { + if err := migrator.AddColumn(&tables.TableClientConfig{}, "allow_direct_keys"); err != nil { + return err + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddEnableLiteLLMFallbacksColumn adds the enable_litellm_fallbacks column to the client config table +func migrationAddEnableLiteLLMFallbacksColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_enable_litellm_fallbacks_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks") { + if err := migrator.AddColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if err := migrator.DropColumn(&tables.TableClientConfig{}, "enable_litellm_fallbacks"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationTeamsTableUpdates adds profile, config, and claims columns to the team table +func migrationTeamsTableUpdates(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_profile_config_claims_columns_to_team_table", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TableTeam{}, "profile") { + if err := migrator.AddColumn(&tables.TableTeam{}, "profile"); err != nil { + return err + } + } + if !migrator.HasColumn(&tables.TableTeam{}, "config") { + if err := migrator.AddColumn(&tables.TableTeam{}, "config"); err != nil { + return err + } + } + if !migrator.HasColumn(&tables.TableTeam{}, "claims") { + if err := migrator.AddColumn(&tables.TableTeam{}, "claims"); err != nil { + return err + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddFrameworkConfigsTable adds the framework_configs table +func migrationAddFrameworkConfigsTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_framework_configs_table", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasTable(&tables.TableFrameworkConfig{}) { + if err := migrator.CreateTable(&tables.TableFrameworkConfig{}); err != nil { + return err + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddKeyNameColumn adds the name column to the key table and populates unique names +func migrationAddKeyNameColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_key_name_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TableKey{}, "name") { + // Step 1: Add the column as nullable first + if err := tx.Exec("ALTER TABLE config_keys ADD COLUMN name VARCHAR(255)").Error; err != nil { + return fmt.Errorf("failed to add name column: %w", err) + } + + // Step 2: Populate unique names for all existing keys + var keys []tables.TableKey + if err := tx.Find(&keys).Error; err != nil { + return fmt.Errorf("failed to fetch keys: %w", err) + } + + for _, key := range keys { + // Create unique name: provider_name-key-{first8chars_of_key_id}-{key_index} + keyIDShort := key.KeyID + if len(keyIDShort) > 8 { + keyIDShort = keyIDShort[:8] + } + keyName := keyIDShort + "-" + strconv.Itoa(int(key.ID)) + uniqueName := fmt.Sprintf("%s-key-%s", key.Provider, keyName) + + // Update the key with the unique name + if err := tx.Model(&key).Update("name", uniqueName).Error; err != nil { + return fmt.Errorf("failed to update key %s with name %s: %w", key.KeyID, uniqueName, err) + } + } + + // Step 3: Add unique index (SQLite compatible) + if err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_key_name ON config_keys (name)").Error; err != nil { + return fmt.Errorf("failed to create unique index on name: %w", err) + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + // Drop the unique index first to avoid orphaned index artifacts + if err := tx.Exec("DROP INDEX IF EXISTS idx_key_name").Error; err != nil { + return err + } + if err := migrator.DropColumn(&tables.TableKey{}, "name"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationCleanupMCPClientToolsConfig removes ToolsToSkipJSON column and converts empty ToolsToExecuteJSON to wildcard +func migrationCleanupMCPClientToolsConfig(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "cleanup_mcp_client_tools_config", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + // Step 1: Remove ToolsToSkipJSON column if it exists (cleanup from old versions) + if migrator.HasColumn(&tables.TableMCPClient{}, "tools_to_skip_json") { + if err := migrator.DropColumn(&tables.TableMCPClient{}, "tools_to_skip_json"); err != nil { + return fmt.Errorf("failed to drop tools_to_skip_json column: %w", err) + } + } + + // Alternative column name variations that might exist + if migrator.HasColumn(&tables.TableMCPClient{}, "ToolsToSkipJSON") { + if err := migrator.DropColumn(&tables.TableMCPClient{}, "ToolsToSkipJSON"); err != nil { + return fmt.Errorf("failed to drop ToolsToSkipJSON column: %w", err) + } + } + + // Step 2: Update empty ToolsToExecuteJSON arrays to wildcard ["*"] + // Convert "[]" (empty array) to "[\"*\"]" (wildcard array) for backward compatibility + updateSQL := ` + UPDATE config_mcp_clients + SET tools_to_execute_json = '["*"]' + WHERE tools_to_execute_json = '[]' OR tools_to_execute_json = '' OR tools_to_execute_json IS NULL + ` + if err := tx.Exec(updateSQL).Error; err != nil { + return fmt.Errorf("failed to update empty ToolsToExecuteJSON to wildcard: %w", err) + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // For rollback, we could add the column back, but since we're moving away from this + // functionality, we'll just revert the wildcard changes back to empty arrays + tx = tx.WithContext(ctx) + + revertSQL := ` + UPDATE config_mcp_clients + SET tools_to_execute_json = '[]' + WHERE tools_to_execute_json = '["*"]' + ` + if err := tx.Exec(revertSQL).Error; err != nil { + return fmt.Errorf("failed to revert wildcard ToolsToExecuteJSON to empty arrays: %w", err) + } + + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running MCP client tools cleanup migration: %s", err.Error()) + } + return nil +} + +// migrationAddVirtualKeyMCPConfigsTable adds the virtual_key_mcp_configs table +func migrationAddVirtualKeyMCPConfigsTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_vk_mcp_configs_table", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasTable(&tables.TableVirtualKeyMCPConfig{}) { + if err := migrator.CreateTable(&tables.TableVirtualKeyMCPConfig{}); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropTable(&tables.TableVirtualKeyMCPConfig{}); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddProviderConfigBudgetRateLimit adds budget_id and rate_limit_id columns with proper foreign key constraints +func migrationAddProviderConfigBudgetRateLimit(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_provider_config_budget_rate_limit", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + // Add BudgetID column if it doesn't exist + if migrator.HasTable(&tables.TableVirtualKeyProviderConfig{}) { + if !migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id") { + if err := migrator.AddColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id"); err != nil { + return fmt.Errorf("failed to add budget_id column: %w", err) + } + } + + // Add RateLimitID column if it doesn't exist + if !migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id") { + if err := migrator.AddColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id"); err != nil { + return fmt.Errorf("failed to add rate_limit_id column: %w", err) + } + } + + // Create foreign key indexes for better performance + if !migrator.HasIndex(&tables.TableVirtualKeyProviderConfig{}, "idx_provider_config_budget") { + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_config_budget ON governance_virtual_key_provider_configs (budget_id)").Error; err != nil { + return fmt.Errorf("failed to create budget_id index: %w", err) + } + } + + if !migrator.HasIndex(&tables.TableVirtualKeyProviderConfig{}, "idx_provider_config_rate_limit") { + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_config_rate_limit ON governance_virtual_key_provider_configs (rate_limit_id)").Error; err != nil { + return fmt.Errorf("failed to create rate_limit_id index: %w", err) + } + } + + // Create FK constraints (dialect‑agnostic) + if !migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget") { + if err := migrator.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget"); err != nil { + return fmt.Errorf("failed to create Budget FK constraint: %w", err) + } + } + if !migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit") { + if err := migrator.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit"); err != nil { + return fmt.Errorf("failed to create RateLimit FK constraint: %w", err) + } + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + // Drop indexes first + if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_config_budget").Error; err != nil { + return fmt.Errorf("failed to drop budget_id index: %w", err) + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_config_rate_limit").Error; err != nil { + return fmt.Errorf("failed to drop rate_limit_id index: %w", err) + } + + // Drop FK constraints + if migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget") { + if err := migrator.DropConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget"); err != nil { + return fmt.Errorf("failed to drop Budget FK constraint: %w", err) + } + } + if migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit") { + if err := migrator.DropConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit"); err != nil { + return fmt.Errorf("failed to drop RateLimit FK constraint: %w", err) + } + } + + // Drop columns + if migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id") { + if err := migrator.DropColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id"); err != nil { + return fmt.Errorf("failed to drop budget_id column: %w", err) + } + } + if migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id") { + if err := migrator.DropColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id"); err != nil { + return fmt.Errorf("failed to drop rate_limit_id column: %w", err) + } + } + + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running provider config budget/rate limit migration: %s", err.Error()) + } + return nil +} + +// migrationAddPluginPathColumn adds the path column to the plugin table +func migrationAddPluginPathColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "update_plugins_table_for_custom_plugins", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TablePlugin{}, "path") { + if err := migrator.AddColumn(&tables.TablePlugin{}, "path"); err != nil { + return err + } + } + if !migrator.HasColumn(&tables.TablePlugin{}, "is_custom") { + if err := migrator.AddColumn(&tables.TablePlugin{}, "is_custom"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&tables.TablePlugin{}, "path"); err != nil { + return err + } + if err := migrator.DropColumn(&tables.TablePlugin{}, "is_custom"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running plugin path migration: %s", err.Error()) + } + return nil +} + +// migrationAddSessionsTable adds the sessions table +func migrationAddSessionsTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_sessions_table", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasTable(&tables.SessionsTable{}) { + if err := migrator.CreateTable(&tables.SessionsTable{}); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropTable(&tables.SessionsTable{}); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddHeadersJSONColumnIntoMCPClient adds the headers_json column to the mcp_client table +func migrationAddHeadersJSONColumnIntoMCPClient(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_headers_json_column_into_mcp_client", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TableMCPClient{}, "headers_json") { + if err := migrator.AddColumn(&tables.TableMCPClient{}, "headers_json"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&tables.TableMCPClient{}, "headers_json"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +func migrationAddDisableContentLoggingColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_disable_content_logging_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TableClientConfig{}, "disable_content_logging") { + if err := migrator.AddColumn(&tables.TableClientConfig{}, "disable_content_logging"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&tables.TableClientConfig{}, "disable_content_logging"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationAddMCPClientIDColumn adds the client_id column to the mcp_clients table and populates unique client IDs +func migrationAddMCPClientIDColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_mcp_client_id_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + if !migrator.HasColumn(&tables.TableMCPClient{}, "client_id") { + // Add the column as nullable first + if err := tx.Exec("ALTER TABLE config_mcp_clients ADD COLUMN client_id VARCHAR(255)").Error; err != nil { + return fmt.Errorf("failed to add client_id column: %w", err) + } + + // Populate unique client_ids (UUIDs) for all existing MCP clients + var mcpClients []tables.TableMCPClient + if err := tx.Find(&mcpClients).Error; err != nil { + return fmt.Errorf("failed to fetch MCP clients: %w", err) + } + + for _, client := range mcpClients { + // Generate a UUID for the client_id + clientID := uuid.New().String() + + // Update the client with the generated client_id + if err := tx.Model(&client).Update("client_id", clientID).Error; err != nil { + return fmt.Errorf("failed to update MCP client %d with client_id %s: %w", client.ID, clientID, err) + } + } + + // Create unique index on client_id + if err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_client_id ON config_mcp_clients (client_id)").Error; err != nil { + return fmt.Errorf("failed to create unique index on client_id: %w", err) + } + // Enforce NOT NULL in Postgres to guarantee ID presence on new rows + if tx.Dialector.Name() == "postgres" { + if err := tx.Exec("ALTER TABLE config_mcp_clients ALTER COLUMN client_id SET NOT NULL").Error; err != nil { + return fmt.Errorf("failed to set client_id NOT NULL: %w", err) + } + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + + // Drop the unique index first to avoid orphaned index artifacts + if err := tx.Exec("DROP INDEX IF EXISTS idx_mcp_client_id").Error; err != nil { + return fmt.Errorf("failed to drop client_id index: %w", err) + } + + if err := migrator.DropColumn(&tables.TableMCPClient{}, "client_id"); err != nil { + return fmt.Errorf("failed to drop client_id column: %w", err) + } + + return nil + }, + }}) + + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running MCP client_id migration: %s", err.Error()) + } + return nil +} \ No newline at end of file diff --git a/framework/configstore/postgres.go b/framework/configstore/postgres.go new file mode 100644 index 000000000..bf9e16b49 --- /dev/null +++ b/framework/configstore/postgres.go @@ -0,0 +1,42 @@ +package configstore + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// PostgresConfig represents the configuration for a Postgres database. +type PostgresConfig struct { + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + DBName string `json:"db_name"` + SSLMode string `json:"ssl_mode"` +} + +// newPostgresConfigStore creates a new Postgres config store. +func newPostgresConfigStore(ctx context.Context, config *PostgresConfig, logger schemas.Logger) (ConfigStore, error) { + db, err := gorm.Open(postgres.Open(fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode)), &gorm.Config{ + Logger: newGormLogger(logger), + }) + if err != nil { + return nil, err + } + d := &RDBConfigStore{db: db, logger: logger} + // Run migrations + if err := triggerMigrations(ctx, db); err != nil { + // Closing the DB connection + if sqlDB, dbErr := db.DB(); dbErr == nil { + if closeErr := sqlDB.Close(); closeErr != nil { + logger.Error("failed to close DB connection: %v", closeErr) + } + } + return nil, err + } + return d, nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go new file mode 100644 index 000000000..d7f0f6c7a --- /dev/null +++ b/framework/configstore/rdb.go @@ -0,0 +1,1672 @@ +package configstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/envutils" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/framework/migrator" + "github.com/maximhq/bifrost/framework/vectorstore" + "gorm.io/gorm" +) + +// RDBConfigStore represents a configuration store that uses a relational database. +type RDBConfigStore struct { + db *gorm.DB + logger schemas.Logger +} + +// UpdateClientConfig updates the client configuration in the database. +func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientConfig) error { + dbConfig := tables.TableClientConfig{ + DropExcessRequests: config.DropExcessRequests, + InitialPoolSize: config.InitialPoolSize, + EnableLogging: config.EnableLogging, + DisableContentLogging: config.DisableContentLogging, + EnableGovernance: config.EnableGovernance, + EnforceGovernanceHeader: config.EnforceGovernanceHeader, + AllowDirectKeys: config.AllowDirectKeys, + PrometheusLabels: config.PrometheusLabels, + AllowedOrigins: config.AllowedOrigins, + MaxRequestBodySizeMB: config.MaxRequestBodySizeMB, + EnableLiteLLMFallbacks: config.EnableLiteLLMFallbacks, + } + // Delete existing client config and create new one in a transaction + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableClientConfig{}).Error; err != nil { + return err + } + return tx.Create(&dbConfig).Error + }) +} + +// Ping checks if the database is reachable. +func (s *RDBConfigStore) Ping(ctx context.Context) error { + return s.db.WithContext(ctx).Exec("SELECT 1").Error +} + +// DB returns the underlying database connection. +func (s *RDBConfigStore) DB() *gorm.DB { + return s.db +} + +// UpdateFrameworkConfig updates the framework configuration in the database. +func (s *RDBConfigStore) UpdateFrameworkConfig(ctx context.Context, config *tables.TableFrameworkConfig) error { + // Update the framework configuration + return s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableFrameworkConfig{}).Error; err != nil { + return err + } + return tx.Create(config).Error + }) + +} + +// GetFrameworkConfig retrieves the framework configuration from the database. +func (s *RDBConfigStore) GetFrameworkConfig(ctx context.Context) (*tables.TableFrameworkConfig, error) { + var dbConfig tables.TableFrameworkConfig + if err := s.db.WithContext(ctx).First(&dbConfig).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &dbConfig, nil +} + +// GetClientConfig retrieves the client configuration from the database. +func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, error) { + var dbConfig tables.TableClientConfig + if err := s.db.WithContext(ctx).First(&dbConfig).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &ClientConfig{ + DropExcessRequests: dbConfig.DropExcessRequests, + InitialPoolSize: dbConfig.InitialPoolSize, + PrometheusLabels: dbConfig.PrometheusLabels, + EnableLogging: dbConfig.EnableLogging, + DisableContentLogging: dbConfig.DisableContentLogging, + EnableGovernance: dbConfig.EnableGovernance, + EnforceGovernanceHeader: dbConfig.EnforceGovernanceHeader, + AllowDirectKeys: dbConfig.AllowDirectKeys, + AllowedOrigins: dbConfig.AllowedOrigins, + MaxRequestBodySizeMB: dbConfig.MaxRequestBodySizeMB, + EnableLiteLLMFallbacks: dbConfig.EnableLiteLLMFallbacks, + }, nil +} + +// UpdateProvidersConfig updates the client configuration in the database. +func (s *RDBConfigStore) UpdateProvidersConfig(ctx context.Context, providers map[schemas.ModelProvider]ProviderConfig) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Delete all existing providers (cascades to keys) + if err := tx.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableProvider{}).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + + for providerName, providerConfig := range providers { + dbProvider := tables.TableProvider{ + Name: string(providerName), + NetworkConfig: providerConfig.NetworkConfig, + ConcurrencyAndBufferSize: providerConfig.ConcurrencyAndBufferSize, + ProxyConfig: providerConfig.ProxyConfig, + SendBackRawResponse: providerConfig.SendBackRawResponse, + CustomProviderConfig: providerConfig.CustomProviderConfig, + } + + // Create provider first + if err := tx.WithContext(ctx).Create(&dbProvider).Error; err != nil { + return err + } + + // Create keys for this provider + dbKeys := make([]tables.TableKey, 0, len(providerConfig.Keys)) + for _, key := range providerConfig.Keys { + dbKey := tables.TableKey{ + Provider: dbProvider.Name, + ProviderID: dbProvider.ID, + KeyID: key.ID, + Name: key.Name, + Value: key.Value, + Models: key.Models, + Weight: key.Weight, + AzureKeyConfig: key.AzureKeyConfig, + VertexKeyConfig: key.VertexKeyConfig, + BedrockKeyConfig: key.BedrockKeyConfig, + } + + // Handle Azure config + if key.AzureKeyConfig != nil { + dbKey.AzureEndpoint = &key.AzureKeyConfig.Endpoint + dbKey.AzureAPIVersion = key.AzureKeyConfig.APIVersion + } + + // Handle Vertex config + if key.VertexKeyConfig != nil { + dbKey.VertexProjectID = &key.VertexKeyConfig.ProjectID + dbKey.VertexRegion = &key.VertexKeyConfig.Region + dbKey.VertexAuthCredentials = &key.VertexKeyConfig.AuthCredentials + } + + // Handle Bedrock config + if key.BedrockKeyConfig != nil { + dbKey.BedrockAccessKey = &key.BedrockKeyConfig.AccessKey + dbKey.BedrockSecretKey = &key.BedrockKeyConfig.SecretKey + dbKey.BedrockSessionToken = key.BedrockKeyConfig.SessionToken + dbKey.BedrockRegion = key.BedrockKeyConfig.Region + dbKey.BedrockARN = key.BedrockKeyConfig.ARN + } + + dbKeys = append(dbKeys, dbKey) + } + + // Upsert keys to handle duplicates properly + for _, dbKey := range dbKeys { + // First try to find existing key by KeyID + var existingKey tables.TableKey + result := tx.WithContext(ctx).Where("key_id = ?", dbKey.KeyID).First(&existingKey) + + if result.Error == nil { + // Update existing key with new data + dbKey.ID = existingKey.ID // Keep the same database ID + if err := tx.WithContext(ctx).Save(&dbKey).Error; err != nil { + return err + } + } else if errors.Is(result.Error, gorm.ErrRecordNotFound) { + // Create new key + if err := tx.WithContext(ctx).Create(&dbKey).Error; err != nil { + return err + } + } else { + // Other error occurred + return result.Error + } + } + } + return nil + }) +} + +// UpdateProvider updates a single provider configuration in the database without deleting/recreating. +func (s *RDBConfigStore) UpdateProvider(ctx context.Context, provider schemas.ModelProvider, config ProviderConfig, envKeys map[string][]EnvKeyInfo) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Find the existing provider + var dbProvider tables.TableProvider + if err := tx.WithContext(ctx).Where("name = ?", string(provider)).First(&dbProvider).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + + // Create a deep copy of the config to avoid modifying the original + configCopy, err := deepCopy(config) + if err != nil { + return err + } + // Substitute environment variables back to their original form + substituteEnvVars(&configCopy, provider, envKeys) + + // Update provider fields + dbProvider.NetworkConfig = configCopy.NetworkConfig + dbProvider.ConcurrencyAndBufferSize = configCopy.ConcurrencyAndBufferSize + dbProvider.ProxyConfig = configCopy.ProxyConfig + dbProvider.SendBackRawResponse = configCopy.SendBackRawResponse + dbProvider.CustomProviderConfig = configCopy.CustomProviderConfig + + // Save the updated provider + if err := tx.WithContext(ctx).Save(&dbProvider).Error; err != nil { + return err + } + + // Get existing keys for this provider + var existingKeys []tables.TableKey + if err := tx.WithContext(ctx).Where("provider_id = ?", dbProvider.ID).Find(&existingKeys).Error; err != nil { + return err + } + + // Create a map of existing keys by KeyID for quick lookup + existingKeysMap := make(map[string]tables.TableKey) + for _, key := range existingKeys { + existingKeysMap[key.KeyID] = key + } + + // Process each key in the new config + for _, key := range configCopy.Keys { + dbKey := tables.TableKey{ + Provider: dbProvider.Name, + ProviderID: dbProvider.ID, + KeyID: key.ID, + Name: key.Name, + Value: key.Value, + Models: key.Models, + Weight: key.Weight, + AzureKeyConfig: key.AzureKeyConfig, + VertexKeyConfig: key.VertexKeyConfig, + BedrockKeyConfig: key.BedrockKeyConfig, + } + + // Handle Azure config + if key.AzureKeyConfig != nil { + dbKey.AzureEndpoint = &key.AzureKeyConfig.Endpoint + dbKey.AzureAPIVersion = key.AzureKeyConfig.APIVersion + } + + // Handle Vertex config + if key.VertexKeyConfig != nil { + dbKey.VertexProjectID = &key.VertexKeyConfig.ProjectID + dbKey.VertexRegion = &key.VertexKeyConfig.Region + dbKey.VertexAuthCredentials = &key.VertexKeyConfig.AuthCredentials + } + + // Handle Bedrock config + if key.BedrockKeyConfig != nil { + dbKey.BedrockAccessKey = &key.BedrockKeyConfig.AccessKey + dbKey.BedrockSecretKey = &key.BedrockKeyConfig.SecretKey + dbKey.BedrockSessionToken = key.BedrockKeyConfig.SessionToken + dbKey.BedrockRegion = key.BedrockKeyConfig.Region + dbKey.BedrockARN = key.BedrockKeyConfig.ARN + } + + // Check if this key already exists + if existingKey, exists := existingKeysMap[key.ID]; exists { + // Update existing key - preserve the database ID + dbKey.ID = existingKey.ID + if err := tx.WithContext(ctx).Save(&dbKey).Error; err != nil { + return err + } + // Remove from map to track which keys are still in use + delete(existingKeysMap, key.ID) + } else { + // Create new key + if err := tx.WithContext(ctx).Create(&dbKey).Error; err != nil { + return err + } + } + } + + // Delete keys that are no longer in the new config + for _, keyToDelete := range existingKeysMap { + if err := tx.WithContext(ctx).Delete(&keyToDelete).Error; err != nil { + return err + } + } + + return nil + }) +} + +// AddProvider creates a new provider configuration in the database. +func (s *RDBConfigStore) AddProvider(ctx context.Context, provider schemas.ModelProvider, config ProviderConfig, envKeys map[string][]EnvKeyInfo) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Check if provider already exists + var existingProvider tables.TableProvider + if err := tx.WithContext(ctx).Where("name = ?", string(provider)).First(&existingProvider).Error; err == nil { + return fmt.Errorf("provider %s already exists", provider) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + // Create a deep copy of the config to avoid modifying the original + configCopy, err := deepCopy(config) + if err != nil { + return err + } + // Substitute environment variables back to their original form + substituteEnvVars(&configCopy, provider, envKeys) + + // Create new provider + dbProvider := tables.TableProvider{ + Name: string(provider), + NetworkConfig: configCopy.NetworkConfig, + ConcurrencyAndBufferSize: configCopy.ConcurrencyAndBufferSize, + ProxyConfig: configCopy.ProxyConfig, + SendBackRawResponse: configCopy.SendBackRawResponse, + CustomProviderConfig: configCopy.CustomProviderConfig, + } + + // Create the provider + if err := tx.WithContext(ctx).Create(&dbProvider).Error; err != nil { + return err + } + + // Create keys for this provider + for _, key := range configCopy.Keys { + dbKey := tables.TableKey{ + Provider: dbProvider.Name, + ProviderID: dbProvider.ID, + KeyID: key.ID, + Name: key.Name, + Value: key.Value, + Models: key.Models, + Weight: key.Weight, + AzureKeyConfig: key.AzureKeyConfig, + VertexKeyConfig: key.VertexKeyConfig, + BedrockKeyConfig: key.BedrockKeyConfig, + } + + // Handle Azure config + if key.AzureKeyConfig != nil { + dbKey.AzureEndpoint = &key.AzureKeyConfig.Endpoint + dbKey.AzureAPIVersion = key.AzureKeyConfig.APIVersion + } + + // Handle Vertex config + if key.VertexKeyConfig != nil { + dbKey.VertexProjectID = &key.VertexKeyConfig.ProjectID + dbKey.VertexRegion = &key.VertexKeyConfig.Region + dbKey.VertexAuthCredentials = &key.VertexKeyConfig.AuthCredentials + } + + // Handle Bedrock config + if key.BedrockKeyConfig != nil { + dbKey.BedrockAccessKey = &key.BedrockKeyConfig.AccessKey + dbKey.BedrockSecretKey = &key.BedrockKeyConfig.SecretKey + dbKey.BedrockSessionToken = key.BedrockKeyConfig.SessionToken + dbKey.BedrockRegion = key.BedrockKeyConfig.Region + dbKey.BedrockARN = key.BedrockKeyConfig.ARN + } + + // Create the key + if err := tx.WithContext(ctx).Create(&dbKey).Error; err != nil { + return err + } + } + + return nil + }) +} + +// DeleteProvider deletes a single provider and all its associated keys from the database. +func (s *RDBConfigStore) DeleteProvider(ctx context.Context, provider schemas.ModelProvider) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Find the existing provider + var dbProvider tables.TableProvider + if err := tx.WithContext(ctx).Where("name = ?", string(provider)).First(&dbProvider).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + + // Delete the provider (keys will be deleted due to CASCADE constraint) + if err := tx.WithContext(ctx).Delete(&dbProvider).Error; err != nil { + return err + } + + return nil + }) +} + +// GetProvidersConfig retrieves the provider configuration from the database. +func (s *RDBConfigStore) GetProvidersConfig(ctx context.Context) (map[schemas.ModelProvider]ProviderConfig, error) { + var dbProviders []tables.TableProvider + if err := s.db.WithContext(ctx).Preload("Keys").Find(&dbProviders).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + if len(dbProviders) == 0 { + // No providers in database, auto-detect from environment + return nil, nil + } + processedProviders := make(map[schemas.ModelProvider]ProviderConfig) + for _, dbProvider := range dbProviders { + provider := schemas.ModelProvider(dbProvider.Name) + // Convert database keys to schemas.Key + keys := make([]schemas.Key, len(dbProvider.Keys)) + for i, dbKey := range dbProvider.Keys { + // Process main key value + processedValue, err := envutils.ProcessEnvValue(dbKey.Value) + if err != nil { + // If env var not found, keep the original value + processedValue = dbKey.Value + } + + // Process Azure config if present + azureConfig := dbKey.AzureKeyConfig + if azureConfig != nil { + azureConfigCopy := *azureConfig + if processedEndpoint, err := envutils.ProcessEnvValue(azureConfig.Endpoint); err == nil { + azureConfigCopy.Endpoint = processedEndpoint + } + if azureConfig.APIVersion != nil { + if processedAPIVersion, err := envutils.ProcessEnvValue(*azureConfig.APIVersion); err == nil { + azureConfigCopy.APIVersion = &processedAPIVersion + } + } + azureConfig = &azureConfigCopy + } + + // Process Vertex config if present + vertexConfig := dbKey.VertexKeyConfig + if vertexConfig != nil { + vertexConfigCopy := *vertexConfig + if processedProjectID, err := envutils.ProcessEnvValue(vertexConfig.ProjectID); err == nil { + vertexConfigCopy.ProjectID = processedProjectID + } + if processedRegion, err := envutils.ProcessEnvValue(vertexConfig.Region); err == nil { + vertexConfigCopy.Region = processedRegion + } + if processedAuthCredentials, err := envutils.ProcessEnvValue(vertexConfig.AuthCredentials); err == nil { + vertexConfigCopy.AuthCredentials = processedAuthCredentials + } + vertexConfig = &vertexConfigCopy + } + + // Process Bedrock config if present + bedrockConfig := dbKey.BedrockKeyConfig + if bedrockConfig != nil { + bedrockConfigCopy := *bedrockConfig + if processedAccessKey, err := envutils.ProcessEnvValue(bedrockConfig.AccessKey); err == nil { + bedrockConfigCopy.AccessKey = processedAccessKey + } + if processedSecretKey, err := envutils.ProcessEnvValue(bedrockConfig.SecretKey); err == nil { + bedrockConfigCopy.SecretKey = processedSecretKey + } + if bedrockConfig.SessionToken != nil { + if processedSessionToken, err := envutils.ProcessEnvValue(*bedrockConfig.SessionToken); err == nil { + bedrockConfigCopy.SessionToken = &processedSessionToken + } + } + if bedrockConfig.Region != nil { + if processedRegion, err := envutils.ProcessEnvValue(*bedrockConfig.Region); err == nil { + bedrockConfigCopy.Region = &processedRegion + } + } + if bedrockConfig.ARN != nil { + if processedARN, err := envutils.ProcessEnvValue(*bedrockConfig.ARN); err == nil { + bedrockConfigCopy.ARN = &processedARN + } + } + bedrockConfig = &bedrockConfigCopy + } + + keys[i] = schemas.Key{ + ID: dbKey.KeyID, + Name: dbKey.Name, + Value: processedValue, + Models: dbKey.Models, + Weight: dbKey.Weight, + AzureKeyConfig: azureConfig, + VertexKeyConfig: vertexConfig, + BedrockKeyConfig: bedrockConfig, + } + } + providerConfig := ProviderConfig{ + Keys: keys, + NetworkConfig: dbProvider.NetworkConfig, + ConcurrencyAndBufferSize: dbProvider.ConcurrencyAndBufferSize, + ProxyConfig: dbProvider.ProxyConfig, + SendBackRawResponse: dbProvider.SendBackRawResponse, + CustomProviderConfig: dbProvider.CustomProviderConfig, + } + processedProviders[provider] = providerConfig + } + return processedProviders, nil +} + +// GetMCPConfig retrieves the MCP configuration from the database. +func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, error) { + var dbMCPClients []tables.TableMCPClient + if err := s.db.WithContext(ctx).Find(&dbMCPClients).Error; err != nil { + return nil, err + } + if len(dbMCPClients) == 0 { + return nil, nil + } + clientConfigs := make([]schemas.MCPClientConfig, len(dbMCPClients)) + for i, dbClient := range dbMCPClients { + // Process connection string for environment variables + var processedConnectionString *string + if dbClient.ConnectionString != nil { + processedValue, err := envutils.ProcessEnvValue(*dbClient.ConnectionString) + if err != nil { + // If env var not found, keep the original value + processedValue = *dbClient.ConnectionString + } + processedConnectionString = &processedValue + } + + // Process headers + var processedHeaders map[string]string + if dbClient.Headers != nil { + processedHeaders = make(map[string]string, len(dbClient.Headers)) + for header, value := range dbClient.Headers { + processedValue, err := envutils.ProcessEnvValue(value) + if err == nil { + processedHeaders[header] = processedValue + } else { + processedHeaders[header] = value + } + } + } + + clientConfigs[i] = schemas.MCPClientConfig{ + ID: dbClient.ClientID, + Name: dbClient.Name, + ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), + ConnectionString: processedConnectionString, + StdioConfig: dbClient.StdioConfig, + ToolsToExecute: dbClient.ToolsToExecute, + Headers: processedHeaders, + } + } + return &schemas.MCPConfig{ + ClientConfigs: clientConfigs, + }, nil +} + +// GetMCPClientByName retrieves an MCP client by name from the database. +func (s *RDBConfigStore) GetMCPClientByName(ctx context.Context, name string) (*tables.TableMCPClient, error) { + var mcpClient tables.TableMCPClient + if err := s.db.WithContext(ctx).Where("name = ?", name).First(&mcpClient).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &mcpClient, nil +} + +// CreateMCPClientConfig creates a new MCP client configuration in the database. +func (s *RDBConfigStore) CreateMCPClientConfig(ctx context.Context, clientConfig schemas.MCPClientConfig, envKeys map[string][]EnvKeyInfo) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Check if client already exists + var existingClient tables.TableMCPClient + if err := tx.WithContext(ctx).Where("name = ?", clientConfig.Name).First(&existingClient).Error; err == nil { + return fmt.Errorf("MCP client with name '%s' already exists", clientConfig.Name) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + // Create a deep copy to avoid modifying the original + clientConfigCopy, err := deepCopy(clientConfig) + if err != nil { + return err + } + + // Substitute environment variables back to their original form + substituteMCPClientEnvVars(&clientConfigCopy, envKeys) + + // Create new client + dbClient := tables.TableMCPClient{ + ClientID: clientConfigCopy.ID, + Name: clientConfigCopy.Name, + ConnectionType: string(clientConfigCopy.ConnectionType), + ConnectionString: clientConfigCopy.ConnectionString, + StdioConfig: clientConfigCopy.StdioConfig, + ToolsToExecute: clientConfigCopy.ToolsToExecute, + Headers: clientConfigCopy.Headers, + } + + return tx.WithContext(ctx).Create(&dbClient).Error + }) +} + +// UpdateMCPClientConfig updates an existing MCP client configuration in the database. +func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, clientConfig schemas.MCPClientConfig, envKeys map[string][]EnvKeyInfo) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Find existing client + var existingClient tables.TableMCPClient + if err := tx.WithContext(ctx).Where("client_id = ?", id).First(&existingClient).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("MCP client with id '%s' not found", id) + } + return err + } + + // Create a deep copy to avoid modifying the original + clientConfigCopy, err := deepCopy(clientConfig) + if err != nil { + return err + } + + // Substitute environment variables back to their original form + substituteMCPClientEnvVars(&clientConfigCopy, envKeys) + + // Update existing client + existingClient.Name = clientConfigCopy.Name + existingClient.ConnectionType = string(clientConfigCopy.ConnectionType) + existingClient.ConnectionString = clientConfigCopy.ConnectionString + existingClient.StdioConfig = clientConfigCopy.StdioConfig + existingClient.ToolsToExecute = clientConfigCopy.ToolsToExecute + existingClient.Headers = clientConfigCopy.Headers + + return tx.WithContext(ctx).Updates(&existingClient).Error + }) +} + +// DeleteMCPClientConfig deletes an MCP client configuration from the database. +func (s *RDBConfigStore) DeleteMCPClientConfig(ctx context.Context, id string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Find existing client + var existingClient tables.TableMCPClient + if err := tx.WithContext(ctx).Where("client_id = ?", id).First(&existingClient).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("MCP client with id '%s' not found", id) + } + return err + } + + // Delete any virtual key MCP configs that reference this client + if err := tx.WithContext(ctx).Where("mcp_client_id = ?", existingClient.ID).Delete(&tables.TableVirtualKeyMCPConfig{}).Error; err != nil { + return err + } + + // Delete the client (this will also handle foreign key cascades) + return tx.WithContext(ctx).Delete(&existingClient).Error + }) +} + +// GetVectorStoreConfig retrieves the vector store configuration from the database. +func (s *RDBConfigStore) GetVectorStoreConfig(ctx context.Context) (*vectorstore.Config, error) { + var vectorStoreTableConfig tables.TableVectorStoreConfig + if err := s.db.WithContext(ctx).First(&vectorStoreTableConfig).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // Return default cache configuration + return nil, nil + } + return nil, err + } + return &vectorstore.Config{ + Enabled: vectorStoreTableConfig.Enabled, + Config: vectorStoreTableConfig.Config, + Type: vectorstore.VectorStoreType(vectorStoreTableConfig.Type), + }, nil +} + +// UpdateVectorStoreConfig updates the vector store configuration in the database. +func (s *RDBConfigStore) UpdateVectorStoreConfig(ctx context.Context, config *vectorstore.Config) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Delete existing cache config + if err := tx.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableVectorStoreConfig{}).Error; err != nil { + return err + } + jsonConfig, err := marshalToStringPtr(config.Config) + if err != nil { + return err + } + var record = &tables.TableVectorStoreConfig{ + Type: string(config.Type), + Enabled: config.Enabled, + Config: jsonConfig, + } + // Create new cache config + return tx.WithContext(ctx).Create(record).Error + }) +} + +// GetLogsStoreConfig retrieves the logs store configuration from the database. +func (s *RDBConfigStore) GetLogsStoreConfig(ctx context.Context) (*logstore.Config, error) { + var dbConfig tables.TableLogStoreConfig + if err := s.db.WithContext(ctx).First(&dbConfig).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + if dbConfig.Config == nil || *dbConfig.Config == "" { + return &logstore.Config{Enabled: dbConfig.Enabled}, nil + } + var logStoreConfig logstore.Config + if err := json.Unmarshal([]byte(*dbConfig.Config), &logStoreConfig); err != nil { + return nil, err + } + return &logStoreConfig, nil +} + +// UpdateLogsStoreConfig updates the logs store configuration in the database. +func (s *RDBConfigStore) UpdateLogsStoreConfig(ctx context.Context, config *logstore.Config) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableLogStoreConfig{}).Error; err != nil { + return err + } + jsonConfig, err := marshalToStringPtr(config) + if err != nil { + return err + } + var record = &tables.TableLogStoreConfig{ + Enabled: config.Enabled, + Type: string(config.Type), + Config: jsonConfig, + } + return tx.WithContext(ctx).Create(record).Error + }) +} + +// GetEnvKeys retrieves the environment keys from the database. +func (s *RDBConfigStore) GetEnvKeys(ctx context.Context) (map[string][]EnvKeyInfo, error) { + var dbEnvKeys []tables.TableEnvKey + if err := s.db.WithContext(ctx).Find(&dbEnvKeys).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + envKeys := make(map[string][]EnvKeyInfo) + for _, dbEnvKey := range dbEnvKeys { + envKeys[dbEnvKey.EnvVar] = append(envKeys[dbEnvKey.EnvVar], EnvKeyInfo{ + EnvVar: dbEnvKey.EnvVar, + Provider: schemas.ModelProvider(dbEnvKey.Provider), + KeyType: EnvKeyType(dbEnvKey.KeyType), + ConfigPath: dbEnvKey.ConfigPath, + KeyID: dbEnvKey.KeyID, + }) + } + return envKeys, nil +} + +// UpdateEnvKeys updates the environment keys in the database. +func (s *RDBConfigStore) UpdateEnvKeys(ctx context.Context, keys map[string][]EnvKeyInfo) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // Delete existing env keys + if err := tx.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableEnvKey{}).Error; err != nil { + return err + } + var dbEnvKeys []tables.TableEnvKey + for envVar, infos := range keys { + for _, info := range infos { + dbEnvKey := tables.TableEnvKey{ + EnvVar: envVar, + Provider: string(info.Provider), + KeyType: string(info.KeyType), + ConfigPath: info.ConfigPath, + KeyID: info.KeyID, + } + dbEnvKeys = append(dbEnvKeys, dbEnvKey) + } + } + if len(dbEnvKeys) > 0 { + if err := tx.WithContext(ctx).CreateInBatches(dbEnvKeys, 100).Error; err != nil { + return err + } + } + return nil + }) +} + +// GetConfig retrieves a specific config from the database. +func (s *RDBConfigStore) GetConfig(ctx context.Context, key string) (*tables.TableGovernanceConfig, error) { + var config tables.TableGovernanceConfig + if err := s.db.WithContext(ctx).First(&config, "key = ?", key).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &config, nil +} + +// UpdateConfig updates a specific config in the database. +func (s *RDBConfigStore) UpdateConfig(ctx context.Context, config *tables.TableGovernanceConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(config).Error +} + +// GetModelPrices retrieves all model pricing records from the database. +func (s *RDBConfigStore) GetModelPrices(ctx context.Context) ([]tables.TableModelPricing, error) { + var modelPrices []tables.TableModelPricing + if err := s.db.WithContext(ctx).Find(&modelPrices).Error; err != nil { + return nil, err + } + return modelPrices, nil +} + +// CreateModelPrices creates a new model pricing record in the database. +func (s *RDBConfigStore) CreateModelPrices(ctx context.Context, pricing *tables.TableModelPricing, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(pricing).Error +} + +// DeleteModelPrices deletes all model pricing records from the database. +func (s *RDBConfigStore) DeleteModelPrices(ctx context.Context, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.TableModelPricing{}).Error +} + +// PLUGINS METHODS + +func (s *RDBConfigStore) GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) { + var plugins []*tables.TablePlugin + if err := s.db.WithContext(ctx).Find(&plugins).Error; err != nil { + return nil, err + } + return plugins, nil +} + +func (s *RDBConfigStore) GetPlugin(ctx context.Context, name string) (*tables.TablePlugin, error) { + var plugin tables.TablePlugin + if err := s.db.WithContext(ctx).First(&plugin, "name = ?", name).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &plugin, nil +} + +func (s *RDBConfigStore) CreatePlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + // Mark plugin as custom if path is not empty + if plugin.Path != nil && strings.TrimSpace(*plugin.Path) != "" { + plugin.IsCustom = true + } else { + plugin.IsCustom = false + } + return txDB.WithContext(ctx).Create(plugin).Error +} + +func (s *RDBConfigStore) UpdatePlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error { + var txDB *gorm.DB + var localTx bool + + if len(tx) > 0 { + txDB = tx[0] + localTx = false + } else { + txDB = s.db.Begin() + localTx = true + } + + // Mark plugin as custom if path is not empty + if plugin.Path != nil && strings.TrimSpace(*plugin.Path) != "" { + plugin.IsCustom = true + } else { + plugin.IsCustom = false + } + + if err := txDB.WithContext(ctx).Delete(&tables.TablePlugin{}, "name = ?", plugin.Name).Error; err != nil { + if localTx { + txDB.Rollback() + } + return err + } + + if err := txDB.WithContext(ctx).Create(plugin).Error; err != nil { + if localTx { + txDB.Rollback() + } + return err + } + + if localTx { + return txDB.Commit().Error + } + + return nil +} + +func (s *RDBConfigStore) DeletePlugin(ctx context.Context, name string, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Delete(&tables.TablePlugin{}, "name = ?", name).Error +} + +// GOVERNANCE METHODS + +func (s *RDBConfigStore) GetRedactedVirtualKeys(ctx context.Context, ids []string) ([]tables.TableVirtualKey, error) { + var virtualKeys []tables.TableVirtualKey + + if len(ids) > 0 { + err := s.db.WithContext(ctx).Select("id, name, description, is_active").Where("id IN ?", ids).Find(&virtualKeys).Error + if err != nil { + return nil, err + } + } else { + err := s.db.WithContext(ctx).Select("id, name, description, is_active").Find(&virtualKeys).Error + if err != nil { + return nil, err + } + } + return virtualKeys, nil +} + +// GetVirtualKeys retrieves all virtual keys from the database. +func (s *RDBConfigStore) GetVirtualKeys(ctx context.Context) ([]tables.TableVirtualKey, error) { + var virtualKeys []tables.TableVirtualKey + + // Preload all relationships for complete information + if err := s.db.WithContext(ctx).Preload("Team"). + Preload("Customer"). + Preload("Budget"). + Preload("RateLimit"). + Preload("ProviderConfigs"). + Preload("ProviderConfigs.Budget"). + Preload("ProviderConfigs.RateLimit"). + Preload("MCPConfigs"). + Preload("MCPConfigs.MCPClient"). + Preload("Keys", func(db *gorm.DB) *gorm.DB { + return db.Select("id, name, key_id, models_json, provider") + }).Find(&virtualKeys).Error; err != nil { + return nil, err + } + + return virtualKeys, nil +} + +// GetVirtualKey retrieves a virtual key from the database. +func (s *RDBConfigStore) GetVirtualKey(ctx context.Context, id string) (*tables.TableVirtualKey, error) { + var virtualKey tables.TableVirtualKey + if err := s.db.WithContext(ctx).Preload("Team"). + Preload("Customer"). + Preload("Budget"). + Preload("RateLimit"). + Preload("ProviderConfigs"). + Preload("ProviderConfigs.Budget"). + Preload("ProviderConfigs.RateLimit"). + Preload("MCPConfigs"). + Preload("MCPConfigs.MCPClient"). + Preload("Keys", func(db *gorm.DB) *gorm.DB { + return db.Select("id, name, key_id, models_json, provider") + }).First(&virtualKey, "id = ?", id).Error; err != nil { + return nil, err + } + return &virtualKey, nil +} + +// GetVirtualKeyByValue retrieves a virtual key by its value +func (s *RDBConfigStore) GetVirtualKeyByValue(ctx context.Context, value string) (*tables.TableVirtualKey, error) { + var virtualKey tables.TableVirtualKey + if err := s.db.WithContext(ctx).Preload("Team"). + Preload("Customer"). + Preload("Budget"). + Preload("RateLimit"). + Preload("ProviderConfigs"). + Preload("ProviderConfigs.Budget"). + Preload("ProviderConfigs.RateLimit"). + Preload("MCPConfigs"). + Preload("MCPConfigs.MCPClient"). + Preload("Keys", func(db *gorm.DB) *gorm.DB { + return db.Select("id, name, key_id, models_json, provider") + }).First(&virtualKey, "value = ?", value).Error; err != nil { + return nil, err + } + return &virtualKey, nil +} + +func (s *RDBConfigStore) CreateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + + // Check if virtual key already exists with the same value or name + if err := txDB.WithContext(ctx).Where("value = ? OR name = ?", virtualKey.Value, virtualKey.Name).First(&tables.TableVirtualKey{}).Error; err == nil { + return fmt.Errorf("virtual key already exists with the same value or name") + } + + // Create virtual key first + if err := txDB.WithContext(ctx).Create(virtualKey).Error; err != nil { + return err + } + + // Create key associations after the virtual key has an ID + if len(virtualKey.Keys) > 0 { + if err := txDB.WithContext(ctx).Model(virtualKey).Association("Keys").Append(virtualKey.Keys); err != nil { + return err + } + } + + return nil +} + +func (s *RDBConfigStore) UpdateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + + // Check if virtual key already exists with the same value or name + var existingVirtualKey tables.TableVirtualKey + if err := txDB.WithContext(ctx). + Where("id <> ? AND (value = ? OR name = ?)", virtualKey.ID, virtualKey.Value, virtualKey.Name). + First(&existingVirtualKey).Error; err == nil { + return fmt.Errorf("virtual key already exists with the same value or name") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + // Store the keys before Save() clears them + keysToAssociate := virtualKey.Keys + + // Update virtual key first (this will clear the Keys field) + // Use Select() to explicitly update all fields, including nil pointer fields + // This ensures TeamID gets set to NULL when switching from team to customer association + if err := txDB.WithContext(ctx).Select("name", "description", "value", "is_active", "team_id", "customer_id", "budget_id", "rate_limit_id", "updated_at").Updates(virtualKey).Error; err != nil { + return err + } + // Clear existing key associations + if err := txDB.WithContext(ctx).Model(virtualKey).Association("Keys").Clear(); err != nil { + return err + } + + // Create new key associations using the stored keys + if len(keysToAssociate) > 0 { + if err := txDB.WithContext(ctx).Model(virtualKey).Association("Keys").Append(keysToAssociate); err != nil { + return err + } + } + + return nil +} + +// GetKeysByIDs retrieves multiple keys by their IDs +func (s *RDBConfigStore) GetKeysByIDs(ctx context.Context, ids []string) ([]tables.TableKey, error) { + if len(ids) == 0 { + return []tables.TableKey{}, nil + } + + var keys []tables.TableKey + if err := s.db.WithContext(ctx).Where("key_id IN ?", ids).Find(&keys).Error; err != nil { + return nil, err + } + return keys, nil +} + +// GetAllRedactedKeys retrieves all redacted keys from the database. +func (s *RDBConfigStore) GetAllRedactedKeys(ctx context.Context, ids []string) ([]schemas.Key, error) { + var keys []tables.TableKey + if len(ids) > 0 { + err := s.db.WithContext(ctx).Select("id, key_id, name, models_json, weight").Where("key_id IN ?", ids).Find(&keys).Error + if err != nil { + return nil, err + } + } else { + err := s.db.WithContext(ctx).Select("id, key_id, name, models_json, weight").Find(&keys).Error + if err != nil { + return nil, err + } + } + redactedKeys := make([]schemas.Key, len(keys)) + for i, key := range keys { + redactedKeys[i] = schemas.Key{ + ID: key.KeyID, + Name: key.Name, + Models: key.Models, + Weight: key.Weight, + } + } + return redactedKeys, nil +} + +// DeleteVirtualKey deletes a virtual key from the database. +func (s *RDBConfigStore) DeleteVirtualKey(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Delete(&tables.TableVirtualKey{}, "id = ?", id).Error +} + +// GetVirtualKeyProviderConfigs retrieves all virtual key provider configs from the database. +func (s *RDBConfigStore) GetVirtualKeyProviderConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyProviderConfig, error) { + var virtualKey tables.TableVirtualKey + if err := s.db.WithContext(ctx).First(&virtualKey, "id = ?", virtualKeyID).Error; err != nil { + return nil, err + } + + if virtualKey.ID == "" { + return nil, nil + } + + var providerConfigs []tables.TableVirtualKeyProviderConfig + if err := s.db.WithContext(ctx).Where("virtual_key_id = ?", virtualKey.ID).Find(&providerConfigs).Error; err != nil { + return nil, err + } + return providerConfigs, nil +} + +// CreateVirtualKeyProviderConfig creates a new virtual key provider config in the database. +func (s *RDBConfigStore) CreateVirtualKeyProviderConfig(ctx context.Context, virtualKeyProviderConfig *tables.TableVirtualKeyProviderConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(virtualKeyProviderConfig).Error +} + +// UpdateVirtualKeyProviderConfig updates a virtual key provider config in the database. +func (s *RDBConfigStore) UpdateVirtualKeyProviderConfig(ctx context.Context, virtualKeyProviderConfig *tables.TableVirtualKeyProviderConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(virtualKeyProviderConfig).Error +} + +// DeleteVirtualKeyProviderConfig deletes a virtual key provider config from the database. +func (s *RDBConfigStore) DeleteVirtualKeyProviderConfig(ctx context.Context, id uint, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Delete(&tables.TableVirtualKeyProviderConfig{}, "id = ?", id).Error +} + +// GetVirtualKeyMCPConfigs retrieves all virtual key MCP configs from the database. +func (s *RDBConfigStore) GetVirtualKeyMCPConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyMCPConfig, error) { + var virtualKey tables.TableVirtualKey + if err := s.db.WithContext(ctx).First(&virtualKey, "id = ?", virtualKeyID).Error; err != nil { + return nil, err + } + + if virtualKey.ID == "" { + return nil, nil + } + + var mcpConfigs []tables.TableVirtualKeyMCPConfig + if err := s.db.WithContext(ctx).Where("virtual_key_id = ?", virtualKey.ID).Find(&mcpConfigs).Error; err != nil { + return nil, err + } + return mcpConfigs, nil +} + +// CreateVirtualKeyMCPConfig creates a new virtual key MCP config in the database. +func (s *RDBConfigStore) CreateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(virtualKeyMCPConfig).Error +} + +// UpdateVirtualKeyMCPConfig updates a virtual key provider config in the database. +func (s *RDBConfigStore) UpdateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(virtualKeyMCPConfig).Error +} + +// DeleteVirtualKeyMCPConfig deletes a virtual key provider config from the database. +func (s *RDBConfigStore) DeleteVirtualKeyMCPConfig(ctx context.Context, id uint, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Delete(&tables.TableVirtualKeyMCPConfig{}, "id = ?", id).Error +} + +// GetTeams retrieves all teams from the database. +func (s *RDBConfigStore) GetTeams(ctx context.Context, customerID string) ([]tables.TableTeam, error) { + // Preload relationships for complete information + query := s.db.WithContext(ctx).Preload("Customer").Preload("Budget") + + // Optional filtering by customer + if customerID != "" { + query = query.Where("customer_id = ?", customerID) + } + + var teams []tables.TableTeam + if err := query.Find(&teams).Error; err != nil { + return nil, err + } + return teams, nil +} + +// GetTeam retrieves a specific team from the database. +func (s *RDBConfigStore) GetTeam(ctx context.Context, id string) (*tables.TableTeam, error) { + var team tables.TableTeam + if err := s.db.WithContext(ctx).Preload("Customer").Preload("Budget").First(&team, "id = ?", id).Error; err != nil { + return nil, err + } + return &team, nil +} + +// CreateTeam creates a new team in the database. +func (s *RDBConfigStore) CreateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(team).Error +} + +// UpdateTeam updates an existing team in the database. +func (s *RDBConfigStore) UpdateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(team).Error +} + +// DeleteTeam deletes a team from the database. +func (s *RDBConfigStore) DeleteTeam(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Delete(&tables.TableTeam{}, "id = ?", id).Error +} + +// GetCustomers retrieves all customers from the database. +func (s *RDBConfigStore) GetCustomers(ctx context.Context) ([]tables.TableCustomer, error) { + var customers []tables.TableCustomer + if err := s.db.WithContext(ctx).Preload("Teams").Preload("Budget").Find(&customers).Error; err != nil { + return nil, err + } + return customers, nil +} + +// GetCustomer retrieves a specific customer from the database. +func (s *RDBConfigStore) GetCustomer(ctx context.Context, id string) (*tables.TableCustomer, error) { + var customer tables.TableCustomer + if err := s.db.WithContext(ctx).Preload("Teams").Preload("Budget").First(&customer, "id = ?", id).Error; err != nil { + return nil, err + } + return &customer, nil +} + +// CreateCustomer creates a new customer in the database. +func (s *RDBConfigStore) CreateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(customer).Error +} + +// UpdateCustomer updates an existing customer in the database. +func (s *RDBConfigStore) UpdateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(customer).Error +} + +// DeleteCustomer deletes a customer from the database. +func (s *RDBConfigStore) DeleteCustomer(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Delete(&tables.TableCustomer{}, "id = ?", id).Error +} + +// GetRateLimit retrieves a specific rate limit from the database. +func (s *RDBConfigStore) GetRateLimit(ctx context.Context, id string) (*tables.TableRateLimit, error) { + var rateLimit tables.TableRateLimit + if err := s.db.WithContext(ctx).First(&rateLimit, "id = ?", id).Error; err != nil { + return nil, err + } + return &rateLimit, nil +} + +// CreateRateLimit creates a new rate limit in the database. +func (s *RDBConfigStore) CreateRateLimit(ctx context.Context, rateLimit *tables.TableRateLimit, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(rateLimit).Error +} + +// UpdateRateLimit updates a rate limit in the database. +func (s *RDBConfigStore) UpdateRateLimit(ctx context.Context, rateLimit *tables.TableRateLimit, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(rateLimit).Error +} + +// UpdateRateLimits updates multiple rate limits in the database. +func (s *RDBConfigStore) UpdateRateLimits(ctx context.Context, rateLimits []*tables.TableRateLimit, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + for _, rl := range rateLimits { + if err := txDB.WithContext(ctx).Save(rl).Error; err != nil { + return err + } + } + return nil +} + +// GetBudgets retrieves all budgets from the database. +func (s *RDBConfigStore) GetBudgets(ctx context.Context) ([]tables.TableBudget, error) { + var budgets []tables.TableBudget + if err := s.db.WithContext(ctx).Find(&budgets).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return budgets, nil +} + +// GetBudget retrieves a specific budget from the database. +func (s *RDBConfigStore) GetBudget(ctx context.Context, id string, tx ...*gorm.DB) (*tables.TableBudget, error) { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + var budget tables.TableBudget + if err := txDB.WithContext(ctx).First(&budget, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &budget, nil +} + +// CreateBudget creates a new budget in the database. +func (s *RDBConfigStore) CreateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Create(budget).Error +} + +// UpdateBudgets updates multiple budgets in the database. +func (s *RDBConfigStore) UpdateBudgets(ctx context.Context, budgets []*tables.TableBudget, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + s.logger.Debug("updating budgets: %+v", budgets) + for _, b := range budgets { + if err := txDB.WithContext(ctx).Save(b).Error; err != nil { + return err + } + } + return nil +} + +// UpdateBudget updates a budget in the database. +func (s *RDBConfigStore) UpdateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db + } + return txDB.WithContext(ctx).Save(budget).Error +} + +// GetGovernanceConfig retrieves the governance configuration from the database. +func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceConfig, error) { + var virtualKeys []tables.TableVirtualKey + var teams []tables.TableTeam + var customers []tables.TableCustomer + var budgets []tables.TableBudget + var rateLimits []tables.TableRateLimit + var governanceConfigs []tables.TableGovernanceConfig + + if err := s.db.WithContext(ctx).Preload("ProviderConfigs").Find(&virtualKeys).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Find(&teams).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Find(&customers).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Find(&budgets).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Find(&rateLimits).Error; err != nil { + return nil, err + } + // Fetching governance config for username and password + if err := s.db.WithContext(ctx).Find(&governanceConfigs).Error; err != nil { + return nil, err + } + // Check if any config is present + if len(virtualKeys) == 0 && len(teams) == 0 && len(customers) == 0 && len(budgets) == 0 && len(rateLimits) == 0 && len(governanceConfigs) == 0 { + return nil, nil + } + var authConfig *AuthConfig + if len(governanceConfigs) > 0 { + // Checking if username and password is present + var username *string + var password *string + var isEnabled bool + for _, entry := range governanceConfigs { + switch entry.Key { + case tables.ConfigAdminUsernameKey: + username = bifrost.Ptr(entry.Value) + case tables.ConfigAdminPasswordKey: + password = bifrost.Ptr(entry.Value) + case tables.ConfigIsAuthEnabledKey: + isEnabled = entry.Value == "true" + } + } + if username != nil && password != nil { + authConfig = &AuthConfig{ + AdminUserName: *username, + AdminPassword: *password, + IsEnabled: isEnabled, + } + } + } + return &GovernanceConfig{ + VirtualKeys: virtualKeys, + Teams: teams, + Customers: customers, + Budgets: budgets, + RateLimits: rateLimits, + AuthConfig: authConfig, + }, nil +} + +// GetAuthConfig retrieves the auth configuration from the database. +func (s *RDBConfigStore) GetAuthConfig(ctx context.Context) (*AuthConfig, error) { + var username *string + var password *string + var isEnabled bool + var disableAuthOnInference bool + if err := s.db.WithContext(ctx).First(&tables.TableGovernanceConfig{}, "key = ?", tables.ConfigAdminUsernameKey).Select("value").Scan(&username).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if err := s.db.WithContext(ctx).First(&tables.TableGovernanceConfig{}, "key = ?", tables.ConfigAdminPasswordKey).Select("value").Scan(&password).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + } + if err := s.db.WithContext(ctx).First(&tables.TableGovernanceConfig{}, "key = ?", tables.ConfigIsAuthEnabledKey).Select("value").Scan(&isEnabled).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if err := s.db.WithContext(ctx).First(&tables.TableGovernanceConfig{}, "key = ?", tables.ConfigDisableAuthOnInferenceKey).Select("value").Scan(&disableAuthOnInference).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if username == nil || password == nil { + return nil, nil + } + return &AuthConfig{ + AdminUserName: *username, + AdminPassword: *password, + IsEnabled: isEnabled, + DisableAuthOnInference: disableAuthOnInference, + }, nil +} + +// UpdateAuthConfig updates the auth configuration in the database. +func (s *RDBConfigStore) UpdateAuthConfig(ctx context.Context, config *AuthConfig) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(&tables.TableGovernanceConfig{ + Key: tables.ConfigAdminUsernameKey, + Value: config.AdminUserName, + }).Error; err != nil { + return err + } + if err := tx.Save(&tables.TableGovernanceConfig{ + Key: tables.ConfigAdminPasswordKey, + Value: config.AdminPassword, + }).Error; err != nil { + return err + } + if err := tx.Save(&tables.TableGovernanceConfig{ + Key: tables.ConfigIsAuthEnabledKey, + Value: fmt.Sprintf("%t", config.IsEnabled), + }).Error; err != nil { + return err + } + if err := tx.Save(&tables.TableGovernanceConfig{ + Key: tables.ConfigDisableAuthOnInferenceKey, + Value: fmt.Sprintf("%t", config.DisableAuthOnInference), + }).Error; err != nil { + return err + } + return nil + }) +} + +// GetSession retrieves a session from the database. +func (s *RDBConfigStore) GetSession(ctx context.Context, token string) (*tables.SessionsTable, error) { + var session tables.SessionsTable + if err := s.db.WithContext(ctx).First(&session, "token = ?", token).Error; err != nil { + return nil, err + } + return &session, nil +} + +// CreateSession creates a new session in the database. +func (s *RDBConfigStore) CreateSession(ctx context.Context, session *tables.SessionsTable) error { + return s.db.WithContext(ctx).Create(session).Error +} + +// DeleteSession deletes a session from the database. +func (s *RDBConfigStore) DeleteSession(ctx context.Context, token string) error { + return s.db.WithContext(ctx).Delete(&tables.SessionsTable{}, "token = ?", token).Error +} + +// ExecuteTransaction executes a transaction. +func (s *RDBConfigStore) ExecuteTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error { + return s.db.WithContext(ctx).Transaction(fn) +} + +// doesTableExist checks if a table exists in the database. +func (s *RDBConfigStore) doesTableExist(ctx context.Context, tableName string) bool { + return s.db.WithContext(ctx).Migrator().HasTable(tableName) +} + +// removeNullKeys removes null keys from the database. +func (s *RDBConfigStore) removeNullKeys(ctx context.Context) error { + return s.db.WithContext(ctx).Exec("DELETE FROM config_keys WHERE key_id IS NULL OR value IS NULL").Error +} + +// removeDuplicateKeysAndNullKeys removes duplicate keys based on key_id and value combination +// Keeps the record with the smallest ID (oldest record) and deletes duplicates +func (s *RDBConfigStore) removeDuplicateKeysAndNullKeys(ctx context.Context) error { + s.logger.Debug("removing duplicate keys and null keys from the database") + // Check if the config_keys table exists first + if !s.doesTableExist(ctx, "config_keys") { + return nil + } + s.logger.Debug("removing null keys from the database") + // First, remove null keys + if err := s.removeNullKeys(ctx); err != nil { + return fmt.Errorf("failed to remove null keys: %w", err) + } + s.logger.Debug("deleting duplicate keys from the database") + // Find and delete duplicate keys, keeping only the one with the smallest ID + // This query deletes all records except the one with the minimum ID for each (key_id, value) pair + result := s.db.WithContext(ctx).Exec(` + DELETE FROM config_keys + WHERE id NOT IN ( + SELECT MIN(id) + FROM config_keys + GROUP BY key_id, value + ) + `) + + if result.Error != nil { + return fmt.Errorf("failed to remove duplicate keys: %w", result.Error) + } + s.logger.Debug("migration complete") + return nil +} + +// RunMigration runs a migration. +func (s *RDBConfigStore) RunMigration(ctx context.Context, migration *migrator.Migration) error { + if migration == nil { + return fmt.Errorf("migration cannot be nil") + } + m := migrator.New(s.db, migrator.DefaultOptions, []*migrator.Migration{migration}) + return m.Migrate() +} + +// Close closes the SQLite config store. +func (s *RDBConfigStore) Close(ctx context.Context) error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} diff --git a/framework/configstore/sqlite.go b/framework/configstore/sqlite.go new file mode 100644 index 000000000..489f3041b --- /dev/null +++ b/framework/configstore/sqlite.go @@ -0,0 +1,49 @@ +package configstore + +import ( + "context" + "fmt" + "os" + + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// SQLiteConfig represents the configuration for a SQLite database. +type SQLiteConfig struct { + Path string `json:"path"` +} + +// newSqliteConfigStore creates a new SQLite config store. +func newSqliteConfigStore(ctx context.Context, config *SQLiteConfig, logger schemas.Logger) (ConfigStore, error) { + if _, err := os.Stat(config.Path); os.IsNotExist(err) { + // Create DB file + f, err := os.Create(config.Path) + if err != nil { + return nil, err + } + _ = f.Close() + } + dsn := fmt.Sprintf("%s?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_busy_timeout=60000&_wal_autocheckpoint=1000&_foreign_keys=1", config.Path) + logger.Debug("opening DB with dsn: %s", dsn) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: newGormLogger(logger), + }) + + if err != nil { + return nil, err + } + logger.Debug("db opened for configstore") + s := &RDBConfigStore{db: db, logger: logger} + logger.Debug("running migration to remove duplicate keys") + // Run migration to remove duplicate keys before AutoMigrate + if err := s.removeDuplicateKeysAndNullKeys(ctx); err != nil { + return nil, fmt.Errorf("failed to remove duplicate keys: %w", err) + } + // Run migrations + if err := triggerMigrations(ctx, db); err != nil { + return nil, err + } + return s, nil +} diff --git a/framework/configstore/store.go b/framework/configstore/store.go new file mode 100644 index 000000000..bb83de4ea --- /dev/null +++ b/framework/configstore/store.go @@ -0,0 +1,169 @@ +// Package configstore provides a persistent configuration store for Bifrost. +package configstore + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/framework/migrator" + "github.com/maximhq/bifrost/framework/vectorstore" + "gorm.io/gorm" +) + +// ConfigStore is the interface for the config store. +type ConfigStore interface { + // Health check + Ping(ctx context.Context) error + + // Client config CRUD + UpdateClientConfig(ctx context.Context, config *ClientConfig) error + GetClientConfig(ctx context.Context) (*ClientConfig, error) + + // Framework config CRUD + UpdateFrameworkConfig(ctx context.Context, config *tables.TableFrameworkConfig) error + GetFrameworkConfig(ctx context.Context) (*tables.TableFrameworkConfig, error) + + // Provider config CRUD + UpdateProvidersConfig(ctx context.Context, providers map[schemas.ModelProvider]ProviderConfig) error + AddProvider(ctx context.Context, provider schemas.ModelProvider, config ProviderConfig, envKeys map[string][]EnvKeyInfo) error + UpdateProvider(ctx context.Context, provider schemas.ModelProvider, config ProviderConfig, envKeys map[string][]EnvKeyInfo) error + DeleteProvider(ctx context.Context, provider schemas.ModelProvider) error + GetProvidersConfig(ctx context.Context) (map[schemas.ModelProvider]ProviderConfig, error) + + // MCP config CRUD + GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, error) + GetMCPClientByName(ctx context.Context, name string) (*tables.TableMCPClient, error) + CreateMCPClientConfig(ctx context.Context, clientConfig schemas.MCPClientConfig, envKeys map[string][]EnvKeyInfo) error + UpdateMCPClientConfig(ctx context.Context, id string, clientConfig schemas.MCPClientConfig, envKeys map[string][]EnvKeyInfo) error + DeleteMCPClientConfig(ctx context.Context, id string) error + + // Vector store config CRUD + UpdateVectorStoreConfig(ctx context.Context, config *vectorstore.Config) error + GetVectorStoreConfig(ctx context.Context) (*vectorstore.Config, error) + + // Logs store config CRUD + UpdateLogsStoreConfig(ctx context.Context, config *logstore.Config) error + GetLogsStoreConfig(ctx context.Context) (*logstore.Config, error) + + // ENV keys CRUD + UpdateEnvKeys(ctx context.Context, keys map[string][]EnvKeyInfo) error + GetEnvKeys(ctx context.Context) (map[string][]EnvKeyInfo, error) + + // Config CRUD + GetConfig(ctx context.Context, key string) (*tables.TableGovernanceConfig, error) + UpdateConfig(ctx context.Context, config *tables.TableGovernanceConfig, tx ...*gorm.DB) error + + // Plugins CRUD + GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) + GetPlugin(ctx context.Context, name string) (*tables.TablePlugin, error) + CreatePlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error + UpdatePlugin(ctx context.Context, plugin *tables.TablePlugin, tx ...*gorm.DB) error + DeletePlugin(ctx context.Context, name string, tx ...*gorm.DB) error + + // Governance config CRUD + GetVirtualKeys(ctx context.Context) ([]tables.TableVirtualKey, error) + GetRedactedVirtualKeys(ctx context.Context, ids []string) ([]tables.TableVirtualKey, error) // leave ids empty to get all + GetVirtualKey(ctx context.Context, id string) (*tables.TableVirtualKey, error) + GetVirtualKeyByValue(ctx context.Context, value string) (*tables.TableVirtualKey, error) + CreateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error + UpdateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error + DeleteVirtualKey(ctx context.Context, id string) error + + // Virtual key provider config CRUD + GetVirtualKeyProviderConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyProviderConfig, error) + CreateVirtualKeyProviderConfig(ctx context.Context, virtualKeyProviderConfig *tables.TableVirtualKeyProviderConfig, tx ...*gorm.DB) error + UpdateVirtualKeyProviderConfig(ctx context.Context, virtualKeyProviderConfig *tables.TableVirtualKeyProviderConfig, tx ...*gorm.DB) error + DeleteVirtualKeyProviderConfig(ctx context.Context, id uint, tx ...*gorm.DB) error + + // Virtual key MCP config CRUD + GetVirtualKeyMCPConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyMCPConfig, error) + CreateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error + UpdateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error + DeleteVirtualKeyMCPConfig(ctx context.Context, id uint, tx ...*gorm.DB) error + + // Team CRUD + GetTeams(ctx context.Context, customerID string) ([]tables.TableTeam, error) + GetTeam(ctx context.Context, id string) (*tables.TableTeam, error) + CreateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error + UpdateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error + DeleteTeam(ctx context.Context, id string) error + + // Customer CRUD + GetCustomers(ctx context.Context) ([]tables.TableCustomer, error) + GetCustomer(ctx context.Context, id string) (*tables.TableCustomer, error) + CreateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error + UpdateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error + DeleteCustomer(ctx context.Context, id string) error + + // Rate limit CRUD + GetRateLimit(ctx context.Context, id string) (*tables.TableRateLimit, error) + CreateRateLimit(ctx context.Context, rateLimit *tables.TableRateLimit, tx ...*gorm.DB) error + UpdateRateLimit(ctx context.Context, rateLimit *tables.TableRateLimit, tx ...*gorm.DB) error + UpdateRateLimits(ctx context.Context, rateLimits []*tables.TableRateLimit, tx ...*gorm.DB) error + + // Budget CRUD + GetBudgets(ctx context.Context) ([]tables.TableBudget, error) + GetBudget(ctx context.Context, id string, tx ...*gorm.DB) (*tables.TableBudget, error) + CreateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error + UpdateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error + UpdateBudgets(ctx context.Context, budgets []*tables.TableBudget, tx ...*gorm.DB) error + + // Governance config CRUD + GetGovernanceConfig(ctx context.Context) (*GovernanceConfig, error) + + // Auth config CRUD + GetAuthConfig(ctx context.Context) (*AuthConfig, error) + UpdateAuthConfig(ctx context.Context, config *AuthConfig) error + + // Session CRUD + GetSession(ctx context.Context, token string) (*tables.SessionsTable, error) + CreateSession(ctx context.Context, session *tables.SessionsTable) error + DeleteSession(ctx context.Context, token string) error + + // Model pricing CRUD + GetModelPrices(ctx context.Context) ([]tables.TableModelPricing, error) + CreateModelPrices(ctx context.Context, pricing *tables.TableModelPricing, tx ...*gorm.DB) error + DeleteModelPrices(ctx context.Context, tx ...*gorm.DB) error + + // Key management + GetKeysByIDs(ctx context.Context, ids []string) ([]tables.TableKey, error) + GetAllRedactedKeys(ctx context.Context, ids []string) ([]schemas.Key, error) // leave ids empty to get all + + // Generic transaction manager + ExecuteTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error + + // DB returns the underlying database connection. + DB() *gorm.DB + + // Migration manager + RunMigration(ctx context.Context, migration *migrator.Migration) error + + // Cleanup + Close(ctx context.Context) error +} + +// NewConfigStore creates a new config store based on the configuration +func NewConfigStore(ctx context.Context, config *Config, logger schemas.Logger) (ConfigStore, error) { + if config == nil { + return nil, fmt.Errorf("config cannot be nil") + } + if !config.Enabled { + return nil, nil + } + switch config.Type { + case ConfigStoreTypeSQLite: + if sqliteConfig, ok := config.Config.(*SQLiteConfig); ok { + return newSqliteConfigStore(ctx, sqliteConfig, logger) + } + return nil, fmt.Errorf("invalid sqlite config: %T", config.Config) + case ConfigStoreTypePostgres: + if postgresConfig, ok := config.Config.(*PostgresConfig); ok { + return newPostgresConfigStore(ctx, postgresConfig, logger) + } + return nil, fmt.Errorf("invalid postgres config: %T", config.Config) + } + return nil, fmt.Errorf("unsupported config store type: %s", config.Type) +} diff --git a/framework/configstore/tables/budget.go b/framework/configstore/tables/budget.go new file mode 100644 index 000000000..71fb0524d --- /dev/null +++ b/framework/configstore/tables/budget.go @@ -0,0 +1,39 @@ +package tables + +import ( + "fmt" + "time" + + "gorm.io/gorm" +) + +// TableBudget defines spending limits with configurable reset periods +type TableBudget struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + MaxLimit float64 `gorm:"not null" json:"max_limit"` // Maximum budget in dollars + ResetDuration string `gorm:"type:varchar(50);not null" json:"reset_duration"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M", "1Y" + LastReset time.Time `gorm:"index" json:"last_reset"` // Last time budget was reset + CurrentUsage float64 `gorm:"default:0" json:"current_usage"` // Current usage in dollars + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableBudget) TableName() string { return "governance_budgets" } + +// BeforeSave hook for Budget to validate reset duration format and max limit +func (b *TableBudget) BeforeSave(tx *gorm.DB) error { + // Validate that ResetDuration is in correct format (e.g., "30s", "5m", "1h", "1d", "1w", "1M", "1Y") + if d, err := ParseDuration(b.ResetDuration); err != nil { + return fmt.Errorf("invalid reset duration format: %s", b.ResetDuration) + }else if d <= 0 { + return fmt.Errorf("reset duration must be > 0: %s", b.ResetDuration) + } + // Validate that MaxLimit is not negative (budgets should be positive) + if b.MaxLimit < 0 { + return fmt.Errorf("budget max_limit cannot be negative: %.2f", b.MaxLimit) + } + + return nil +} diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go new file mode 100644 index 000000000..ad360e711 --- /dev/null +++ b/framework/configstore/tables/clientconfig.go @@ -0,0 +1,76 @@ +package tables + +import ( + "encoding/json" + "time" + + "gorm.io/gorm" +) + +// TableClientConfig represents global client configuration in the database +type TableClientConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + DropExcessRequests bool `gorm:"default:false" json:"drop_excess_requests"` + PrometheusLabelsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + AllowedOriginsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + InitialPoolSize int `gorm:"default:300" json:"initial_pool_size"` + EnableLogging bool `gorm:"" json:"enable_logging"` + DisableContentLogging bool `gorm:"default:false" json:"disable_content_logging"` // DisableContentLogging controls whether sensitive content (inputs, outputs, embeddings, etc.) is logged + EnableGovernance bool `gorm:"" json:"enable_governance"` + EnforceGovernanceHeader bool `gorm:"" json:"enforce_governance_header"` + AllowDirectKeys bool `gorm:"" json:"allow_direct_keys"` + MaxRequestBodySizeMB int `gorm:"default:100" json:"max_request_body_size_mb"` + // LiteLLM fallback flag + EnableLiteLLMFallbacks bool `gorm:"column:enable_litellm_fallbacks;default:false" json:"enable_litellm_fallbacks"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + + // Virtual fields for runtime use (not stored in DB) + PrometheusLabels []string `gorm:"-" json:"prometheus_labels"` + AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"` +} + +// TableName sets the table name for each model +func (TableClientConfig) TableName() string { return "config_client" } + +func (cc *TableClientConfig) BeforeSave(tx *gorm.DB) error { + if cc.PrometheusLabels != nil { + data, err := json.Marshal(cc.PrometheusLabels) + if err != nil { + return err + } + cc.PrometheusLabelsJSON = string(data) + } else { + cc.PrometheusLabelsJSON = "[]" + } + + if cc.AllowedOrigins != nil { + data, err := json.Marshal(cc.AllowedOrigins) + if err != nil { + return err + } + cc.AllowedOriginsJSON = string(data) + } else { + cc.AllowedOriginsJSON = "[]" + } + + return nil +} + +// AfterFind hooks for deserialization +func (cc *TableClientConfig) AfterFind(tx *gorm.DB) error { + if cc.PrometheusLabelsJSON != "" { + if err := json.Unmarshal([]byte(cc.PrometheusLabelsJSON), &cc.PrometheusLabels); err != nil { + return err + } + } + + if cc.AllowedOriginsJSON != "" { + if err := json.Unmarshal([]byte(cc.AllowedOriginsJSON), &cc.AllowedOrigins); err != nil { + return err + } + } + + return nil +} diff --git a/framework/configstore/tables/config.go b/framework/configstore/tables/config.go new file mode 100644 index 000000000..252e22929 --- /dev/null +++ b/framework/configstore/tables/config.go @@ -0,0 +1,17 @@ +package tables + +const ( + ConfigAdminUsernameKey = "admin_username" + ConfigAdminPasswordKey = "admin_password" + ConfigIsAuthEnabledKey = "is_auth_enabled" + ConfigDisableAuthOnInferenceKey = "disable_auth_on_inference" +) + +// TableGovernanceConfig represents generic configuration key-value pairs +type TableGovernanceConfig struct { + Key string `gorm:"primaryKey;type:varchar(255)" json:"key"` + Value string `gorm:"type:text" json:"value"` +} + +// TableName sets the table name for each model +func (TableGovernanceConfig) TableName() string { return "governance_config" } diff --git a/framework/configstore/tables/confighash.go b/framework/configstore/tables/confighash.go new file mode 100644 index 000000000..c9cd0eb44 --- /dev/null +++ b/framework/configstore/tables/confighash.go @@ -0,0 +1,15 @@ +// Package tables contains the database tables for the configstore. +package tables + +import "time" + +// TableConfigHash represents the configuration hash in the database +type TableConfigHash struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Hash string `gorm:"type:varchar(255);uniqueIndex;not null" json:"hash"` + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableConfigHash) TableName() string { return "config_hashes" } diff --git a/framework/configstore/tables/customer.go b/framework/configstore/tables/customer.go new file mode 100644 index 000000000..27aa757a0 --- /dev/null +++ b/framework/configstore/tables/customer.go @@ -0,0 +1,21 @@ +package tables + +import "time" + +// TableCustomer represents a customer entity with budget +type TableCustomer struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` + + // Relationships + Budget *TableBudget `gorm:"foreignKey:BudgetID" json:"budget,omitempty"` + Teams []TableTeam `gorm:"foreignKey:CustomerID" json:"teams"` + VirtualKeys []TableVirtualKey `gorm:"foreignKey:CustomerID" json:"virtual_keys"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableCustomer) TableName() string { return "governance_customers" } diff --git a/framework/configstore/tables/env.go b/framework/configstore/tables/env.go new file mode 100644 index 000000000..994033861 --- /dev/null +++ b/framework/configstore/tables/env.go @@ -0,0 +1,17 @@ +package tables + +import "time" + +// TableEnvKey represents environment variable tracking in the database +type TableEnvKey struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + EnvVar string `gorm:"type:varchar(255);index;not null" json:"env_var"` + Provider string `gorm:"type:varchar(50);index" json:"provider"` // Empty for MCP/client configs + KeyType string `gorm:"type:varchar(50);not null" json:"key_type"` // "api_key", "azure_config", "vertex_config", "bedrock_config", "connection_string" + ConfigPath string `gorm:"type:varchar(500);not null" json:"config_path"` // Descriptive path of where this env var is used + KeyID string `gorm:"type:varchar(255);index" json:"key_id"` // Key UUID (empty for non-key configs) + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` +} + +// TableName sets the table name for each model +func (TableEnvKey) TableName() string { return "config_env_keys" } diff --git a/framework/configstore/tables/framework.go b/framework/configstore/tables/framework.go new file mode 100644 index 000000000..883215f77 --- /dev/null +++ b/framework/configstore/tables/framework.go @@ -0,0 +1,12 @@ +package tables + +// TableFrameworkConfig represents the framework configurations +// We will keep on adding different columns here as we add new features to the framework +type TableFrameworkConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + PricingURL *string `gorm:"type:text" json:"pricing_url"` + PricingSyncInterval *int64 `gorm:"" json:"pricing_sync_interval"` +} + +// TableName sets the table name for each model +func (TableFrameworkConfig) TableName() string { return "framework_configs" } diff --git a/framework/configstore/tables/key.go b/framework/configstore/tables/key.go new file mode 100644 index 000000000..ef524c51f --- /dev/null +++ b/framework/configstore/tables/key.go @@ -0,0 +1,222 @@ +package tables + +import ( + "encoding/json" + "time" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/gorm" +) + +// TableKey represents an API key configuration in the database +type TableKey struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(255);uniqueIndex:idx_key_name;not null" json:"name"` + ProviderID uint `gorm:"index;not null" json:"provider_id"` + Provider string `gorm:"index;type:varchar(50)" json:"provider"` // ModelProvider as string + KeyID string `gorm:"type:varchar(255);uniqueIndex:idx_key_id;not null" json:"key_id"` // UUID from schemas.Key + Value string `gorm:"type:text;not null" json:"value"` + ModelsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + Weight float64 `gorm:"default:1.0" json:"weight"` + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + + // Azure config fields (embedded instead of separate table for simplicity) + AzureEndpoint *string `gorm:"type:text" json:"azure_endpoint,omitempty"` + AzureAPIVersion *string `gorm:"type:varchar(50)" json:"azure_api_version,omitempty"` + AzureDeploymentsJSON *string `gorm:"type:text" json:"-"` // JSON serialized map[string]string + + // Vertex config fields (embedded) + VertexProjectID *string `gorm:"type:varchar(255)" json:"vertex_project_id,omitempty"` + VertexRegion *string `gorm:"type:varchar(100)" json:"vertex_region,omitempty"` + VertexAuthCredentials *string `gorm:"type:text" json:"vertex_auth_credentials,omitempty"` + + // Bedrock config fields (embedded) + BedrockAccessKey *string `gorm:"type:varchar(255)" json:"bedrock_access_key,omitempty"` + BedrockSecretKey *string `gorm:"type:text" json:"bedrock_secret_key,omitempty"` + BedrockSessionToken *string `gorm:"type:text" json:"bedrock_session_token,omitempty"` + BedrockRegion *string `gorm:"type:varchar(100)" json:"bedrock_region,omitempty"` + BedrockARN *string `gorm:"type:text" json:"bedrock_arn,omitempty"` + BedrockDeploymentsJSON *string `gorm:"type:text" json:"-"` // JSON serialized map[string]string + + // Virtual fields for runtime use (not stored in DB) + Models []string `gorm:"-" json:"models"` + AzureKeyConfig *schemas.AzureKeyConfig `gorm:"-" json:"azure_key_config,omitempty"` + VertexKeyConfig *schemas.VertexKeyConfig `gorm:"-" json:"vertex_key_config,omitempty"` + BedrockKeyConfig *schemas.BedrockKeyConfig `gorm:"-" json:"bedrock_key_config,omitempty"` +} + +// TableName sets the table name for each model +func (TableKey) TableName() string { return "config_keys" } + +func (k *TableKey) BeforeSave(tx *gorm.DB) error { + + if k.Models != nil { + data, err := json.Marshal(k.Models) + if err != nil { + return err + } + k.ModelsJSON = string(data) + } else { + k.ModelsJSON = "[]" + } + + if k.AzureKeyConfig != nil { + if k.AzureKeyConfig.Endpoint != "" { + k.AzureEndpoint = &k.AzureKeyConfig.Endpoint + } else { + k.AzureEndpoint = nil + } + k.AzureAPIVersion = k.AzureKeyConfig.APIVersion + if k.AzureKeyConfig.Deployments != nil { + data, err := json.Marshal(k.AzureKeyConfig.Deployments) + if err != nil { + return err + } + s := string(data) + k.AzureDeploymentsJSON = &s + } else { + k.AzureDeploymentsJSON = nil + } + } else { + k.AzureEndpoint = nil + k.AzureAPIVersion = nil + k.AzureDeploymentsJSON = nil + } + + if k.VertexKeyConfig != nil { + if k.VertexKeyConfig.ProjectID != "" { + k.VertexProjectID = &k.VertexKeyConfig.ProjectID + } else { + k.VertexProjectID = nil + } + if k.VertexKeyConfig.Region != "" { + k.VertexRegion = &k.VertexKeyConfig.Region + } else { + k.VertexRegion = nil + } + if k.VertexKeyConfig.AuthCredentials != "" { + k.VertexAuthCredentials = &k.VertexKeyConfig.AuthCredentials + } else { + k.VertexAuthCredentials = nil + } + } else { + k.VertexProjectID = nil + k.VertexRegion = nil + k.VertexAuthCredentials = nil + } + + if k.BedrockKeyConfig != nil { + if k.BedrockKeyConfig.AccessKey != "" { + k.BedrockAccessKey = &k.BedrockKeyConfig.AccessKey + } else { + k.BedrockAccessKey = nil + } + if k.BedrockKeyConfig.SecretKey != "" { + k.BedrockSecretKey = &k.BedrockKeyConfig.SecretKey + } else { + k.BedrockSecretKey = nil + } + k.BedrockSessionToken = k.BedrockKeyConfig.SessionToken + k.BedrockRegion = k.BedrockKeyConfig.Region + k.BedrockARN = k.BedrockKeyConfig.ARN + if k.BedrockKeyConfig.Deployments != nil { + data, err := sonic.Marshal(k.BedrockKeyConfig.Deployments) + if err != nil { + return err + } + s := string(data) + k.BedrockDeploymentsJSON = &s + } else { + k.BedrockDeploymentsJSON = nil + } + } else { + k.BedrockAccessKey = nil + k.BedrockSecretKey = nil + k.BedrockSessionToken = nil + k.BedrockRegion = nil + k.BedrockARN = nil + k.BedrockDeploymentsJSON = nil + } + return nil +} + +func (k *TableKey) AfterFind(tx *gorm.DB) error { + if k.ModelsJSON != "" { + if err := json.Unmarshal([]byte(k.ModelsJSON), &k.Models); err != nil { + return err + } + } + + // Reconstruct Azure config if fields are present + if k.AzureEndpoint != nil { + azureConfig := &schemas.AzureKeyConfig{ + Endpoint: "", + APIVersion: k.AzureAPIVersion, + } + + if k.AzureEndpoint != nil { + azureConfig.Endpoint = *k.AzureEndpoint + } + + if k.AzureDeploymentsJSON != nil { + var deployments map[string]string + if err := json.Unmarshal([]byte(*k.AzureDeploymentsJSON), &deployments); err != nil { + return err + } + azureConfig.Deployments = deployments + } else { + azureConfig.Deployments = nil + } + + k.AzureKeyConfig = azureConfig + } + + // Reconstruct Vertex config if fields are present + if k.VertexProjectID != nil || k.VertexRegion != nil || k.VertexAuthCredentials != nil { + config := &schemas.VertexKeyConfig{} + + if k.VertexProjectID != nil { + config.ProjectID = *k.VertexProjectID + } + + if k.VertexRegion != nil { + config.Region = *k.VertexRegion + } + if k.VertexAuthCredentials != nil { + config.AuthCredentials = *k.VertexAuthCredentials + } + + k.VertexKeyConfig = config + } + + // Reconstruct Bedrock config if fields are present + if k.BedrockAccessKey != nil || k.BedrockSecretKey != nil || k.BedrockSessionToken != nil || k.BedrockRegion != nil || k.BedrockARN != nil || (k.BedrockDeploymentsJSON != nil && *k.BedrockDeploymentsJSON != "") { + bedrockConfig := &schemas.BedrockKeyConfig{} + + if k.BedrockAccessKey != nil { + bedrockConfig.AccessKey = *k.BedrockAccessKey + } + + bedrockConfig.SessionToken = k.BedrockSessionToken + bedrockConfig.Region = k.BedrockRegion + bedrockConfig.ARN = k.BedrockARN + + if k.BedrockSecretKey != nil { + bedrockConfig.SecretKey = *k.BedrockSecretKey + } + + if k.BedrockDeploymentsJSON != nil { + var deployments map[string]string + if err := json.Unmarshal([]byte(*k.BedrockDeploymentsJSON), &deployments); err != nil { + return err + } + bedrockConfig.Deployments = deployments + } + + k.BedrockKeyConfig = bedrockConfig + } + + return nil +} diff --git a/framework/configstore/tables/logstore.go b/framework/configstore/tables/logstore.go new file mode 100644 index 000000000..43ceac2c9 --- /dev/null +++ b/framework/configstore/tables/logstore.go @@ -0,0 +1,16 @@ +package tables + +import "time" + +// TableLogStoreConfig represents the configuration for the log store in the database +type TableLogStoreConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Enabled bool `json:"enabled"` + Type string `gorm:"type:varchar(50);not null" json:"type"` // "sqlite" + Config *string `gorm:"type:text" json:"config"` // JSON serialized logstore.Config + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableLogStoreConfig) TableName() string { return "config_log_store" } diff --git a/framework/configstore/tables/mcp.go b/framework/configstore/tables/mcp.go new file mode 100644 index 000000000..17a6f4f9d --- /dev/null +++ b/framework/configstore/tables/mcp.go @@ -0,0 +1,91 @@ +package tables + +import ( + "encoding/json" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/gorm" +) + +// TableMCPClient represents an MCP client configuration in the database +type TableMCPClient struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` // ID is used as the internal primary key and is also accessed by public methods, so it must be present. + ClientID string `gorm:"type:varchar(255);uniqueIndex;not null" json:"client_id"` + Name string `gorm:"type:varchar(255);uniqueIndex;not null" json:"name"` + ConnectionType string `gorm:"type:varchar(20);not null" json:"connection_type"` // schemas.MCPConnectionType + ConnectionString *string `gorm:"type:text" json:"connection_string,omitempty"` + StdioConfigJSON *string `gorm:"type:text" json:"-"` // JSON serialized schemas.MCPStdioConfig + ToolsToExecuteJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + HeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]string + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + + // Virtual fields for runtime use (not stored in DB) + StdioConfig *schemas.MCPStdioConfig `gorm:"-" json:"stdio_config,omitempty"` + ToolsToExecute []string `gorm:"-" json:"tools_to_execute"` + Headers map[string]string `gorm:"-" json:"headers"` +} + +// TableName sets the table name for each model +func (TableMCPClient) TableName() string { return "config_mcp_clients" } + +func (c *TableMCPClient) BeforeSave(tx *gorm.DB) error { + if c.StdioConfig != nil { + data, err := json.Marshal(c.StdioConfig) + if err != nil { + return err + } + config := string(data) + c.StdioConfigJSON = &config + } else { + c.StdioConfigJSON = nil + } + + if c.ToolsToExecute != nil { + data, err := json.Marshal(c.ToolsToExecute) + if err != nil { + return err + } + c.ToolsToExecuteJSON = string(data) + } else { + c.ToolsToExecuteJSON = "[]" + } + + if c.Headers != nil { + data, err := json.Marshal(c.Headers) + if err != nil { + return err + } + c.HeadersJSON = string(data) + } else { + c.HeadersJSON = "{}" + } + + return nil +} + +// AfterFind hooks for deserialization +func (c *TableMCPClient) AfterFind(tx *gorm.DB) error { + if c.StdioConfigJSON != nil { + var config schemas.MCPStdioConfig + if err := json.Unmarshal([]byte(*c.StdioConfigJSON), &config); err != nil { + return err + } + c.StdioConfig = &config + } + + if c.ToolsToExecuteJSON != "" { + if err := json.Unmarshal([]byte(c.ToolsToExecuteJSON), &c.ToolsToExecute); err != nil { + return err + } + } + + if c.HeadersJSON != "" { + if err := json.Unmarshal([]byte(c.HeadersJSON), &c.Headers); err != nil { + return err + } + } + + return nil +} diff --git a/framework/configstore/tables/model.go b/framework/configstore/tables/model.go new file mode 100644 index 000000000..a60284dd9 --- /dev/null +++ b/framework/configstore/tables/model.go @@ -0,0 +1,15 @@ +package tables + +import "time" + +// TableModel represents a model configuration in the database +type TableModel struct { + ID string `gorm:"primaryKey" json:"id"` + ProviderID uint `gorm:"index;not null;uniqueIndex:idx_provider_name" json:"provider_id"` + Name string `gorm:"uniqueIndex:idx_provider_name" json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableModel) TableName() string { return "config_models" } diff --git a/framework/configstore/tables/modelpricing.go b/framework/configstore/tables/modelpricing.go new file mode 100644 index 000000000..2b9f00f7b --- /dev/null +++ b/framework/configstore/tables/modelpricing.go @@ -0,0 +1,37 @@ +package tables + +// TableModelPricing represents pricing information for AI models +type TableModelPricing struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Model string `gorm:"type:varchar(255);not null;uniqueIndex:idx_model_provider_mode" json:"model"` + Provider string `gorm:"type:varchar(50);not null;uniqueIndex:idx_model_provider_mode" json:"provider"` + InputCostPerToken float64 `gorm:"not null" json:"input_cost_per_token"` + OutputCostPerToken float64 `gorm:"not null" json:"output_cost_per_token"` + Mode string `gorm:"type:varchar(50);not null;uniqueIndex:idx_model_provider_mode" json:"mode"` + + // Additional pricing for media + InputCostPerImage *float64 `gorm:"default:null" json:"input_cost_per_image,omitempty"` + InputCostPerVideoPerSecond *float64 `gorm:"default:null" json:"input_cost_per_video_per_second,omitempty"` + InputCostPerAudioPerSecond *float64 `gorm:"default:null" json:"input_cost_per_audio_per_second,omitempty"` + + // Character-based pricing + InputCostPerCharacter *float64 `gorm:"default:null" json:"input_cost_per_character,omitempty"` + OutputCostPerCharacter *float64 `gorm:"default:null" json:"output_cost_per_character,omitempty"` + + // Pricing above 128k tokens + InputCostPerTokenAbove128kTokens *float64 `gorm:"default:null" json:"input_cost_per_token_above_128k_tokens,omitempty"` + InputCostPerCharacterAbove128kTokens *float64 `gorm:"default:null" json:"input_cost_per_character_above_128k_tokens,omitempty"` + InputCostPerImageAbove128kTokens *float64 `gorm:"default:null" json:"input_cost_per_image_above_128k_tokens,omitempty"` + InputCostPerVideoPerSecondAbove128kTokens *float64 `gorm:"default:null" json:"input_cost_per_video_per_second_above_128k_tokens,omitempty"` + InputCostPerAudioPerSecondAbove128kTokens *float64 `gorm:"default:null" json:"input_cost_per_audio_per_second_above_128k_tokens,omitempty"` + OutputCostPerTokenAbove128kTokens *float64 `gorm:"default:null" json:"output_cost_per_token_above_128k_tokens,omitempty"` + OutputCostPerCharacterAbove128kTokens *float64 `gorm:"default:null" json:"output_cost_per_character_above_128k_tokens,omitempty"` + + // Cache and batch pricing + CacheReadInputTokenCost *float64 `gorm:"default:null" json:"cache_read_input_token_cost,omitempty"` + InputCostPerTokenBatches *float64 `gorm:"default:null" json:"input_cost_per_token_batches,omitempty"` + OutputCostPerTokenBatches *float64 `gorm:"default:null" json:"output_cost_per_token_batches,omitempty"` +} + +// TableName sets the table name for each model +func (TableModelPricing) TableName() string { return "governance_model_pricing" } diff --git a/framework/configstore/tables/plugin.go b/framework/configstore/tables/plugin.go new file mode 100644 index 000000000..ab45c7fa4 --- /dev/null +++ b/framework/configstore/tables/plugin.go @@ -0,0 +1,55 @@ +package tables + +import ( + "encoding/json" + "time" + + "gorm.io/gorm" +) + +// TablePlugin represents a plugin configuration in the database + +type TablePlugin struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(255);uniqueIndex;not null" json:"name"` + Enabled bool `json:"enabled"` + Path *string `json:"path,omitempty"` + ConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized plugin.Config + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + IsCustom bool `gorm:"not null;default:false" json:"isCustom"` + + // Virtual fields for runtime use (not stored in DB) + Config any `gorm:"-" json:"config,omitempty"` +} + +// TableName sets the table name for each model +func (TablePlugin) TableName() string { return "config_plugins" } + +// BeforeSave hooks for serialization +func (p *TablePlugin) BeforeSave(tx *gorm.DB) error { + if p.Config != nil { + data, err := json.Marshal(p.Config) + if err != nil { + return err + } + p.ConfigJSON = string(data) + } else { + p.ConfigJSON = "{}" + } + + return nil +} + +// AfterFind hooks for deserialization +func (p *TablePlugin) AfterFind(tx *gorm.DB) error { + if p.ConfigJSON != "" { + if err := json.Unmarshal([]byte(p.ConfigJSON), &p.Config); err != nil { + return err + } + } else { + p.Config = nil + } + + return nil +} diff --git a/framework/configstore/tables/provider.go b/framework/configstore/tables/provider.go new file mode 100644 index 000000000..f4fb56088 --- /dev/null +++ b/framework/configstore/tables/provider.go @@ -0,0 +1,118 @@ +package tables + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/gorm" +) + +// TableProvider represents a provider configuration in the database +type TableProvider struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"` // ModelProvider as string + NetworkConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized schemas.NetworkConfig + ConcurrencyBufferJSON string `gorm:"type:text" json:"-"` // JSON serialized schemas.ConcurrencyAndBufferSize + ProxyConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized schemas.ProxyConfig + CustomProviderConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized schemas.CustomProviderConfig + SendBackRawResponse bool `json:"send_back_raw_response"` + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + + // Relationships + Keys []TableKey `gorm:"foreignKey:ProviderID;constraint:OnDelete:CASCADE" json:"keys"` + + // Virtual fields for runtime use (not stored in DB) + NetworkConfig *schemas.NetworkConfig `gorm:"-" json:"network_config,omitempty"` + ConcurrencyAndBufferSize *schemas.ConcurrencyAndBufferSize `gorm:"-" json:"concurrency_and_buffer_size,omitempty"` + ProxyConfig *schemas.ProxyConfig `gorm:"-" json:"proxy_config,omitempty"` + + // Custom provider fields + CustomProviderConfig *schemas.CustomProviderConfig `gorm:"-" json:"custom_provider_config,omitempty"` + + // Foreign keys + Models []TableModel `gorm:"foreignKey:ProviderID;constraint:OnDelete:CASCADE" json:"models"` +} + +// TableName represents a provider configuration in the database +func (TableProvider) TableName() string { return "config_providers" } + +// BeforeSave hooks for serialization +func (p *TableProvider) BeforeSave(tx *gorm.DB) error { + if p.NetworkConfig != nil { + data, err := json.Marshal(p.NetworkConfig) + if err != nil { + return err + } + p.NetworkConfigJSON = string(data) + } + + if p.ConcurrencyAndBufferSize != nil { + data, err := json.Marshal(p.ConcurrencyAndBufferSize) + if err != nil { + return err + } + p.ConcurrencyBufferJSON = string(data) + } + + if p.ProxyConfig != nil { + data, err := json.Marshal(p.ProxyConfig) + if err != nil { + return err + } + p.ProxyConfigJSON = string(data) + } + + if p.CustomProviderConfig != nil && p.CustomProviderConfig.BaseProviderType == "" { + return fmt.Errorf("base_provider_type is required when custom_provider_config is set") + } + + if p.CustomProviderConfig != nil { + data, err := json.Marshal(p.CustomProviderConfig) + if err != nil { + return err + } + p.CustomProviderConfigJSON = string(data) + } + + return nil +} + +// AfterFind hooks for deserialization +func (p *TableProvider) AfterFind(tx *gorm.DB) error { + if p.NetworkConfigJSON != "" { + var config schemas.NetworkConfig + if err := json.Unmarshal([]byte(p.NetworkConfigJSON), &config); err != nil { + return err + } + p.NetworkConfig = &config + } + + if p.ConcurrencyBufferJSON != "" { + var config schemas.ConcurrencyAndBufferSize + if err := json.Unmarshal([]byte(p.ConcurrencyBufferJSON), &config); err != nil { + return err + } + p.ConcurrencyAndBufferSize = &config + } + + if p.ProxyConfigJSON != "" { + var proxyConfig schemas.ProxyConfig + if err := json.Unmarshal([]byte(p.ProxyConfigJSON), &proxyConfig); err != nil { + return err + } + p.ProxyConfig = &proxyConfig + } + + if p.CustomProviderConfigJSON != "" { + var customConfig schemas.CustomProviderConfig + if err := json.Unmarshal([]byte(p.CustomProviderConfigJSON), &customConfig); err != nil { + return err + } + p.CustomProviderConfig = &customConfig + } + + return nil +} diff --git a/framework/configstore/tables/ratelimit.go b/framework/configstore/tables/ratelimit.go new file mode 100644 index 000000000..6324db6f8 --- /dev/null +++ b/framework/configstore/tables/ratelimit.go @@ -0,0 +1,73 @@ +package tables + +import ( + "fmt" + "time" + + "gorm.io/gorm" +) + +// TableRateLimit defines rate limiting rules for virtual keys using flexible max+reset approach +type TableRateLimit struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + + // Token limits with flexible duration + TokenMaxLimit *int64 `gorm:"default:null" json:"token_max_limit,omitempty"` // Maximum tokens allowed + TokenResetDuration *string `gorm:"type:varchar(50)" json:"token_reset_duration,omitempty"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M", "1Y" + TokenCurrentUsage int64 `gorm:"default:0" json:"token_current_usage"` // Current token usage + TokenLastReset time.Time `gorm:"index" json:"token_last_reset"` // Last time token counter was reset + + // Request limits with flexible duration + RequestMaxLimit *int64 `gorm:"default:null" json:"request_max_limit,omitempty"` // Maximum requests allowed + RequestResetDuration *string `gorm:"type:varchar(50)" json:"request_reset_duration,omitempty"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M", "1Y" + RequestCurrentUsage int64 `gorm:"default:0" json:"request_current_usage"` // Current request usage + RequestLastReset time.Time `gorm:"index" json:"request_last_reset"` // Last time request counter was reset + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableRateLimit) TableName() string { return "governance_rate_limits" } + +// BeforeSave hook for RateLimit to validate reset duration formats +func (rl *TableRateLimit) BeforeSave(tx *gorm.DB) error { + // Validate token reset duration if provided + if rl.TokenResetDuration != nil { + if d, err := ParseDuration(*rl.TokenResetDuration); err != nil { + return fmt.Errorf("invalid token reset duration format: %s", *rl.TokenResetDuration) + } else if d <= 0 { + return fmt.Errorf("token reset duration cannot be zero or negative: %s", *rl.TokenResetDuration) + } + } + + // Validate request reset duration if provided + if rl.RequestResetDuration != nil { + if d, err := ParseDuration(*rl.RequestResetDuration); err != nil { + return fmt.Errorf("invalid request reset duration format: %s", *rl.RequestResetDuration) + } else if d <= 0 { + return fmt.Errorf("request reset duration cannot be zero or negative: %s", *rl.RequestResetDuration) + } + } + + // Validate that if a max limit is set, a reset duration is also provided + if rl.TokenMaxLimit != nil && rl.TokenResetDuration == nil { + return fmt.Errorf("token_reset_duration is required when token_max_limit is set") + } + + if rl.RequestMaxLimit != nil && rl.RequestResetDuration == nil { + return fmt.Errorf("request_reset_duration is required when request_max_limit is set") + } + + // Making sure token limit is greater than zero + if rl.TokenMaxLimit != nil && *rl.TokenMaxLimit <= 0 { + return fmt.Errorf("token_max_limit cannot be zero or negative: %d", *rl.TokenMaxLimit) + } + + // Making sure request limit is greater than zero + if rl.RequestMaxLimit != nil && *rl.RequestMaxLimit <= 0 { + return fmt.Errorf("request_max_limit cannot be zero or negative: %d", *rl.RequestMaxLimit) + } + + return nil +} diff --git a/framework/configstore/tables/sessions.go b/framework/configstore/tables/sessions.go new file mode 100644 index 000000000..0fbb4bb93 --- /dev/null +++ b/framework/configstore/tables/sessions.go @@ -0,0 +1,15 @@ +package tables + +import "time" + +// SessionsTable represents a session in the database +type SessionsTable struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Token string `gorm:"type:varchar(255);not null;uniqueIndex" json:"token"` + ExpiresAt time.Time `gorm:"index;not null" json:"expires_at,omitempty"` + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (SessionsTable) TableName() string { return "sessions" } diff --git a/framework/configstore/tables/team.go b/framework/configstore/tables/team.go new file mode 100644 index 000000000..4b6253e90 --- /dev/null +++ b/framework/configstore/tables/team.go @@ -0,0 +1,89 @@ +package tables + +import ( + "encoding/json" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "gorm.io/gorm" +) + +// TableTeam represents a team entity with budget and customer association +type TableTeam struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"` // A team can belong to a customer + BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` + + // Relationships + Customer *TableCustomer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"` + Budget *TableBudget `gorm:"foreignKey:BudgetID" json:"budget,omitempty"` + VirtualKeys []TableVirtualKey `gorm:"foreignKey:TeamID" json:"virtual_keys"` + + Profile *string `gorm:"type:text" json:"-"` + ParsedProfile map[string]interface{} `gorm:"-" json:"profile"` + + Config *string `gorm:"type:text" json:"-"` + ParsedConfig map[string]interface{} `gorm:"-" json:"config"` + + Claims *string `gorm:"type:text" json:"-"` + ParsedClaims map[string]interface{} `gorm:"-" json:"claims"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableTeam) TableName() string { return "governance_teams" } + +// BeforeSave hook for TableTeam to serialize JSON fields +func (t *TableTeam) BeforeSave(tx *gorm.DB) error { + if t.ParsedProfile != nil { + data, err := json.Marshal(t.ParsedProfile) + if err != nil { + return err + } + t.Profile = bifrost.Ptr(string(data)) + } else { + t.Profile = nil + } + if t.ParsedConfig != nil { + data, err := json.Marshal(t.ParsedConfig) + if err != nil { + return err + } + t.Config = bifrost.Ptr(string(data)) + } else { + t.Config = nil + } + if t.ParsedClaims != nil { + data, err := json.Marshal(t.ParsedClaims) + if err != nil { + return err + } + t.Claims = bifrost.Ptr(string(data)) + } else { + t.Claims = nil + } + return nil +} + +// AfterFind hook for TableTeam to deserialize JSON fields +func (t *TableTeam) AfterFind(tx *gorm.DB) error { + if t.Profile != nil { + if err := json.Unmarshal([]byte(*t.Profile), &t.ParsedProfile); err != nil { + return err + } + } + if t.Config != nil { + if err := json.Unmarshal([]byte(*t.Config), &t.ParsedConfig); err != nil { + return err + } + } + if t.Claims != nil { + if err := json.Unmarshal([]byte(*t.Claims), &t.ParsedClaims); err != nil { + return err + } + } + return nil +} diff --git a/framework/configstore/tables/utils.go b/framework/configstore/tables/utils.go new file mode 100644 index 000000000..f86ecfd80 --- /dev/null +++ b/framework/configstore/tables/utils.go @@ -0,0 +1,43 @@ +package tables + +import ( + "fmt" + "time" +) + +// ParseDuration function to parse duration strings +func ParseDuration(duration string) (time.Duration, error) { + if duration == "" { + return 0, fmt.Errorf("duration is empty") + } + + // Handle special cases for days, weeks, months, years + switch { + case duration[len(duration)-1:] == "d": + days := duration[:len(duration)-1] + if d, err := time.ParseDuration(days + "h"); err == nil { + return d * 24, nil + } + return 0, fmt.Errorf("invalid day duration: %s", duration) + case duration[len(duration)-1:] == "w": + weeks := duration[:len(duration)-1] + if w, err := time.ParseDuration(weeks + "h"); err == nil { + return w * 24 * 7, nil + } + return 0, fmt.Errorf("invalid week duration: %s", duration) + case duration[len(duration)-1:] == "M": + months := duration[:len(duration)-1] + if m, err := time.ParseDuration(months + "h"); err == nil { + return m * 24 * 30, nil // Approximate month as 30 days + } + return 0, fmt.Errorf("invalid month duration: %s", duration) + case duration[len(duration)-1:] == "Y": + years := duration[:len(duration)-1] + if y, err := time.ParseDuration(years + "h"); err == nil { + return y * 24 * 365, nil // Approximate year as 365 days + } + return 0, fmt.Errorf("invalid year duration: %s", duration) + default: + return time.ParseDuration(duration) + } +} diff --git a/framework/configstore/tables/vectorstore.go b/framework/configstore/tables/vectorstore.go new file mode 100644 index 000000000..e02c23fff --- /dev/null +++ b/framework/configstore/tables/vectorstore.go @@ -0,0 +1,19 @@ +package tables + +import "time" + +// TableVectorStoreConfig represents Cache plugin configuration in the database +type TableVectorStoreConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Enabled bool `json:"enabled"` // Enable vector store + Type string `gorm:"type:varchar(50);not null" json:"type"` // "weaviate, elasticsearch, pinecone, etc." + TTLSeconds int `gorm:"default:300" json:"ttl_seconds"` // TTL in seconds (default: 5 minutes) + CacheByModel bool `gorm:"" json:"cache_by_model"` // Include model in cache key + CacheByProvider bool `gorm:"" json:"cache_by_provider"` // Include provider in cache key + Config *string `gorm:"type:text" json:"config"` // JSON serialized schemas.RedisVectorStoreConfig + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableVectorStoreConfig) TableName() string { return "config_vector_store" } diff --git a/framework/configstore/tables/virtualkey.go b/framework/configstore/tables/virtualkey.go new file mode 100644 index 000000000..65a6d9ac4 --- /dev/null +++ b/framework/configstore/tables/virtualkey.go @@ -0,0 +1,117 @@ +package tables + +import ( + "fmt" + "time" + + "gorm.io/gorm" +) + +// TableVirtualKeyProviderConfig represents a provider configuration for a virtual key +type TableVirtualKeyProviderConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + VirtualKeyID string `gorm:"type:varchar(255);not null" json:"virtual_key_id"` + Provider string `gorm:"type:varchar(50);not null" json:"provider"` + Weight float64 `gorm:"default:1.0" json:"weight"` + AllowedModels []string `gorm:"type:text;serializer:json" json:"allowed_models"` // Empty means all models allowed + BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` + + // Relationships + Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` + RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` +} + +// TableName sets the table name for each model +func (TableVirtualKeyProviderConfig) TableName() string { + return "governance_virtual_key_provider_configs" +} + +type TableVirtualKeyMCPConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + VirtualKeyID string `gorm:"type:varchar(255);not null;uniqueIndex:idx_vk_mcpclient" json:"virtual_key_id"` + MCPClientID uint `gorm:"not null;uniqueIndex:idx_vk_mcpclient" json:"mcp_client_id"` + MCPClient TableMCPClient `gorm:"foreignKey:MCPClientID" json:"mcp_client"` + ToolsToExecute []string `gorm:"type:text;serializer:json" json:"tools_to_execute"` +} + +// TableName sets the table name for each model +func (TableVirtualKeyMCPConfig) TableName() string { + return "governance_virtual_key_mcp_configs" +} + +// TableVirtualKey represents a virtual key with budget, rate limits, and team/customer association +type TableVirtualKey struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + Name string `gorm:"uniqueIndex:idx_virtual_key_name;type:varchar(255);not null" json:"name"` + Description string `gorm:"type:text" json:"description,omitempty"` + Value string `gorm:"uniqueIndex:idx_virtual_key_value;type:varchar(255);not null" json:"value"` // The virtual key value + IsActive bool `gorm:"default:true" json:"is_active"` + ProviderConfigs []TableVirtualKeyProviderConfig `gorm:"foreignKey:VirtualKeyID;constraint:OnDelete:CASCADE" json:"provider_configs"` // Empty means all providers allowed + MCPConfigs []TableVirtualKeyMCPConfig `gorm:"foreignKey:VirtualKeyID;constraint:OnDelete:CASCADE" json:"mcp_configs"` + + // Foreign key relationships (mutually exclusive: either TeamID or CustomerID, not both) + TeamID *string `gorm:"type:varchar(255);index" json:"team_id,omitempty"` + CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"` + BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` + Keys []TableKey `gorm:"many2many:governance_virtual_key_keys;constraint:OnDelete:CASCADE" json:"keys"` + + // Relationships + Team *TableTeam `gorm:"foreignKey:TeamID" json:"team,omitempty"` + Customer *TableCustomer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"` + Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` + RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` +} + +// TableName sets the table name for each model +func (TableVirtualKey) TableName() string { return "governance_virtual_keys" } + +// BeforeSave hook for VirtualKey to enforce mutual exclusion +func (vk *TableVirtualKey) BeforeSave(tx *gorm.DB) error { + // Enforce mutual exclusion: VK can belong to either Team OR Customer, not both + if vk.TeamID != nil && vk.CustomerID != nil { + return fmt.Errorf("virtual key cannot belong to both team and customer") + } + return nil +} + +// AfterFind hook for VirtualKey to clear sensitive data from associated keys +func (vk *TableVirtualKey) AfterFind(tx *gorm.DB) error { + if vk.Keys != nil { + // Clear sensitive data from associated keys, keeping only key IDs and non-sensitive metadata + for i := range vk.Keys { + key := &vk.Keys[i] + + // Clear the actual API key value + key.Value = "" + + // Clear all Azure-related sensitive fields + key.AzureEndpoint = nil + key.AzureAPIVersion = nil + key.AzureDeploymentsJSON = nil + key.AzureKeyConfig = nil + + // Clear all Vertex-related sensitive fields + key.VertexProjectID = nil + key.VertexRegion = nil + key.VertexAuthCredentials = nil + key.VertexKeyConfig = nil + + // Clear all Bedrock-related sensitive fields + key.BedrockAccessKey = nil + key.BedrockSecretKey = nil + key.BedrockSessionToken = nil + key.BedrockRegion = nil + key.BedrockARN = nil + key.BedrockDeploymentsJSON = nil + key.BedrockKeyConfig = nil + + vk.Keys[i] = *key + } + } + return nil +} diff --git a/framework/configstore/utils.go b/framework/configstore/utils.go new file mode 100644 index 000000000..33dbf0f3f --- /dev/null +++ b/framework/configstore/utils.go @@ -0,0 +1,214 @@ +package configstore + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/maximhq/bifrost/core/schemas" +) + +// marshalToString marshals the given value to a JSON string. +func marshalToString(v any) (string, error) { + if v == nil { + return "", nil + } + data, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(data), nil +} + +// marshalToStringPtr marshals the given value to a JSON string and returns a pointer to the string. +func marshalToStringPtr(v any) (*string, error) { + if v == nil { + return nil, nil + } + data, err := marshalToString(v) + if err != nil { + return nil, err + } + return &data, nil +} + +// deepCopy creates a deep copy of a given type +func deepCopy[T any](in T) (T, error) { + var out T + b, err := json.Marshal(in) + if err != nil { + return out, err + } + err = json.Unmarshal(b, &out) + return out, err +} + +// substituteEnvVars replaces resolved environment variable values with their original env.VAR_NAME references +func substituteEnvVars(config *ProviderConfig, provider schemas.ModelProvider, envKeys map[string][]EnvKeyInfo) { + // Create a map for quick lookup of env vars by provider and key ID + envVarMap := make(map[string]string) // key: "provider.keyID.field" -> env var name + + for envVar, keyInfos := range envKeys { + for _, keyInfo := range keyInfos { + if keyInfo.Provider == provider { + // For API keys + if keyInfo.KeyType == "api_key" { + envVarMap[fmt.Sprintf("%s.%s.value", provider, keyInfo.KeyID)] = envVar + } + // For Azure config + if keyInfo.KeyType == "azure_config" { + field := strings.TrimPrefix(keyInfo.ConfigPath, fmt.Sprintf("providers.%s.keys[%s].azure_key_config.", provider, keyInfo.KeyID)) + envVarMap[fmt.Sprintf("%s.%s.azure.%s", provider, keyInfo.KeyID, field)] = envVar + } + // For Vertex config + if keyInfo.KeyType == "vertex_config" { + field := strings.TrimPrefix(keyInfo.ConfigPath, fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.", provider, keyInfo.KeyID)) + envVarMap[fmt.Sprintf("%s.%s.vertex.%s", provider, keyInfo.KeyID, field)] = envVar + } + // For Bedrock config + if keyInfo.KeyType == "bedrock_config" { + field := strings.TrimPrefix(keyInfo.ConfigPath, fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.", provider, keyInfo.KeyID)) + envVarMap[fmt.Sprintf("%s.%s.bedrock.%s", provider, keyInfo.KeyID, field)] = envVar + } + } + } + } + + // Substitute values in keys + for i, key := range config.Keys { + keyPrefix := fmt.Sprintf("%s.%s", provider, key.ID) + + // Substitute API key value + if envVar, exists := envVarMap[fmt.Sprintf("%s.value", keyPrefix)]; exists { + config.Keys[i].Value = fmt.Sprintf("env.%s", envVar) + } + + // Substitute Azure config + if key.AzureKeyConfig != nil { + if envVar, exists := envVarMap[fmt.Sprintf("%s.azure.endpoint", keyPrefix)]; exists { + config.Keys[i].AzureKeyConfig.Endpoint = fmt.Sprintf("env.%s", envVar) + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.azure.api_version", keyPrefix)]; exists { + apiVersion := fmt.Sprintf("env.%s", envVar) + config.Keys[i].AzureKeyConfig.APIVersion = &apiVersion + } + } + + // Substitute Vertex config + if key.VertexKeyConfig != nil { + if envVar, exists := envVarMap[fmt.Sprintf("%s.vertex.project_id", keyPrefix)]; exists { + config.Keys[i].VertexKeyConfig.ProjectID = fmt.Sprintf("env.%s", envVar) + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.vertex.region", keyPrefix)]; exists { + config.Keys[i].VertexKeyConfig.Region = fmt.Sprintf("env.%s", envVar) + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.vertex.auth_credentials", keyPrefix)]; exists { + config.Keys[i].VertexKeyConfig.AuthCredentials = fmt.Sprintf("env.%s", envVar) + } + } + + // Substitute Bedrock config + if key.BedrockKeyConfig != nil { + if envVar, exists := envVarMap[fmt.Sprintf("%s.bedrock.access_key", keyPrefix)]; exists { + config.Keys[i].BedrockKeyConfig.AccessKey = fmt.Sprintf("env.%s", envVar) + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.bedrock.secret_key", keyPrefix)]; exists { + config.Keys[i].BedrockKeyConfig.SecretKey = fmt.Sprintf("env.%s", envVar) + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.bedrock.session_token", keyPrefix)]; exists { + config.Keys[i].BedrockKeyConfig.SessionToken = &[]string{fmt.Sprintf("env.%s", envVar)}[0] + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.bedrock.region", keyPrefix)]; exists { + config.Keys[i].BedrockKeyConfig.Region = &[]string{fmt.Sprintf("env.%s", envVar)}[0] + } + if envVar, exists := envVarMap[fmt.Sprintf("%s.bedrock.arn", keyPrefix)]; exists { + config.Keys[i].BedrockKeyConfig.ARN = &[]string{fmt.Sprintf("env.%s", envVar)}[0] + } + } + } +} + +// substituteMCPEnvVars replaces resolved environment variable values with their original env.VAR_NAME references for MCP config +func substituteMCPEnvVars(config *schemas.MCPConfig, envKeys map[string][]EnvKeyInfo) { + // Create a map for quick lookup of env vars by MCP client name + envVarMap := make(map[string]string) // key: "clientName.connection_string" -> env var name + + for envVar, keyInfos := range envKeys { + for _, keyInfo := range keyInfos { + // For MCP connection strings + if keyInfo.KeyType == "connection_string" { + // Extract client name from config path like "mcp.client_configs.clientName.connection_string" + pathParts := strings.Split(keyInfo.ConfigPath, ".") + if len(pathParts) >= 3 && pathParts[0] == "mcp" && pathParts[1] == "client_configs" { + clientName := pathParts[2] + envVarMap[fmt.Sprintf("%s.connection_string", clientName)] = envVar + } + } + // For MCP headers + if keyInfo.KeyType == "mcp_header" { + // Extract client name and header name from config path like "mcp.client_configs.clientName.headers.headerName" + pathParts := strings.Split(keyInfo.ConfigPath, ".") + if len(pathParts) >= 5 && pathParts[0] == "mcp" && pathParts[1] == "client_configs" && pathParts[3] == "headers" { + clientName := pathParts[2] + headerName := pathParts[4] + envVarMap[fmt.Sprintf("%s.headers.%s", clientName, headerName)] = envVar + } + } + } + } + + // Substitute values in MCP client configs + for i, clientConfig := range config.ClientConfigs { + clientPrefix := clientConfig.Name + + // Substitute connection string + if clientConfig.ConnectionString != nil { + if envVar, exists := envVarMap[fmt.Sprintf("%s.connection_string", clientPrefix)]; exists { + config.ClientConfigs[i].ConnectionString = &[]string{fmt.Sprintf("env.%s", envVar)}[0] + } + } + + // Substitute headers + if clientConfig.Headers != nil { + for header := range clientConfig.Headers { + if envVar, exists := envVarMap[fmt.Sprintf("%s.headers.%s", clientPrefix, header)]; exists { + clientConfig.Headers[header] = fmt.Sprintf("env.%s", envVar) + } + } + } + } +} + +// substituteMCPClientEnvVars replaces resolved environment variable values with their original env.VAR_NAME references for a single MCP client config +func substituteMCPClientEnvVars(clientConfig *schemas.MCPClientConfig, envKeys map[string][]EnvKeyInfo) { + // Find the environment variable for this client's connection string and headers + for envVar, keyInfos := range envKeys { + for _, keyInfo := range keyInfos { + // For MCP connection strings + if keyInfo.KeyType == "connection_string" { + // Extract client name from config path like "mcp.client_configs.clientName.connection_string" + pathParts := strings.Split(keyInfo.ConfigPath, ".") + if len(pathParts) >= 3 && pathParts[0] == "mcp" && pathParts[1] == "client_configs" { + clientName := pathParts[2] + // If this environment variable is for the current client + if clientName == clientConfig.Name && clientConfig.ConnectionString != nil { + clientConfig.ConnectionString = &[]string{fmt.Sprintf("env.%s", envVar)}[0] + } + } + } + // For MCP headers + if keyInfo.KeyType == "mcp_header" { + // Extract client name and header name from config path like "mcp.client_configs.clientName.headers.headerName" + pathParts := strings.Split(keyInfo.ConfigPath, ".") + if len(pathParts) >= 5 && pathParts[0] == "mcp" && pathParts[1] == "client_configs" && pathParts[3] == "headers" { + clientName := pathParts[2] + headerName := pathParts[4] + // If this environment variable is for the current client + if clientName == clientConfig.Name && clientConfig.Headers != nil { + clientConfig.Headers[headerName] = fmt.Sprintf("env.%s", envVar) + } + } + } + } + } +} diff --git a/framework/docker-compose.yml b/framework/docker-compose.yml new file mode 100644 index 000000000..0b601118c --- /dev/null +++ b/framework/docker-compose.yml @@ -0,0 +1,71 @@ +services: + postgres: + image: postgres:16-alpine + container_name: bifrost-postgres + 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 + + redis: + image: redis/redis-stack:latest + container_name: bifrost-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - bifrost_network + + weaviate: + image: cr.weaviate.io/semitechnologies/weaviate:1.25.0 + container_name: bifrost-weaviate + ports: + - "9000:8080" + - "50051:50051" + environment: + QUERY_DEFAULTS_LIMIT: 25 + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true' + PERSISTENCE_DATA_PATH: '/var/lib/weaviate' + DEFAULT_VECTORIZER_MODULE: 'none' + CLUSTER_HOSTNAME: 'node1' + 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 + restart: unless-stopped + networks: + - bifrost_network + +networks: + bifrost_network: + driver: bridge + +volumes: + postgres_data: + driver: local + weaviate_data: + driver: local + redis_data: + driver: local + diff --git a/framework/encrypt/encrypt.go b/framework/encrypt/encrypt.go new file mode 100644 index 000000000..2f85297d8 --- /dev/null +++ b/framework/encrypt/encrypt.go @@ -0,0 +1,143 @@ +// Package encrypt provides reversible AES-256-GCM encryption and decryption utilities +// for securing sensitive data like API keys and credentials. +// We are not using it anywhere yet - we will introduce encryption for all the sensitive data in one go to avoid breaking changes +package encrypt + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + + "github.com/maximhq/bifrost/core/schemas" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +var encryptionKey []byte +var logger schemas.Logger + +var ErrEncryptionKeyNotInitialized = errors.New("encryption key is not initialized") + +// Init initializes the encryption key using Argon2id KDF to derive a secure 32-byte key +// from the provided passphrase. This ensures strong entropy regardless of passphrase length. +// The function accepts any passphrase but warns if it's too short (< 16 bytes). +func Init(key string, _logger schemas.Logger) { + logger = _logger + if key == "" { + // TODO uncomment this warning when we have full coverage for encryption + // In this case encryption will be disabled + // logger.Warn("encryption key is not set, encryption will be disabled. To set encryption key: use the encryption_key field in the configuration file or set the BIFROST_ENCRYPTION_KEY environment variable. Note that - once encryption key is set, it cannot be changed later unless you clean up the database.") + return + } + + // Warn if passphrase is too short + if len(key) < 16 { + logger.Warn("encryption passphrase is shorter than 16 bytes, consider using a longer passphrase for better security") + } + + // Derive a secure 32-byte key using Argon2id KDF + // We use a fixed salt since this is a system-wide encryption key (not per-user passwords) + // Argon2id parameters: time=1, memory=64MB, threads=4, keyLen=32 + // This provides strong security while maintaining reasonable performance for initialization + salt := []byte("bifrost-encryption-v1-salt-2024") + encryptionKey = argon2.IDKey([]byte(key), salt, 1, 64*1024, 4, 32) +} + +// CompareHash compares a hash and a password +func CompareHash(hash string, password string) (bool, error) { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return false, fmt.Errorf("failed to compare hash: %w", err) + } + return true, nil +} + +// Hash hashes a password using bcrypt +func Hash(password string) (string, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(hashedPassword), nil +} + +// Encrypt encrypts a plaintext string using AES-256-GCM and returns a base64-encoded ciphertext +func Encrypt(plaintext string) (string, error) { + if encryptionKey == nil { + return plaintext, nil + } + if plaintext == "" { + return "", nil + } + + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return plaintext, fmt.Errorf("failed to create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return plaintext, fmt.Errorf("failed to create GCM: %w", err) + } + + // Create a nonce (number used once) + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return plaintext, fmt.Errorf("failed to read nonce: %w", err) + } + + // Encrypt the data + ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + + // Encode to base64 for storage + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a base64-encoded ciphertext using AES-256-GCM and returns the plaintext +func Decrypt(ciphertext string) (string, error) { + if encryptionKey == nil { + return ciphertext, ErrEncryptionKeyNotInitialized + } + if ciphertext == "" { + return ciphertext, nil + } + + // Decode from base64 + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode base64: %w", err) + } + + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + // Extract nonce + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + + // Decrypt the data + plaintext, err := aesGCM.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return string(plaintext), nil +} diff --git a/framework/encrypt/encrypt_test.go b/framework/encrypt/encrypt_test.go new file mode 100644 index 000000000..60cc1f0c7 --- /dev/null +++ b/framework/encrypt/encrypt_test.go @@ -0,0 +1,245 @@ +package encrypt + +import ( + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +func TestEncryptDecrypt(t *testing.T) { + // Set a test encryption key + testKey := "test-encryption-key-for-testing-32bytes" + Init(testKey, bifrost.NewDefaultLogger(schemas.LogLevelInfo)) + + testCases := []struct { + name string + plaintext string + }{ + { + name: "Simple text", + plaintext: "hello world", + }, + { + name: "AWS Access Key", + plaintext: "AKIAIOSFODNN7EXAMPLE", + }, + { + name: "AWS Secret Key", + plaintext: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + { + name: "Empty string", + plaintext: "", + }, + { + name: "Special characters", + plaintext: "!@#$%^&*()_+-=[]{}|;':\",./<>?`~", + }, + { + name: "Long text", + plaintext: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Encrypt + encrypted, err := Encrypt(tc.plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + // For empty strings, encryption should return empty + if tc.plaintext == "" { + if encrypted != "" { + t.Errorf("Expected empty string for empty input, got: %s", encrypted) + } + return + } + + // Encrypted text should be different from plaintext + if encrypted == tc.plaintext { + t.Errorf("Encrypted text should be different from plaintext") + } + + // Decrypt + decrypted, err := Decrypt(encrypted) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + + // Decrypted text should match original plaintext + if decrypted != tc.plaintext { + t.Errorf("Decrypted text does not match original.\nExpected: %s\nGot: %s", tc.plaintext, decrypted) + } + }) + } +} + +func TestEncryptDeterminism(t *testing.T) { + // Set a test encryption key + testKey := "test-encryption-key-for-testing-32bytes" + Init(testKey, bifrost.NewDefaultLogger(schemas.LogLevelInfo)) + + plaintext := "test-plaintext" + + // Encrypt the same text twice + encrypted1, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + encrypted2, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + // They should be different (due to random nonce) + if encrypted1 == encrypted2 { + t.Errorf("Two encryptions of the same plaintext should produce different ciphertexts (due to random nonce)") + } + + // But both should decrypt to the same plaintext + decrypted1, err := Decrypt(encrypted1) + if err != nil { + t.Fatalf("Failed to decrypt first: %v", err) + } + decrypted2, err := Decrypt(encrypted2) + if err != nil { + t.Fatalf("Failed to decrypt second: %v", err) + } + + if decrypted1 != plaintext || decrypted2 != plaintext { + t.Errorf("Both decryptions should match original plaintext") + } +} + +func TestDecryptInvalidData(t *testing.T) { + // Set a test encryption key + testKey := "test-encryption-key-for-testing-32bytes" + Init(testKey, bifrost.NewDefaultLogger(schemas.LogLevelInfo)) + + testCases := []struct { + name string + ciphertext string + }{ + { + name: "Invalid base64", + ciphertext: "not-valid-base64!@#$", + }, + { + name: "Valid base64 but invalid ciphertext", + ciphertext: "YWJjZGVmZ2hpamtsbW5vcA==", + }, + { + name: "Too short ciphertext", + ciphertext: "YWJj", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := Decrypt(tc.ciphertext) + if err == nil { + t.Errorf("Expected error when decrypting invalid data, got nil") + } + }) + } +} + +func TestKDFWithVariousKeyLengths(t *testing.T) { + // Test that keys of various lengths work correctly with KDF + testCases := []struct { + name string + key string + }{ + { + name: "Short key (8 bytes)", + key: "shortkey", + }, + { + name: "Medium key (16 bytes)", + key: "medium-key-16byt", + }, + { + name: "Long key (32 bytes)", + key: "this-is-a-32-byte-long-key!!", + }, + { + name: "Very long key (64 bytes)", + key: "this-is-a-very-long-key-that-is-definitely-more-than-64-bytes", + }, + } + + plaintext := "test-data-for-encryption" + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Initialize with this key + Init(tc.key, bifrost.NewDefaultLogger(schemas.LogLevelInfo)) + + // Encrypt + encrypted, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + // Should produce valid ciphertext + if encrypted == plaintext { + t.Errorf("Encrypted text should be different from plaintext") + } + + // Decrypt should work + decrypted, err := Decrypt(encrypted) + if err != nil { + t.Fatalf("Failed to decrypt with %s: %v", tc.name, err) + } + + if decrypted != plaintext { + t.Errorf("Decrypted text does not match original.\nExpected: %s\nGot: %s", plaintext, decrypted) + } + }) + } +} + +func TestKDFDeterministic(t *testing.T) { + // Test that the same passphrase always produces the same derived key + passphrase := "test-passphrase" + plaintext := "test-data" + + // Initialize with passphrase and encrypt + Init(passphrase, bifrost.NewDefaultLogger(schemas.LogLevelInfo)) + encrypted1, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + // Re-initialize with same passphrase (simulating restart) + Init(passphrase, bifrost.NewDefaultLogger(schemas.LogLevelInfo)) + + // Should be able to decrypt the previously encrypted data + decrypted, err := Decrypt(encrypted1) + if err != nil { + t.Fatalf("Failed to decrypt after re-initialization: %v", err) + } + + if decrypted != plaintext { + t.Errorf("Decrypted text does not match original after re-initialization.\nExpected: %s\nGot: %s", plaintext, decrypted) + } + + // Encrypt again with same passphrase + encrypted2, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + // Should be able to decrypt both (even though they're different due to nonce) + decrypted2, err := Decrypt(encrypted2) + if err != nil { + t.Fatalf("Failed to decrypt second encryption: %v", err) + } + + if decrypted2 != plaintext { + t.Errorf("Second decryption does not match original.\nExpected: %s\nGot: %s", plaintext, decrypted2) + } +} diff --git a/framework/envutils/utils.go b/framework/envutils/utils.go new file mode 100644 index 000000000..25fc2bf86 --- /dev/null +++ b/framework/envutils/utils.go @@ -0,0 +1,23 @@ +package envutils + +import ( + "fmt" + "os" + "strings" +) + +// ProcessEnvValue processes a value that might be an environment variable reference +func ProcessEnvValue(value string) (string, error) { + v := strings.TrimSpace(value) + if !strings.HasPrefix(v, "env.") { + return value, nil + } + envKey := strings.TrimSpace(strings.TrimPrefix(v, "env.")) + if envKey == "" { + return "", fmt.Errorf("environment variable name missing in %q", value) + } + if envValue, ok := os.LookupEnv(envKey); ok { + return envValue, nil + } + return "", fmt.Errorf("environment variable %s not found", envKey) +} diff --git a/framework/go.mod b/framework/go.mod new file mode 100644 index 000000000..050956add --- /dev/null +++ b/framework/go.mod @@ -0,0 +1,115 @@ +module github.com/maximhq/bifrost/framework + +go 1.24.0 + +toolchain go1.24.3 + +require ( + github.com/google/uuid v1.6.0 + github.com/maximhq/bifrost/core v1.2.22 + github.com/redis/go-redis/v9 v9.14.0 + github.com/stretchr/testify v1.11.1 + github.com/weaviate/weaviate v1.33.1 + github.com/weaviate/weaviate-go-client/v5 v5.5.0 + golang.org/x/crypto v0.43.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sync v0.17.0 // indirect +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/sonic v1.14.1 + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 +) diff --git a/framework/go.sum b/framework/go.sum new file mode 100644 index 000000000..ddfef87c6 --- /dev/null +++ b/framework/go.sum @@ -0,0 +1,253 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/framework/list.go b/framework/list.go new file mode 100644 index 000000000..7e32cfdd4 --- /dev/null +++ b/framework/list.go @@ -0,0 +1,14 @@ +// Package framework provides a list of dependencies that are required for the framework to work. +package framework + +// FrameworkDependency is a type that represents a dependency of the framework. +type FrameworkDependency string + +const ( + // FrameworkDependencyVectorStore indicates the framework requires a VectorStore implementation. + FrameworkDependencyVectorStore FrameworkDependency = "vector_store" + // FrameworkDependencyConfigStore indicates the framework requires a ConfigStore implementation. + FrameworkDependencyConfigStore FrameworkDependency = "config_store" + // FrameworkDependencyLogsStore indicates the framework requires a LogsStore implementation. + FrameworkDependencyLogsStore FrameworkDependency = "logs_store" +) diff --git a/framework/logstore/config.go b/framework/logstore/config.go new file mode 100644 index 000000000..772cc9039 --- /dev/null +++ b/framework/logstore/config.go @@ -0,0 +1,101 @@ +// Package logstore provides a logs store for Bifrost. +package logstore + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/maximhq/bifrost/framework/envutils" +) + +// Config represents the configuration for the logs store. +type Config struct { + Enabled bool `json:"enabled"` + Type LogStoreType `json:"type"` + Config any `json:"config"` +} + +// UnmarshalJSON is the custom unmarshal logic for Config +func (c *Config) UnmarshalJSON(data []byte) error { + // First, unmarshal into a temporary struct to get the basic fields + type TempConfig struct { + Enabled bool `json:"enabled"` + Type LogStoreType `json:"type"` + Config json.RawMessage `json:"config"` // Keep as raw JSON + } + + var temp TempConfig + if err := json.Unmarshal(data, &temp); err != nil { + return fmt.Errorf("failed to unmarshal logs config: %w", err) + } + + // Set basic fields + c.Enabled = temp.Enabled + c.Type = temp.Type + + if !temp.Enabled { + c.Config = nil + return nil + } + + // Parse the config field based on type + switch temp.Type { + case LogStoreTypeSQLite: + if len(temp.Config) == 0 { + return fmt.Errorf("missing sqlite config payload") + } + var sqliteConfig SQLiteConfig + if err := json.Unmarshal(temp.Config, &sqliteConfig); err != nil { + return fmt.Errorf("failed to unmarshal sqlite config: %w", err) + } + c.Config = &sqliteConfig + case LogStoreTypePostgres: + var postgresConfig PostgresConfig + var err error + if err = json.Unmarshal(temp.Config, &postgresConfig); err != nil { + return fmt.Errorf("failed to unmarshal postgres config: %w", err) + } + // Checking if any of the values start with env. If so, we need to process them. + if postgresConfig.DBName != "" && strings.HasPrefix(postgresConfig.DBName, "env.") { + postgresConfig.DBName, err = envutils.ProcessEnvValue(postgresConfig.DBName) + if err != nil { + return fmt.Errorf("failed to process env value for db name: %w", err) + } + } + if postgresConfig.Password != "" && strings.HasPrefix(postgresConfig.Password, "env.") { + postgresConfig.Password, err = envutils.ProcessEnvValue(postgresConfig.Password) + if err != nil { + return fmt.Errorf("failed to process env value for password: %w", err) + } + } + if postgresConfig.User != "" && strings.HasPrefix(postgresConfig.User, "env.") { + postgresConfig.User, err = envutils.ProcessEnvValue(postgresConfig.User) + if err != nil { + return fmt.Errorf("failed to process env value for user: %w", err) + } + } + if postgresConfig.Host != "" && strings.HasPrefix(postgresConfig.Host, "env.") { + postgresConfig.Host, err = envutils.ProcessEnvValue(postgresConfig.Host) + if err != nil { + return fmt.Errorf("failed to process env value for host: %w", err) + } + } + if postgresConfig.Port != "" && strings.HasPrefix(postgresConfig.Port, "env.") { + postgresConfig.Port, err = envutils.ProcessEnvValue(postgresConfig.Port) + if err != nil { + return fmt.Errorf("failed to process env value for port: %w", err) + } + } + if postgresConfig.SSLMode != "" && strings.HasPrefix(postgresConfig.SSLMode, "env.") { + postgresConfig.SSLMode, err = envutils.ProcessEnvValue(postgresConfig.SSLMode) + if err != nil { + return fmt.Errorf("failed to process env value for ssl mode: %w", err) + } + } + c.Config = &postgresConfig + default: + return fmt.Errorf("unknown log store type: %s", temp.Type) + } + return nil +} diff --git a/framework/logstore/errors.go b/framework/logstore/errors.go new file mode 100644 index 000000000..650d767d3 --- /dev/null +++ b/framework/logstore/errors.go @@ -0,0 +1,7 @@ +package logstore + +import "fmt" + +var ( + ErrNotFound = fmt.Errorf("log not found") +) diff --git a/framework/logstore/logger.go b/framework/logstore/logger.go new file mode 100644 index 000000000..eda60cf72 --- /dev/null +++ b/framework/logstore/logger.go @@ -0,0 +1,45 @@ +package logstore + +import ( + "context" + "time" + + "github.com/maximhq/bifrost/core/schemas" + gormLibLogger "gorm.io/gorm/logger" +) + +// GormLogger is a logger for GORM. +type gormLogger struct { + logger schemas.Logger +} + +// LogMode sets the log mode for the logger. +func (l *gormLogger) LogMode(level gormLibLogger.LogLevel) gormLibLogger.Interface { + // NOOP + return l +} + +// Info logs an info message. +func (l *gormLogger) Info(ctx context.Context, msg string, data ...interface{}) { + l.logger.Info(msg, data...) +} + +// Warn logs a warning message. +func (l *gormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + l.logger.Warn(msg, data...) +} + +// Error logs an error message. +func (l *gormLogger) Error(ctx context.Context, msg string, data ...interface{}) { + l.logger.Error(msg, data...) +} + +// Trace logs a trace message. +func (l *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + // NOOP +} + +// newGormLogger creates a new GormLogger. +func newGormLogger(l schemas.Logger) *gormLogger { + return &gormLogger{logger: l} +} diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go new file mode 100644 index 000000000..0a3b8fac3 --- /dev/null +++ b/framework/logstore/migrations.go @@ -0,0 +1,382 @@ +package logstore + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/framework/migrator" + "gorm.io/gorm" +) + +// Migrate performs the necessary database migrations. +func triggerMigrations(ctx context.Context, db *gorm.DB) error { + if err := migrationInit(ctx, db); err != nil { + return err + } + if err := migrationUpdateObjectColumnValues(ctx, db); err != nil { + return err + } + if err := migrationAddParentRequestIDColumn(ctx, db); err != nil { + return err + } + if err := migrationAddResponsesOutputColumn(ctx, db); err != nil { + return err + } + if err := migrationAddCostAndCacheDebugColumn(ctx, db); err != nil { + return err + } + if err := migrationAddResponsesInputHistoryColumn(ctx, db); err != nil { + return err + } + if err := migrationAddNumberOfRetriesAndFallbackIndexAndSelectedKeyAndVirtualKeyColumns(ctx, db); err != nil { + return err + } + return nil +} + +// migrationInit is the first migration +func migrationInit(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "logs_init", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasTable(&Log{}) { + if err := migrator.CreateTable(&Log{}); err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + // Drop children first, then parents (adjust if your actual FKs differ) + if err := migrator.DropTable(&Log{}); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + +// migrationUpdateObjectColumnValues updates the object column values from old format to new format +func migrationUpdateObjectColumnValues(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_init_update_object_column_values", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + + updateSQL := ` + UPDATE logs + SET object_type = CASE object_type + WHEN 'chat.completion' THEN 'chat_completion' + WHEN 'text.completion' THEN 'text_completion' + WHEN 'list' THEN 'embedding' + WHEN 'audio.speech' THEN 'speech' + WHEN 'audio.transcription' THEN 'transcription' + WHEN 'chat.completion.chunk' THEN 'chat_completion_stream' + WHEN 'audio.speech.chunk' THEN 'speech_stream' + WHEN 'audio.transcription.chunk' THEN 'transcription_stream' + WHEN 'response' THEN 'responses' + WHEN 'response.completion.chunk' THEN 'responses_stream' + ELSE object_type + END + WHERE object_type IN ( + 'chat.completion', 'text.completion', 'list', + 'audio.speech', 'audio.transcription', 'chat.completion.chunk', + 'audio.speech.chunk', 'audio.transcription.chunk', + 'response', 'response.completion.chunk' + )` + + result := tx.Exec(updateSQL) + if result.Error != nil { + return fmt.Errorf("failed to update object_type values: %w", result.Error) + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + + // Use a single CASE statement for efficient bulk rollback + rollbackSQL := ` + UPDATE logs + SET object_type = CASE object_type + WHEN 'chat_completion' THEN 'chat.completion' + WHEN 'text_completion' THEN 'text.completion' + WHEN 'embedding' THEN 'list' + WHEN 'speech' THEN 'audio.speech' + WHEN 'transcription' THEN 'audio.transcription' + WHEN 'chat_completion_stream' THEN 'chat.completion.chunk' + WHEN 'speech_stream' THEN 'audio.speech.chunk' + WHEN 'transcription_stream' THEN 'audio.transcription.chunk' + WHEN 'responses' THEN 'response' + WHEN 'responses_stream' THEN 'response.completion.chunk' + ELSE object_type + END + WHERE object_type IN ( + 'chat_completion', 'text_completion', 'embedding', 'speech', + 'transcription', 'chat_completion_stream', 'speech_stream', + 'transcription_stream', 'responses', 'responses_stream' + )` + + result := tx.Exec(rollbackSQL) + if result.Error != nil { + return fmt.Errorf("failed to rollback object_type values: %w", result.Error) + } + + return nil + }, + }}) + + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running object column migration: %s", err.Error()) + } + return nil +} + +// migrationAddParentRequestIDColumn adds the parent_request_id column to the logs table +func migrationAddParentRequestIDColumn(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_init_add_parent_request_id_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&Log{}, "parent_request_id") { + if err := migrator.AddColumn(&Log{}, "parent_request_id"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&Log{}, "parent_request_id"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding parent_request_id column: %s", err.Error()) + } + return nil +} + +func migrationAddResponsesOutputColumn(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_init_add_responses_output_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&Log{}, "responses_output") { + if err := migrator.AddColumn(&Log{}, "responses_output"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "input_history") { + if err := migrator.AddColumn(&Log{}, "input_history"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "output_message") { + if err := migrator.AddColumn(&Log{}, "output_message"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "embedding_output") { + if err := migrator.AddColumn(&Log{}, "embedding_output"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "raw_response") { + if err := migrator.AddColumn(&Log{}, "raw_response"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&Log{}, "responses_output"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "input_history"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "output_message"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "embedding_output"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "raw_response"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding responses_output column: %s", err.Error()) + } + return nil +} + +func migrationAddCostAndCacheDebugColumn(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_init_add_cost_and_cache_debug_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&Log{}, "cost") { + if err := migrator.AddColumn(&Log{}, "cost"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "cache_debug") { + if err := migrator.AddColumn(&Log{}, "cache_debug"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&Log{}, "cost"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "cache_debug"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding cost column: %s", err.Error()) + } + return nil +} + +func migrationAddResponsesInputHistoryColumn(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_init_add_responses_input_history_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&Log{}, "responses_input_history") { + if err := migrator.AddColumn(&Log{}, "responses_input_history"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&Log{}, "responses_input_history"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding responses_input_history column: %s", err.Error()) + } + return nil +} + +func migrationAddNumberOfRetriesAndFallbackIndexAndSelectedKeyAndVirtualKeyColumns(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_init_add_number_of_retries_and_fallback_index_and_selected_key_and_virtual_key_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&Log{}, "number_of_retries") { + if err := migrator.AddColumn(&Log{}, "number_of_retries"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "fallback_index") { + if err := migrator.AddColumn(&Log{}, "fallback_index"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "selected_key_id") { + if err := migrator.AddColumn(&Log{}, "selected_key_id"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "selected_key_name") { + if err := migrator.AddColumn(&Log{}, "selected_key_name"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "virtual_key_id") { + if err := migrator.AddColumn(&Log{}, "virtual_key_id"); err != nil { + return err + } + } + if !migrator.HasColumn(&Log{}, "virtual_key_name") { + if err := migrator.AddColumn(&Log{}, "virtual_key_name"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if err := migrator.DropColumn(&Log{}, "number_of_retries"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "fallback_index"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "selected_key_id"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "selected_key_name"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "virtual_key_id"); err != nil { + return err + } + if err := migrator.DropColumn(&Log{}, "virtual_key_name"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding number_of_retries and fallback_index columns: %s", err.Error()) + } + return nil +} diff --git a/framework/logstore/postgres.go b/framework/logstore/postgres.go new file mode 100644 index 000000000..a449b589d --- /dev/null +++ b/framework/logstore/postgres.go @@ -0,0 +1,43 @@ +package logstore + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// PostgresConfig represents the configuration for a Postgres database. +type PostgresConfig struct { + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + DBName string `json:"db_name"` + SSLMode string `json:"ssl_mode"` +} + +// newPostgresLogStore creates a new Postgres log store. +func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger schemas.Logger) (LogStore, error) { + db, err := gorm.Open(postgres.Open(fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode)), &gorm.Config{ + Logger: newGormLogger(logger), + }) + if err != nil { + return nil, err + } + d := &RDBLogStore{db: db, logger: logger} + // Run migrations + if err := db.WithContext(ctx).AutoMigrate(&Log{}); err != nil { + // Closing the DB connection + if sqlDB, dbErr := db.DB(); dbErr == nil { + if closeErr := sqlDB.Close(); closeErr != nil { + logger.Error("failed to close DB connection: %v", closeErr) + } + } + return nil, err + } + return d, nil +} diff --git a/framework/logstore/rdb.go b/framework/logstore/rdb.go new file mode 100644 index 000000000..7f08e6bdd --- /dev/null +++ b/framework/logstore/rdb.go @@ -0,0 +1,236 @@ +package logstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/gorm" +) + +// RDBLogStore represents a log store that uses a SQLite database. +type RDBLogStore struct { + db *gorm.DB + logger schemas.Logger +} + +// Create inserts a new log entry into the database. +func (s *RDBLogStore) Create(ctx context.Context, entry *Log) error { + return s.db.WithContext(ctx).Create(entry).Error +} + +// Ping checks if the database is reachable. +func (s *RDBLogStore) Ping(ctx context.Context) error { + return s.db.WithContext(ctx).Exec("SELECT 1").Error +} + +// Update updates a log entry in the database. +func (s *RDBLogStore) Update(ctx context.Context, id string, entry any) error { + tx := s.db.WithContext(ctx).Model(&Log{}).Where("id = ?", id).Updates(entry) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return ErrNotFound + } + if tx.RowsAffected == 0 { + return ErrNotFound + } + return tx.Error +} + +// SearchLogs searches for logs in the database. +func (s *RDBLogStore) SearchLogs(ctx context.Context, filters SearchFilters, pagination PaginationOptions) (*SearchResult, error) { + baseQuery := s.db.WithContext(ctx).Model(&Log{}) + + // Apply filters efficiently + if len(filters.Providers) > 0 { + baseQuery = baseQuery.Where("provider IN ?", filters.Providers) + } + if len(filters.Models) > 0 { + baseQuery = baseQuery.Where("model IN ?", filters.Models) + } + if len(filters.Status) > 0 { + baseQuery = baseQuery.Where("status IN ?", filters.Status) + } + if len(filters.Objects) > 0 { + baseQuery = baseQuery.Where("object_type IN ?", filters.Objects) + } + if len(filters.SelectedKeyIDs) > 0 { + baseQuery = baseQuery.Where("selected_key_id IN ?", filters.SelectedKeyIDs) + } + if len(filters.VirtualKeyIDs) > 0 { + baseQuery = baseQuery.Where("virtual_key_id IN ?", filters.VirtualKeyIDs) + } + if filters.StartTime != nil { + baseQuery = baseQuery.Where("timestamp >= ?", *filters.StartTime) + } + if filters.EndTime != nil { + baseQuery = baseQuery.Where("timestamp <= ?", *filters.EndTime) + } + if filters.MinLatency != nil { + baseQuery = baseQuery.Where("latency >= ?", *filters.MinLatency) + } + if filters.MaxLatency != nil { + baseQuery = baseQuery.Where("latency <= ?", *filters.MaxLatency) + } + if filters.MinTokens != nil { + baseQuery = baseQuery.Where("total_tokens >= ?", *filters.MinTokens) + } + if filters.MaxTokens != nil { + baseQuery = baseQuery.Where("total_tokens <= ?", *filters.MaxTokens) + } + if filters.MinCost != nil { + baseQuery = baseQuery.Where("cost >= ?", *filters.MinCost) + } + if filters.MaxCost != nil { + baseQuery = baseQuery.Where("cost <= ?", *filters.MaxCost) + } + if filters.ContentSearch != "" { + baseQuery = baseQuery.Where("content_summary LIKE ?", "%"+filters.ContentSearch+"%") + } + + // Get total count + var totalCount int64 + if err := baseQuery.Count(&totalCount).Error; err != nil { + return nil, err + } + + // Initialize stats + stats := SearchStats{} + + // Calculate statistics efficiently if we have data + if totalCount > 0 { + // Total requests should include all requests (processing, success, error) + stats.TotalRequests = totalCount + + // Get completed requests count (success + error, excluding processing) for success rate calculation + var completedCount int64 + completedQuery := baseQuery.Session(&gorm.Session{}) + if err := completedQuery.Where("status IN ?", []string{"success", "error"}).Count(&completedCount).Error; err != nil { + return nil, err + } + + if completedCount > 0 { + // Calculate success rate based on completed requests only + var successCount int64 + successQuery := baseQuery.Session(&gorm.Session{}) + if err := successQuery.Where("status = ?", "success").Count(&successCount).Error; err != nil { + return nil, err + } + stats.SuccessRate = float64(successCount) / float64(completedCount) * 100 + + // Calculate average latency and total tokens in a single query for better performance + var result struct { + AvgLatency sql.NullFloat64 `json:"avg_latency"` + TotalTokens sql.NullInt64 `json:"total_tokens"` + TotalCost sql.NullFloat64 `json:"total_cost"` + } + + statsQuery := baseQuery.Session(&gorm.Session{}) + if err := statsQuery.Select("AVG(latency) as avg_latency, SUM(total_tokens) as total_tokens, SUM(cost) as total_cost").Scan(&result).Error; err != nil { + return nil, err + } + + if result.AvgLatency.Valid { + stats.AverageLatency = result.AvgLatency.Float64 + } + if result.TotalTokens.Valid { + stats.TotalTokens = result.TotalTokens.Int64 + } + if result.TotalCost.Valid { + stats.TotalCost = result.TotalCost.Float64 + } + } + } + + // Build order clause + direction := "DESC" + if pagination.Order == "asc" { + direction = "ASC" + } + + var orderClause string + switch pagination.SortBy { + case "timestamp": + orderClause = "timestamp " + direction + case "latency": + orderClause = "latency " + direction + case "tokens": + orderClause = "total_tokens " + direction + case "cost": + orderClause = "cost " + direction + default: + orderClause = "timestamp " + direction + } + + // Execute main query with sorting and pagination + var logs []Log + mainQuery := baseQuery.Order(orderClause) + + if pagination.Limit > 0 { + mainQuery = mainQuery.Limit(pagination.Limit) + } + if pagination.Offset > 0 { + mainQuery = mainQuery.Offset(pagination.Offset) + } + + if err := mainQuery.Find(&logs).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &SearchResult{ + Logs: logs, + Pagination: pagination, + Stats: stats, + }, nil + } + return nil, err + } + + return &SearchResult{ + Logs: logs, + Pagination: pagination, + Stats: stats, + }, nil +} + +// FindFirst gets a log entry from the database. +func (s *RDBLogStore) FindFirst(ctx context.Context, query any, fields ...string) (*Log, error) { + var log Log + if err := s.db.WithContext(ctx).Select(fields).Where(query).First(&log).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &log, nil +} + +// Flush deletes old log entries from the database. +func (s *RDBLogStore) Flush(ctx context.Context, since time.Time) error { + result := s.db.WithContext(ctx).Where("status = ? AND created_at < ?", "processing", since).Delete(&Log{}) + if result.Error != nil { + return fmt.Errorf("failed to cleanup old processing logs: %w", result.Error) + } + return nil +} + +// FindAll finds all log entries from the database. +func (s *RDBLogStore) FindAll(ctx context.Context, query any, fields ...string) ([]*Log, error) { + var logs []*Log + if err := s.db.WithContext(ctx).Select(fields).Where(query).Find(&logs).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []*Log{}, nil + } + return nil, err + } + return logs, nil +} + +// Close closes the log store. +func (s *RDBLogStore) Close(ctx context.Context) error { + sqlDB, err := s.db.WithContext(ctx).DB() + if err != nil { + return err + } + return sqlDB.Close() +} diff --git a/framework/logstore/sqlite.go b/framework/logstore/sqlite.go new file mode 100644 index 000000000..ffef69c32 --- /dev/null +++ b/framework/logstore/sqlite.go @@ -0,0 +1,45 @@ +package logstore + +import ( + "context" + "fmt" + "os" + + "github.com/maximhq/bifrost/core/schemas" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// SQLiteConfig represents the configuration for a SQLite database. +type SQLiteConfig struct { + Path string `json:"path"` +} + +// newSqliteLogStore creates a new SQLite log store. +func newSqliteLogStore(ctx context.Context, config *SQLiteConfig, logger schemas.Logger) (*RDBLogStore, error) { + if _, err := os.Stat(config.Path); os.IsNotExist(err) { + // Create DB file + f, err := os.Create(config.Path) + if err != nil { + return nil, err + } + _ = f.Close() + } + // Configure SQLite with proper settings to handle concurrent access + dsn := fmt.Sprintf("%s?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_busy_timeout=60000&_wal_autocheckpoint=1000&_foreign_keys=1", config.Path) + logger.Debug("opening DB with dsn: %s", dsn) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: newGormLogger(logger), + }) + + if err != nil { + return nil, err + } + logger.Debug("db opened for logstore") + s := &RDBLogStore{db: db, logger: logger} + // Run migrations + if err := triggerMigrations(ctx, db); err != nil { + return nil, err + } + return s, nil +} diff --git a/framework/logstore/store.go b/framework/logstore/store.go new file mode 100644 index 000000000..3410c063c --- /dev/null +++ b/framework/logstore/store.go @@ -0,0 +1,48 @@ +package logstore + +import ( + "context" + "fmt" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// LogStoreType represents the type of log store. +type LogStoreType string + +// LogStoreTypeSQLite is the type of log store for SQLite. +const ( + LogStoreTypeSQLite LogStoreType = "sqlite" + LogStoreTypePostgres LogStoreType = "postgres" +) + +// LogStore is the interface for the log store. +type LogStore interface { + Ping(ctx context.Context) error + Create(ctx context.Context, entry *Log) error + FindFirst(ctx context.Context, query any, fields ...string) (*Log, error) + FindAll(ctx context.Context, query any, fields ...string) ([]*Log, error) + SearchLogs(ctx context.Context, filters SearchFilters, pagination PaginationOptions) (*SearchResult, error) + Update(ctx context.Context, id string, entry any) error + Flush(ctx context.Context, since time.Time) error + Close(ctx context.Context) error +} + +// NewLogStore creates a new log store based on the configuration. +func NewLogStore(ctx context.Context,config *Config, logger schemas.Logger) (LogStore, error) { + switch config.Type { + case LogStoreTypeSQLite: + if sqliteConfig, ok := config.Config.(*SQLiteConfig); ok { + return newSqliteLogStore(ctx, sqliteConfig, logger) + } + return nil, fmt.Errorf("invalid sqlite config: %T", config.Config) + case LogStoreTypePostgres: + if postgresConfig, ok := config.Config.(*PostgresConfig); ok { + return newPostgresLogStore(ctx, postgresConfig, logger) + } + return nil, fmt.Errorf("invalid postgres config: %T", config.Config) + default: + return nil, fmt.Errorf("unsupported log store type: %s", config.Type) + } +} diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go new file mode 100644 index 000000000..3df3ccb4b --- /dev/null +++ b/framework/logstore/tables.go @@ -0,0 +1,506 @@ +package logstore + +import ( + "encoding/json" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore/tables" + "gorm.io/gorm" +) + +type SortBy string + +const ( + SortByTimestamp SortBy = "timestamp" + SortByLatency SortBy = "latency" + SortByTokens SortBy = "tokens" + SortByCost SortBy = "cost" +) + +type SortOrder string + +const ( + SortAsc SortOrder = "asc" + SortDesc SortOrder = "desc" +) + +// SearchFilters represents the available filters for log searches +type SearchFilters struct { + Providers []string `json:"providers,omitempty"` + Models []string `json:"models,omitempty"` + Status []string `json:"status,omitempty"` + Objects []string `json:"objects,omitempty"` // For filtering by request type (chat.completion, text.completion, embedding) + SelectedKeyIDs []string `json:"selected_key_ids,omitempty"` + VirtualKeyIDs []string `json:"virtual_key_ids,omitempty"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + MinLatency *float64 `json:"min_latency,omitempty"` + MaxLatency *float64 `json:"max_latency,omitempty"` + MinTokens *int `json:"min_tokens,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + MinCost *float64 `json:"min_cost,omitempty"` + MaxCost *float64 `json:"max_cost,omitempty"` + ContentSearch string `json:"content_search,omitempty"` +} + +// PaginationOptions represents pagination parameters +type PaginationOptions struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + SortBy string `json:"sort_by"` // "timestamp", "latency", "tokens", "cost" + Order string `json:"order"` // "asc", "desc" +} + +// SearchResult represents the result of a log search +type SearchResult struct { + Logs []Log `json:"logs"` + Pagination PaginationOptions `json:"pagination"` + Stats SearchStats `json:"stats"` +} + +type SearchStats struct { + TotalRequests int64 `json:"total_requests"` + SuccessRate float64 `json:"success_rate"` // Percentage of successful requests + AverageLatency float64 `json:"average_latency"` // Average latency in milliseconds + TotalTokens int64 `json:"total_tokens"` // Total tokens used + TotalCost float64 `json:"total_cost"` // Total cost in dollars +} + +// Log represents a complete log entry for a request/response cycle +// This is the GORM model with appropriate tags +type Log struct { + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + ParentRequestID *string `gorm:"type:varchar(255)" json:"parent_request_id"` + Timestamp time.Time `gorm:"index;not null" json:"timestamp"` + Object string `gorm:"type:varchar(255);index;not null;column:object_type" json:"object"` // text.completion, chat.completion, or embedding + Provider string `gorm:"type:varchar(255);index;not null" json:"provider"` + Model string `gorm:"type:varchar(255);index;not null" json:"model"` + NumberOfRetries int `gorm:"default:0" json:"number_of_retries"` + FallbackIndex int `gorm:"default:0" json:"fallback_index"` + SelectedKeyID string `gorm:"type:varchar(255)" json:"selected_key_id"` + SelectedKeyName string `gorm:"type:varchar(255)" json:"selected_key_name"` + VirtualKeyID *string `gorm:"type:varchar(255)" json:"virtual_key_id"` + VirtualKeyName *string `gorm:"type:varchar(255)" json:"virtual_key_name"` + InputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ChatMessage + ResponsesInputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ResponsesMessage + OutputMessage string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ChatMessage + ResponsesOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ResponsesMessage + EmbeddingOutput string `gorm:"type:text" json:"-"` // JSON serialized [][]float32 + Params string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ModelParameters + Tools string `gorm:"type:text" json:"-"` // JSON serialized []schemas.Tool + ToolCalls string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ToolCall (For backward compatibility, tool calls are now in the content) + SpeechInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.SpeechInput + TranscriptionInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.TranscriptionInput + SpeechOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostSpeech + TranscriptionOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostTranscribe + CacheDebug string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostCacheDebug + Latency *float64 `json:"latency,omitempty"` + TokenUsage string `gorm:"type:text" json:"-"` // JSON serialized *schemas.LLMUsage + Cost *float64 `gorm:"index" json:"cost,omitempty"` // Cost in dollars (total cost of the request - includes cache lookup cost) + Status string `gorm:"type:varchar(50);index;not null" json:"status"` // "processing", "success", or "error" + ErrorDetails string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostError + Stream bool `gorm:"default:false" json:"stream"` // true if this was a streaming response + ContentSummary string `gorm:"type:text" json:"-"` // For content search + RawResponse string `gorm:"type:text" json:"raw_response"` // Populated when `send-back-raw-response` is on + + // Denormalized token fields for easier querying + PromptTokens int `gorm:"default:0" json:"-"` + CompletionTokens int `gorm:"default:0" json:"-"` + TotalTokens int `gorm:"default:0" json:"-"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + + // Virtual fields for JSON output - these will be populated when needed + InputHistoryParsed []schemas.ChatMessage `gorm:"-" json:"input_history,omitempty"` + ResponsesInputHistoryParsed []schemas.ResponsesMessage `gorm:"-" json:"responses_input_history,omitempty"` + OutputMessageParsed *schemas.ChatMessage `gorm:"-" json:"output_message,omitempty"` + ResponsesOutputParsed []schemas.ResponsesMessage `gorm:"-" json:"responses_output,omitempty"` + EmbeddingOutputParsed []schemas.EmbeddingData `gorm:"-" json:"embedding_output,omitempty"` + ParamsParsed interface{} `gorm:"-" json:"params,omitempty"` + ToolsParsed []schemas.ChatTool `gorm:"-" json:"tools,omitempty"` + ToolCallsParsed []schemas.ChatAssistantMessageToolCall `gorm:"-" json:"tool_calls,omitempty"` // For backward compatibility, tool calls are now in the content + TokenUsageParsed *schemas.BifrostLLMUsage `gorm:"-" json:"token_usage,omitempty"` + ErrorDetailsParsed *schemas.BifrostError `gorm:"-" json:"error_details,omitempty"` + SpeechInputParsed *schemas.SpeechInput `gorm:"-" json:"speech_input,omitempty"` + TranscriptionInputParsed *schemas.TranscriptionInput `gorm:"-" json:"transcription_input,omitempty"` + SpeechOutputParsed *schemas.BifrostSpeechResponse `gorm:"-" json:"speech_output,omitempty"` + TranscriptionOutputParsed *schemas.BifrostTranscriptionResponse `gorm:"-" json:"transcription_output,omitempty"` + CacheDebugParsed *schemas.BifrostCacheDebug `gorm:"-" json:"cache_debug,omitempty"` + + // Populated in handlers after find using the virtual key id and key id + VirtualKey *tables.TableVirtualKey `gorm:"-" json:"virtual_key,omitempty"` // redacted + SelectedKey *schemas.Key `gorm:"-" json:"selected_key,omitempty"` // redacted +} + +// TableName sets the table name for GORM +func (Log) TableName() string { + return "logs" +} + +// BeforeCreate GORM hook to set created_at and serialize JSON fields +func (l *Log) BeforeCreate(tx *gorm.DB) error { + if l.CreatedAt.IsZero() { + l.CreatedAt = time.Now().UTC() + } + return l.SerializeFields() +} + +// BeforeSave GORM hook to serialize JSON fields +func (l *Log) BeforeSave(tx *gorm.DB) error { + return l.SerializeFields() +} + +// AfterFind GORM hook to deserialize JSON fields +func (l *Log) AfterFind(tx *gorm.DB) error { + return l.DeserializeFields() +} + +// SerializeFields converts Go structs to JSON strings for storage +func (l *Log) SerializeFields() error { + if l.InputHistoryParsed != nil { + if data, err := json.Marshal(l.InputHistoryParsed); err != nil { + return err + } else { + l.InputHistory = string(data) + } + } + + if l.ResponsesInputHistoryParsed != nil { + if data, err := json.Marshal(l.ResponsesInputHistoryParsed); err != nil { + return err + } else { + l.ResponsesInputHistory = string(data) + } + } + + if l.OutputMessageParsed != nil { + if data, err := json.Marshal(l.OutputMessageParsed); err != nil { + return err + } else { + l.OutputMessage = string(data) + } + } + + if l.ResponsesOutputParsed != nil { + if data, err := json.Marshal(l.ResponsesOutputParsed); err != nil { + return err + } else { + l.ResponsesOutput = string(data) + } + } + + if l.EmbeddingOutputParsed != nil { + if data, err := json.Marshal(l.EmbeddingOutputParsed); err != nil { + return err + } else { + l.EmbeddingOutput = string(data) + } + } + + if l.SpeechInputParsed != nil { + if data, err := json.Marshal(l.SpeechInputParsed); err != nil { + return err + } else { + l.SpeechInput = string(data) + } + } + + if l.TranscriptionInputParsed != nil { + if data, err := json.Marshal(l.TranscriptionInputParsed); err != nil { + return err + } else { + l.TranscriptionInput = string(data) + } + } + + if l.SpeechOutputParsed != nil { + if data, err := json.Marshal(l.SpeechOutputParsed); err != nil { + return err + } else { + l.SpeechOutput = string(data) + } + } + + if l.TranscriptionOutputParsed != nil { + if data, err := json.Marshal(l.TranscriptionOutputParsed); err != nil { + return err + } else { + l.TranscriptionOutput = string(data) + } + } + + if l.ParamsParsed != nil { + if data, err := json.Marshal(l.ParamsParsed); err != nil { + return err + } else { + l.Params = string(data) + } + } + + if l.ToolsParsed != nil { + if data, err := json.Marshal(l.ToolsParsed); err != nil { + return err + } else { + l.Tools = string(data) + } + } + + if l.ToolCallsParsed != nil { + if data, err := json.Marshal(l.ToolCallsParsed); err != nil { + return err + } else { + l.ToolCalls = string(data) + } + } + + if l.TokenUsageParsed != nil { + if data, err := json.Marshal(l.TokenUsageParsed); err != nil { + return err + } else { + l.TokenUsage = string(data) + } + // Update denormalized fields for easier querying + l.PromptTokens = l.TokenUsageParsed.PromptTokens + l.CompletionTokens = l.TokenUsageParsed.CompletionTokens + l.TotalTokens = l.TokenUsageParsed.TotalTokens + } + + if l.ErrorDetailsParsed != nil { + if data, err := json.Marshal(l.ErrorDetailsParsed); err != nil { + return err + } else { + l.ErrorDetails = string(data) + } + } + + if l.CacheDebugParsed != nil { + if data, err := json.Marshal(l.CacheDebugParsed); err != nil { + return err + } else { + l.CacheDebug = string(data) + } + } + + // Build content summary for search + l.ContentSummary = l.BuildContentSummary() + + return nil +} + +// DeserializeFields converts JSON strings back to Go structs +func (l *Log) DeserializeFields() error { + if l.InputHistory != "" { + if err := json.Unmarshal([]byte(l.InputHistory), &l.InputHistoryParsed); err != nil { + // Log error but don't fail the operation - initialize as empty slice + l.InputHistoryParsed = []schemas.ChatMessage{} + } + } + + if l.ResponsesInputHistory != "" { + if err := json.Unmarshal([]byte(l.ResponsesInputHistory), &l.ResponsesInputHistoryParsed); err != nil { + // Log error but don't fail the operation - initialize as empty slice + l.ResponsesInputHistoryParsed = []schemas.ResponsesMessage{} + } + } + + if l.OutputMessage != "" { + if err := json.Unmarshal([]byte(l.OutputMessage), &l.OutputMessageParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.OutputMessageParsed = nil + } + } + + if l.ResponsesOutput != "" { + if err := json.Unmarshal([]byte(l.ResponsesOutput), &l.ResponsesOutputParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.ResponsesOutputParsed = []schemas.ResponsesMessage{} + } + } + + if l.EmbeddingOutput != "" { + if err := json.Unmarshal([]byte(l.EmbeddingOutput), &l.EmbeddingOutputParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.EmbeddingOutputParsed = nil + } + } + + if l.Params != "" { + if err := json.Unmarshal([]byte(l.Params), &l.ParamsParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.ParamsParsed = nil + } + } + + if l.Tools != "" { + if err := json.Unmarshal([]byte(l.Tools), &l.ToolsParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.ToolsParsed = nil + } + } + + if l.ToolCalls != "" { + if err := json.Unmarshal([]byte(l.ToolCalls), &l.ToolCallsParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.ToolCallsParsed = nil + } + } + + if l.TokenUsage != "" { + if err := json.Unmarshal([]byte(l.TokenUsage), &l.TokenUsageParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.TokenUsageParsed = nil + } + } + + if l.ErrorDetails != "" { + if err := json.Unmarshal([]byte(l.ErrorDetails), &l.ErrorDetailsParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.ErrorDetailsParsed = nil + } + } + + // Deserialize speech and transcription fields + if l.SpeechInput != "" { + if err := json.Unmarshal([]byte(l.SpeechInput), &l.SpeechInputParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.SpeechInputParsed = nil + } + } + + if l.TranscriptionInput != "" { + if err := json.Unmarshal([]byte(l.TranscriptionInput), &l.TranscriptionInputParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.TranscriptionInputParsed = nil + } + } + + if l.SpeechOutput != "" { + if err := json.Unmarshal([]byte(l.SpeechOutput), &l.SpeechOutputParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.SpeechOutputParsed = nil + } + } + + if l.TranscriptionOutput != "" { + if err := json.Unmarshal([]byte(l.TranscriptionOutput), &l.TranscriptionOutputParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.TranscriptionOutputParsed = nil + } + } + + if l.CacheDebug != "" { + if err := json.Unmarshal([]byte(l.CacheDebug), &l.CacheDebugParsed); err != nil { + // Log error but don't fail the operation - initialize as nil + l.CacheDebugParsed = nil + } + } + + return nil +} + +// BuildContentSummary creates a searchable text summary +func (l *Log) BuildContentSummary() string { + var parts []string + + // Add input messages + for _, msg := range l.InputHistoryParsed { + if msg.Content != nil { + // Access content through the Content field + if msg.Content.ContentStr != nil && *msg.Content.ContentStr != "" { + parts = append(parts, *msg.Content.ContentStr) + } + // If content blocks exist, extract text from them + if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil && *block.Text != "" { + parts = append(parts, *block.Text) + } + } + } + } + } + + // Add responses input history + if l.ResponsesInputHistoryParsed != nil { + for _, msg := range l.ResponsesInputHistoryParsed { + if msg.Content != nil { + if msg.Content.ContentStr != nil && *msg.Content.ContentStr != "" { + parts = append(parts, *msg.Content.ContentStr) + } + // If content blocks exist, extract text from them + if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil && *block.Text != "" { + parts = append(parts, *block.Text) + } + } + } + } + if msg.ResponsesReasoning != nil { + for _, summary := range msg.ResponsesReasoning.Summary { + parts = append(parts, summary.Text) + } + } + } + } + + // Add output message + if l.OutputMessageParsed != nil { + if l.OutputMessageParsed.Content != nil { + if l.OutputMessageParsed.Content.ContentStr != nil && *l.OutputMessageParsed.Content.ContentStr != "" { + parts = append(parts, *l.OutputMessageParsed.Content.ContentStr) + } + // If content blocks exist, extract text from them + if l.OutputMessageParsed.Content.ContentBlocks != nil { + for _, block := range l.OutputMessageParsed.Content.ContentBlocks { + if block.Text != nil && *block.Text != "" { + parts = append(parts, *block.Text) + } + } + } + } + } + + // Add responses output content + if l.ResponsesOutputParsed != nil { + for _, msg := range l.ResponsesOutputParsed { + if msg.Content != nil { + if msg.Content.ContentStr != nil && *msg.Content.ContentStr != "" { + parts = append(parts, *msg.Content.ContentStr) + } + // If content blocks exist, extract text from them + if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil && *block.Text != "" { + parts = append(parts, *block.Text) + } + } + } + } + if msg.ResponsesReasoning != nil { + for _, summary := range msg.ResponsesReasoning.Summary { + parts = append(parts, summary.Text) + } + } + } + } + + // Add speech input content + if l.SpeechInputParsed != nil && l.SpeechInputParsed.Input != "" { + parts = append(parts, l.SpeechInputParsed.Input) + } + + // Add transcription output content + if l.TranscriptionOutputParsed != nil && l.TranscriptionOutputParsed.Text != "" { + parts = append(parts, l.TranscriptionOutputParsed.Text) + } + + // Add error details + if l.ErrorDetailsParsed != nil && l.ErrorDetailsParsed.Error.Message != "" { + parts = append(parts, l.ErrorDetailsParsed.Error.Message) + } + + return strings.Join(parts, " ") +} diff --git a/framework/migrator/migrator.go b/framework/migrator/migrator.go new file mode 100644 index 000000000..de392ad27 --- /dev/null +++ b/framework/migrator/migrator.go @@ -0,0 +1,512 @@ +// Portions of this file are derived from https://github.com/go-gormigrate/gormigrate +// MIT License +// Copyright (c) 2016 Andrey Nering +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + +package migrator + +import ( + "context" + "errors" + "fmt" + "reflect" + + "gorm.io/gorm" +) + +const ( + initSchemaMigrationID = "SCHEMA_INIT" +) + +// MigrateFunc is the func signature for migrating. +type MigrateFunc func(*gorm.DB) error + +// RollbackFunc is the func signature for rollbacking. +type RollbackFunc func(*gorm.DB) error + +// InitSchemaFunc is the func signature for initializing the schema. +type InitSchemaFunc func(*gorm.DB) error + +// Options define options for all migrations. +type Options struct { + // TableName is the migration table. + TableName string + // IDColumnName is the name of column where the migration id will be stored. + IDColumnName string + // IDColumnSize is the length of the migration id column + IDColumnSize int + // UseTransaction makes Gormigrate execute migrations inside a single transaction. + // Keep in mind that not all databases support DDL commands inside transactions. + UseTransaction bool + // ValidateUnknownMigrations will cause migrate to fail if there's unknown migration + // IDs in the database + ValidateUnknownMigrations bool +} + +// Migration represents a database migration (a modification to be made on the database). +type Migration struct { + // ID is the migration identifier. Usually a timestamp like "201601021504". + ID string + // Migrate is a function that will br executed while running this migration. + Migrate MigrateFunc + // Rollback will be executed on rollback. Can be nil. + Rollback RollbackFunc +} + +// Gormigrate represents a collection of all migrations of a database schema. +type Gormigrate struct { + db *gorm.DB + tx *gorm.DB + options *Options + migrations []*Migration + initSchema InitSchemaFunc +} + +// ReservedIDError is returned when a migration is using a reserved ID +type ReservedIDError struct { + ID string +} + +func (e *ReservedIDError) Error() string { + return fmt.Sprintf(`gormigrate: Reserved migration ID: "%s"`, e.ID) +} + +// DuplicatedIDError is returned when more than one migration have the same ID +type DuplicatedIDError struct { + ID string +} + +func (e *DuplicatedIDError) Error() string { + return fmt.Sprintf(`gormigrate: Duplicated migration ID: "%s"`, e.ID) +} + +var ( + // DefaultOptions can be used if you don't want to think about options. + DefaultOptions = &Options{ + TableName: "migrations", + IDColumnName: "id", + IDColumnSize: 255, + UseTransaction: false, + ValidateUnknownMigrations: false, + } + + // ErrRollbackImpossible is returned when trying to rollback a migration + // that has no rollback function. + ErrRollbackImpossible = errors.New("gormigrate: It's impossible to rollback this migration") + + // ErrNoMigrationDefined is returned when no migration is defined. + ErrNoMigrationDefined = errors.New("gormigrate: No migration defined") + + // ErrMissingID is returned when the ID od migration is equal to "" + ErrMissingID = errors.New("gormigrate: Missing ID in migration") + + // ErrNoRunMigration is returned when any run migration was found while + // running RollbackLast + ErrNoRunMigration = errors.New("gormigrate: Could not find last run migration") + + // ErrMigrationIDDoesNotExist is returned when migrating or rolling back to a migration ID that + // does not exist in the list of migrations + ErrMigrationIDDoesNotExist = errors.New("gormigrate: Tried to migrate to an ID that doesn't exist") + + // ErrUnknownPastMigration is returned if a migration exists in the DB that doesn't exist in the code + ErrUnknownPastMigration = errors.New("gormigrate: Found migration in DB that does not exist in code") +) + +// New returns a new Gormigrate. +func New(db *gorm.DB, options *Options, migrations []*Migration) *Gormigrate { + if options == nil { + options = DefaultOptions + } + if options.TableName == "" { + options.TableName = DefaultOptions.TableName + } + if options.IDColumnName == "" { + options.IDColumnName = DefaultOptions.IDColumnName + } + if options.IDColumnSize == 0 { + options.IDColumnSize = DefaultOptions.IDColumnSize + } + return &Gormigrate{ + db: db, + options: options, + migrations: migrations, + } +} + +// InitSchema sets a function that is run if no migration is found. +// The idea is preventing to run all migrations when a new clean database +// is being migrating. In this function you should create all tables and +// foreign key necessary to your application. +func (g *Gormigrate) InitSchema(initSchema InitSchemaFunc) { + g.initSchema = initSchema +} + +// Migrate executes all migrations that did not run yet. +func (g *Gormigrate) Migrate() error { + if !g.hasMigrations() { + return ErrNoMigrationDefined + } + var targetMigrationID string + if len(g.migrations) > 0 { + targetMigrationID = g.migrations[len(g.migrations)-1].ID + } + return g.migrate(targetMigrationID) +} + +// MigrateTo executes all migrations that did not run yet up to the migration that matches `migrationID`. +func (g *Gormigrate) MigrateTo(migrationID string) error { + if err := g.checkIDExist(migrationID); err != nil { + return err + } + return g.migrate(migrationID) +} + +func (g *Gormigrate) migrate(migrationID string) error { + if !g.hasMigrations() { + return ErrNoMigrationDefined + } + + if err := g.checkReservedID(); err != nil { + return err + } + + if err := g.checkDuplicatedID(); err != nil { + return err + } + + g.begin() + defer g.rollback() + + if err := g.createMigrationTableIfNotExists(); err != nil { + return err + } + + if g.options.ValidateUnknownMigrations { + unknownMigrations, err := g.unknownMigrationsHaveHappened() + if err != nil { + return err + } + if unknownMigrations { + return ErrUnknownPastMigration + } + } + + if g.initSchema != nil { + canInitializeSchema, err := g.canInitializeSchema() + if err != nil { + return err + } + if canInitializeSchema { + if err := g.runInitSchema(); err != nil { + return err + } + return g.commit() + } + } + + for _, migration := range g.migrations { + if err := g.runMigration(migration); err != nil { + return err + } + if migrationID != "" && migration.ID == migrationID { + break + } + } + return g.commit() +} + +// There are migrations to apply if either there's a defined +// initSchema function or if the list of migrations is not empty. +func (g *Gormigrate) hasMigrations() bool { + return g.initSchema != nil || len(g.migrations) > 0 +} + +// Check whether any migration is using a reserved ID. +// For now there's only have one reserved ID, but there may be more in the future. +func (g *Gormigrate) checkReservedID() error { + for _, m := range g.migrations { + if m.ID == initSchemaMigrationID { + return &ReservedIDError{ID: m.ID} + } + } + return nil +} + +func (g *Gormigrate) checkDuplicatedID() error { + lookup := make(map[string]struct{}, len(g.migrations)) + for _, m := range g.migrations { + if _, ok := lookup[m.ID]; ok { + return &DuplicatedIDError{ID: m.ID} + } + lookup[m.ID] = struct{}{} + } + return nil +} + +func (g *Gormigrate) checkIDExist(migrationID string) error { + for _, migrate := range g.migrations { + if migrate.ID == migrationID { + return nil + } + } + return ErrMigrationIDDoesNotExist +} + +// RollbackLast undo the last migration +func (g *Gormigrate) RollbackLast() error { + if len(g.migrations) == 0 { + return ErrNoMigrationDefined + } + + g.begin() + defer g.rollback() + + lastRunMigration, err := g.getLastRunMigration() + if err != nil { + return err + } + + if err := g.rollbackMigration(lastRunMigration); err != nil { + return err + } + return g.commit() +} + +// RollbackTo undoes migrations up to the given migration that matches the `migrationID`. +// Migration with the matching `migrationID` is not rolled back. +func (g *Gormigrate) RollbackTo(migrationID string) error { + if len(g.migrations) == 0 { + return ErrNoMigrationDefined + } + + if err := g.checkIDExist(migrationID); err != nil { + return err + } + + g.begin() + defer g.rollback() + + for i := len(g.migrations) - 1; i >= 0; i-- { + migration := g.migrations[i] + if migration.ID == migrationID { + break + } + migrationRan, err := g.migrationRan(migration) + if err != nil { + return err + } + if migrationRan { + if err := g.rollbackMigration(migration); err != nil { + return err + } + } + } + return g.commit() +} + +func (g *Gormigrate) getLastRunMigration() (*Migration, error) { + for i := len(g.migrations) - 1; i >= 0; i-- { + migration := g.migrations[i] + + migrationRan, err := g.migrationRan(migration) + if err != nil { + return nil, err + } + + if migrationRan { + return migration, nil + } + } + return nil, ErrNoRunMigration +} + +// RollbackMigration undo a migration. +func (g *Gormigrate) RollbackMigration(m *Migration) error { + g.begin() + defer g.rollback() + + if err := g.rollbackMigration(m); err != nil { + return err + } + return g.commit() +} + +func (g *Gormigrate) rollbackMigration(m *Migration) error { + if m.Rollback == nil { + return ErrRollbackImpossible + } + + if err := m.Rollback(g.tx); err != nil { + return err + } + + cond := fmt.Sprintf("%s = ?", g.options.IDColumnName) + return g.tx.Table(g.options.TableName).Where(cond, m.ID).Delete(g.model()).Error +} + +func (g *Gormigrate) runInitSchema() error { + if err := g.initSchema(g.tx); err != nil { + return err + } + if err := g.insertMigration(initSchemaMigrationID); err != nil { + return err + } + + for _, migration := range g.migrations { + if err := g.insertMigration(migration.ID); err != nil { + return err + } + } + + return nil +} + +func (g *Gormigrate) runMigration(migration *Migration) error { + if len(migration.ID) == 0 { + return ErrMissingID + } + + migrationRan, err := g.migrationRan(migration) + if err != nil { + return err + } + if !migrationRan { + if err := migration.Migrate(g.tx); err != nil { + return err + } + + if err := g.insertMigration(migration.ID); err != nil { + return err + } + } + return nil +} + +// model returns pointer to dynamically created gorm migration model struct value +// +// struct defined as { +// ID string `gorm:"primaryKey;column:;size:"` +// } +func (g *Gormigrate) model() any { + f := reflect.StructField{ + Name: reflect.ValueOf("ID").Interface().(string), + Type: reflect.TypeOf(""), + Tag: reflect.StructTag(fmt.Sprintf( + `gorm:"primaryKey;column:%s;size:%d"`, + g.options.IDColumnName, + g.options.IDColumnSize, + )), + } + structType := reflect.StructOf([]reflect.StructField{f}) + structValue := reflect.New(structType).Elem() + return structValue.Addr().Interface() +} + +func (g *Gormigrate) createMigrationTableIfNotExists() error { + if g.tx.Migrator().HasTable(g.options.TableName) { + return nil + } + return g.tx.Table(g.options.TableName).AutoMigrate(g.model()) +} + +func (g *Gormigrate) migrationRan(m *Migration) (bool, error) { + var count int64 + err := g.tx. + Table(g.options.TableName). + Where(fmt.Sprintf("%s = ?", g.options.IDColumnName), m.ID). + Count(&count). + Error + return count > 0, err +} + +// The schema can be initialised only if it hasn't been initialised yet +// and no other migration has been applied already. +func (g *Gormigrate) canInitializeSchema() (bool, error) { + migrationRan, err := g.migrationRan(&Migration{ID: initSchemaMigrationID}) + if err != nil { + return false, err + } + if migrationRan { + return false, nil + } + + // If the ID doesn't exist, we also want the list of migrations to be empty + var count int64 + err = g.tx. + Table(g.options.TableName). + Count(&count). + Error + return count == 0, err +} + +func (g *Gormigrate) unknownMigrationsHaveHappened() (bool, error) { + rows, err := g.tx.Table(g.options.TableName).Select(g.options.IDColumnName).Rows() + if err != nil { + return false, err + } + defer func() { + if err := rows.Close(); err != nil { + g.tx.Logger.Error(context.TODO(), err.Error()) + } + }() + + validIDSet := make(map[string]struct{}, len(g.migrations)+1) + validIDSet[initSchemaMigrationID] = struct{}{} + for _, migration := range g.migrations { + validIDSet[migration.ID] = struct{}{} + } + + for rows.Next() { + var pastMigrationID string + if err := rows.Scan(&pastMigrationID); err != nil { + return false, err + } + if _, ok := validIDSet[pastMigrationID]; !ok { + return true, nil + } + } + + return false, nil +} + +func (g *Gormigrate) insertMigration(id string) error { + record := g.model() + reflect.ValueOf(record).Elem().FieldByName("ID").SetString(id) + return g.tx.Table(g.options.TableName).Create(record).Error +} + +func (g *Gormigrate) begin() { + if g.options.UseTransaction { + g.tx = g.db.Begin() + } else { + g.tx = g.db + } +} + +func (g *Gormigrate) commit() error { + if g.options.UseTransaction { + return g.tx.Commit().Error + } + return nil +} + +func (g *Gormigrate) rollback() { + if g.options.UseTransaction { + g.tx.Rollback() + } +} diff --git a/framework/modelcatalog/main.go b/framework/modelcatalog/main.go new file mode 100644 index 000000000..eb28020ea --- /dev/null +++ b/framework/modelcatalog/main.go @@ -0,0 +1,376 @@ +// Package modelcatalog provides a pricing manager for the framework. +package modelcatalog + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" +) + +// Default sync interval and config key +const ( + DefaultPricingSyncInterval = 24 * time.Hour + ConfigLastPricingSyncKey = "LastModelPricingSync" + DefaultPricingURL = "https://getbifrost.ai/datasheet" + TokenTierAbove128K = 128000 +) + +// Config is the model pricing configuration. +type Config struct { + PricingURL *string `json:"pricing_url,omitempty"` + PricingSyncInterval *time.Duration `json:"pricing_sync_interval,omitempty"` +} + +type ModelCatalog struct { + configStore configstore.ConfigStore + logger schemas.Logger + + // Pricing configuration fields (protected by pricingMu) + pricingURL string + pricingSyncInterval time.Duration + pricingMu sync.RWMutex + + // In-memory cache for fast access - direct map for O(1) lookups + pricingData map[string]configstoreTables.TableModelPricing + mu sync.RWMutex + + modelPool map[schemas.ModelProvider][]string + + // Background sync worker + syncTicker *time.Ticker + done chan struct{} + wg sync.WaitGroup + syncCtx context.Context + syncCancel context.CancelFunc +} + +// PricingEntry represents a single model's pricing information +type PricingEntry struct { + // Basic pricing + InputCostPerToken float64 `json:"input_cost_per_token"` + OutputCostPerToken float64 `json:"output_cost_per_token"` + Provider string `json:"provider"` + Mode string `json:"mode"` + // Additional pricing for media + InputCostPerImage *float64 `json:"input_cost_per_image,omitempty"` + InputCostPerVideoPerSecond *float64 `json:"input_cost_per_video_per_second,omitempty"` + InputCostPerAudioPerSecond *float64 `json:"input_cost_per_audio_per_second,omitempty"` + // Character-based pricing + InputCostPerCharacter *float64 `json:"input_cost_per_character,omitempty"` + OutputCostPerCharacter *float64 `json:"output_cost_per_character,omitempty"` + // Pricing above 128k tokens + InputCostPerTokenAbove128kTokens *float64 `json:"input_cost_per_token_above_128k_tokens,omitempty"` + InputCostPerCharacterAbove128kTokens *float64 `json:"input_cost_per_character_above_128k_tokens,omitempty"` + InputCostPerImageAbove128kTokens *float64 `json:"input_cost_per_image_above_128k_tokens,omitempty"` + InputCostPerVideoPerSecondAbove128kTokens *float64 `json:"input_cost_per_video_per_second_above_128k_tokens,omitempty"` + InputCostPerAudioPerSecondAbove128kTokens *float64 `json:"input_cost_per_audio_per_second_above_128k_tokens,omitempty"` + OutputCostPerTokenAbove128kTokens *float64 `json:"output_cost_per_token_above_128k_tokens,omitempty"` + OutputCostPerCharacterAbove128kTokens *float64 `json:"output_cost_per_character_above_128k_tokens,omitempty"` + // Cache and batch pricing + CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost,omitempty"` + InputCostPerTokenBatches *float64 `json:"input_cost_per_token_batches,omitempty"` + OutputCostPerTokenBatches *float64 `json:"output_cost_per_token_batches,omitempty"` +} + +// Init initializes the pricing manager +func Init(ctx context.Context, config *Config, configStore configstore.ConfigStore, logger schemas.Logger) (*ModelCatalog, error) { + // Initialize pricing URL and sync interval + pricingURL := DefaultPricingURL + if config.PricingURL != nil { + pricingURL = *config.PricingURL + } + pricingSyncInterval := DefaultPricingSyncInterval + if config.PricingSyncInterval != nil { + pricingSyncInterval = *config.PricingSyncInterval + } + mc := &ModelCatalog{ + pricingURL: pricingURL, + pricingSyncInterval: pricingSyncInterval, + configStore: configStore, + logger: logger, + pricingData: make(map[string]configstoreTables.TableModelPricing), + modelPool: make(map[schemas.ModelProvider][]string), + done: make(chan struct{}), + } + + logger.Info("initializing pricing manager...") + if configStore != nil { + // Load initial pricing data + if err := mc.loadPricingFromDatabase(ctx); err != nil { + return nil, fmt.Errorf("failed to load initial pricing data: %w", err) + } + + // For the boot-up we sync pricing data from file to database + if err := mc.syncPricing(ctx); err != nil { + return nil, fmt.Errorf("failed to sync pricing data: %w", err) + } + } else { + // Load pricing data from config memory + if err := mc.loadPricingIntoMemory(ctx); err != nil { + return nil, fmt.Errorf("failed to load pricing data from config memory: %w", err) + } + } + + // Populate model pool with normalized providers from pricing data + mc.populateModelPoolFromPricingData() + + // Start background sync worker + mc.syncCtx, mc.syncCancel = context.WithCancel(ctx) + mc.startSyncWorker(mc.syncCtx) + mc.configStore = configStore + mc.logger = logger + + return mc, nil +} + +// ReloadPricing reloads the pricing manager from config +func (mc *ModelCatalog) ReloadPricing(ctx context.Context, config *Config) error { + // Acquire pricing mutex to update configuration atomically + mc.pricingMu.Lock() + + // Stop existing sync worker before updating configuration + if mc.syncCancel != nil { + mc.syncCancel() + } + if mc.syncTicker != nil { + mc.syncTicker.Stop() + } + + // Update pricing configuration + mc.pricingURL = DefaultPricingURL + if config.PricingURL != nil { + mc.pricingURL = *config.PricingURL + } + mc.pricingSyncInterval = DefaultPricingSyncInterval + if config.PricingSyncInterval != nil { + mc.pricingSyncInterval = *config.PricingSyncInterval + } + + // Create new sync worker with updated configuration + mc.syncCtx, mc.syncCancel = context.WithCancel(ctx) + mc.startSyncWorker(mc.syncCtx) + + mc.pricingMu.Unlock() + + // Perform immediate sync with new configuration + if err := mc.syncPricing(ctx); err != nil { + return fmt.Errorf("failed to sync pricing data: %w", err) + } + + return nil +} + +// getPricingURL returns a copy of the pricing URL under mutex protection +func (mc *ModelCatalog) getPricingURL() string { + mc.pricingMu.RLock() + defer mc.pricingMu.RUnlock() + return mc.pricingURL +} + +// getPricingSyncInterval returns a copy of the pricing sync interval under mutex protection +func (mc *ModelCatalog) getPricingSyncInterval() time.Duration { + mc.pricingMu.RLock() + defer mc.pricingMu.RUnlock() + return mc.pricingSyncInterval +} + +// GetPricingData returns the pricing data +func (mc *ModelCatalog) GetPricingEntryForModel(model string, provider schemas.ModelProvider) *PricingEntry { + mc.mu.RLock() + defer mc.mu.RUnlock() + // Check all modes + for _, mode := range []schemas.RequestType{ + schemas.TextCompletionRequest, + schemas.ChatCompletionRequest, + schemas.ResponsesRequest, + schemas.EmbeddingRequest, + schemas.SpeechRequest, + schemas.TranscriptionRequest, + } { + key := makeKey(model, string(provider), normalizeRequestType(mode)) + pricing, ok := mc.pricingData[key] + if ok { + return convertTableModelPricingToPricingData(&pricing) + } + } + return nil +} + +// GetModelsForProvider returns all available models for a given provider (thread-safe) +func (mc *ModelCatalog) GetModelsForProvider(provider schemas.ModelProvider) []string { + mc.mu.RLock() + defer mc.mu.RUnlock() + + models, exists := mc.modelPool[provider] + if !exists { + return []string{} + } + + // Return a copy to prevent external modification + result := make([]string, len(models)) + copy(result, models) + return result +} + +// GetProvidersForModel returns all providers for a given model (thread-safe) +func (mc *ModelCatalog) GetProvidersForModel(model string) []schemas.ModelProvider { + mc.mu.RLock() + defer mc.mu.RUnlock() + + providers := make([]schemas.ModelProvider, 0) + for provider, models := range mc.modelPool { + if slices.Contains(models, model) { + providers = append(providers, provider) + } + } + + // Handler special provider cases + // 1. Handler openrouter models + if !slices.Contains(providers, schemas.OpenRouter) { + for _, provider := range providers { + if openRouterModels, ok := mc.modelPool[schemas.OpenRouter]; ok { + if slices.Contains(openRouterModels, string(provider)+"/"+model) { + providers = append(providers, schemas.OpenRouter) + } + } + } + } + + // 2. Handle vertex models + if !slices.Contains(providers, schemas.Vertex) { + for _, provider := range providers { + if vertexModels, ok := mc.modelPool[schemas.Vertex]; ok { + if slices.Contains(vertexModels, string(provider)+"/"+model) { + providers = append(providers, schemas.Vertex) + } + } + } + } + + // 3. Handle openai models for groq + if !slices.Contains(providers, schemas.Groq) && strings.Contains(model, "gpt-") { + if groqModels, ok := mc.modelPool[schemas.Groq]; ok { + if slices.Contains(groqModels, "openai/"+model) { + providers = append(providers, schemas.Groq) + } + } + } + + // 4. Handle anthropic models for bedrock + if !slices.Contains(providers, schemas.Bedrock) && strings.Contains(model, "claude") { + if bedrockModels, ok := mc.modelPool[schemas.Bedrock]; ok { + for _, bedrockModel := range bedrockModels { + if strings.Contains(bedrockModel, model) { + providers = append(providers, schemas.Bedrock) + break + } + } + } + } + + return providers +} + +// AddModelDataToPool adds model data to the model pool. +func (mc *ModelCatalog) AddModelDataToPool(modelData *schemas.BifrostListModelsResponse) { + if modelData == nil { + return + } + mc.mu.Lock() + defer mc.mu.Unlock() + + for _, model := range modelData.Data { + provider, model := schemas.ParseModelString(model.ID, "") + if provider == "" { + continue + } + provider = schemas.ModelProvider(provider) + mc.modelPool[provider] = append(mc.modelPool[provider], model) + } +} + +// DeleteModelDataForProvider deletes all model data from the pool for a given provider +func (mc *ModelCatalog) DeleteModelDataForProvider(provider schemas.ModelProvider) { + mc.mu.Lock() + defer mc.mu.Unlock() + + delete(mc.modelPool, provider) +} + +// RefineModelForProvider refines the model for a given provider. +// e.g. "gpt-oss-120b" for groq provider -> "openai/gpt-oss-120b" +func (mc *ModelCatalog) RefineModelForProvider(provider schemas.ModelProvider, model string) string { + switch provider { + case schemas.Groq: + if model == "gpt-oss-120b" { + return "openai/" + model + } + } + return model +} + +// populateModelPool populates the model pool with all available models per provider (thread-safe) +func (mc *ModelCatalog) populateModelPoolFromPricingData() { + // Acquire write lock for the entire rebuild operation + mc.mu.Lock() + defer mc.mu.Unlock() + + // Clear existing model pool + mc.modelPool = make(map[schemas.ModelProvider][]string) + + // Map to track unique models per provider + providerModels := make(map[schemas.ModelProvider]map[string]bool) + + // Iterate through all pricing data to collect models per provider + for _, pricing := range mc.pricingData { + // Normalize provider before adding to model pool + normalizedProvider := schemas.ModelProvider(normalizeProvider(pricing.Provider)) + + // Initialize map for this provider if not exists + if providerModels[normalizedProvider] == nil { + providerModels[normalizedProvider] = make(map[string]bool) + } + + // Add model to the provider's model set (using map for deduplication) + providerModels[normalizedProvider][pricing.Model] = true + } + + // Convert sets to slices and assign to modelPool + for provider, modelSet := range providerModels { + models := make([]string, 0, len(modelSet)) + for model := range modelSet { + models = append(models, model) + } + mc.modelPool[provider] = models + } + + // Log the populated model pool for debugging + totalModels := 0 + for provider, models := range mc.modelPool { + totalModels += len(models) + mc.logger.Debug("populated %d models for provider %s", len(models), string(provider)) + } + mc.logger.Info("populated model pool with %d models across %d providers", totalModels, len(mc.modelPool)) +} + +// Cleanup cleans up the model catalog +func (mc *ModelCatalog) Cleanup() error { + if mc.syncCancel != nil { + mc.syncCancel() + } + if mc.syncTicker != nil { + mc.syncTicker.Stop() + } + + close(mc.done) + mc.wg.Wait() + + return nil +} diff --git a/framework/modelcatalog/pricing.go b/framework/modelcatalog/pricing.go new file mode 100644 index 000000000..a4bf30f34 --- /dev/null +++ b/framework/modelcatalog/pricing.go @@ -0,0 +1,319 @@ +package modelcatalog + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" +) + +// CalculateCost calculates the cost of a Bifrost response +func (mc *ModelCatalog) CalculateCost(result *schemas.BifrostResponse) float64 { + if result == nil { + return 0.0 + } + + var usage *schemas.BifrostLLMUsage + var audioSeconds *int + var audioTokenDetails *schemas.TranscriptionUsageInputTokenDetails + + //TODO: Detect cache and batch operations + isCacheRead := false + isBatch := false + + switch { + case result.TextCompletionResponse != nil && result.TextCompletionResponse.Usage != nil: + usage = result.TextCompletionResponse.Usage + case result.ChatResponse != nil && result.ChatResponse.Usage != nil: + usage = result.ChatResponse.Usage + case result.ResponsesResponse != nil && result.ResponsesResponse.Usage != nil: + usage = &schemas.BifrostLLMUsage{ + PromptTokens: result.ResponsesResponse.Usage.InputTokens, + CompletionTokens: result.ResponsesResponse.Usage.OutputTokens, + TotalTokens: result.ResponsesResponse.Usage.TotalTokens, + } + case result.ResponsesStreamResponse != nil && result.ResponsesStreamResponse.Response != nil && result.ResponsesStreamResponse.Response.Usage != nil: + usage = &schemas.BifrostLLMUsage{ + PromptTokens: result.ResponsesStreamResponse.Response.Usage.InputTokens, + CompletionTokens: result.ResponsesStreamResponse.Response.Usage.OutputTokens, + TotalTokens: result.ResponsesStreamResponse.Response.Usage.TotalTokens, + } + case result.EmbeddingResponse != nil && result.EmbeddingResponse.Usage != nil: + usage = result.EmbeddingResponse.Usage + case result.SpeechResponse != nil: + return 0 + case result.SpeechStreamResponse != nil && result.SpeechStreamResponse.Usage != nil: + usage = &schemas.BifrostLLMUsage{ + PromptTokens: result.SpeechStreamResponse.Usage.InputTokens, + CompletionTokens: result.SpeechStreamResponse.Usage.OutputTokens, + TotalTokens: result.SpeechStreamResponse.Usage.TotalTokens, + } + case result.TranscriptionResponse != nil && result.TranscriptionResponse.Usage != nil: + usage = &schemas.BifrostLLMUsage{} + if result.TranscriptionResponse.Usage.InputTokens != nil { + usage.PromptTokens = *result.TranscriptionResponse.Usage.InputTokens + } + if result.TranscriptionResponse.Usage.OutputTokens != nil { + usage.CompletionTokens = *result.TranscriptionResponse.Usage.OutputTokens + } + if result.TranscriptionResponse.Usage.TotalTokens != nil { + usage.TotalTokens = *result.TranscriptionResponse.Usage.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if result.TranscriptionResponse.Usage.InputTokenDetails != nil { + audioTokenDetails = &schemas.TranscriptionUsageInputTokenDetails{} + audioTokenDetails.AudioTokens = result.TranscriptionResponse.Usage.InputTokenDetails.AudioTokens + audioTokenDetails.TextTokens = result.TranscriptionResponse.Usage.InputTokenDetails.TextTokens + } + case result.TranscriptionStreamResponse != nil && result.TranscriptionStreamResponse.Usage != nil: + usage = &schemas.BifrostLLMUsage{} + if result.TranscriptionStreamResponse.Usage.InputTokens != nil { + usage.PromptTokens = *result.TranscriptionStreamResponse.Usage.InputTokens + } + if result.TranscriptionStreamResponse.Usage.OutputTokens != nil { + usage.CompletionTokens = *result.TranscriptionStreamResponse.Usage.OutputTokens + } + if result.TranscriptionStreamResponse.Usage.TotalTokens != nil { + usage.TotalTokens = *result.TranscriptionStreamResponse.Usage.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if result.TranscriptionStreamResponse.Usage.InputTokenDetails != nil { + audioTokenDetails = &schemas.TranscriptionUsageInputTokenDetails{} + audioTokenDetails.AudioTokens = result.TranscriptionStreamResponse.Usage.InputTokenDetails.AudioTokens + audioTokenDetails.TextTokens = result.TranscriptionStreamResponse.Usage.InputTokenDetails.TextTokens + } + default: + return 0 + } + + cost := 0.0 + if usage != nil || audioSeconds != nil || audioTokenDetails != nil { + extraFields := result.GetExtraFields() + cost = mc.CalculateCostFromUsage(string(extraFields.Provider), extraFields.ModelRequested, usage, extraFields.RequestType, isCacheRead, isBatch, audioSeconds, audioTokenDetails) + } + + return cost +} + +// CalculateCostWithCacheDebug calculates the cost of a Bifrost response with cache debug information +func (mc *ModelCatalog) CalculateCostWithCacheDebug(result *schemas.BifrostResponse) float64 { + if result == nil { + return 0.0 + } + cacheDebug := result.GetExtraFields().CacheDebug + if cacheDebug != nil { + if cacheDebug.CacheHit { + if cacheDebug.HitType != nil && *cacheDebug.HitType == "direct" { + return 0 + } else if cacheDebug.ProviderUsed != nil && cacheDebug.ModelUsed != nil && cacheDebug.InputTokens != nil { + return mc.CalculateCostFromUsage(*cacheDebug.ProviderUsed, *cacheDebug.ModelUsed, &schemas.BifrostLLMUsage{ + PromptTokens: *cacheDebug.InputTokens, + CompletionTokens: 0, + TotalTokens: *cacheDebug.InputTokens, + }, schemas.EmbeddingRequest, false, false, nil, nil) + } + + // Don't over-bill cache hits if fields are missing. + return 0 + } else { + baseCost := mc.CalculateCost(result) + var semanticCacheCost float64 + if cacheDebug.ProviderUsed != nil && cacheDebug.ModelUsed != nil && cacheDebug.InputTokens != nil { + semanticCacheCost = mc.CalculateCostFromUsage(*cacheDebug.ProviderUsed, *cacheDebug.ModelUsed, &schemas.BifrostLLMUsage{ + PromptTokens: *cacheDebug.InputTokens, + CompletionTokens: 0, + TotalTokens: *cacheDebug.InputTokens, + }, schemas.EmbeddingRequest, false, false, nil, nil) + } + + return baseCost + semanticCacheCost + } + } + + return mc.CalculateCost(result) +} + +// CalculateCostFromUsage calculates cost in dollars using pricing manager and usage data with conditional pricing +func (mc *ModelCatalog) CalculateCostFromUsage(provider string, model string, usage *schemas.BifrostLLMUsage, requestType schemas.RequestType, isCacheRead bool, isBatch bool, audioSeconds *int, audioTokenDetails *schemas.TranscriptionUsageInputTokenDetails) float64 { + // Allow audio-only flows by only returning early if we have no usage data at all + if usage == nil && audioSeconds == nil && audioTokenDetails == nil { + return 0.0 + } + + if usage.Cost != nil && usage.Cost.TotalCost > 0 { + return usage.Cost.TotalCost + } + + mc.logger.Debug("looking up pricing for model %s and provider %s of request type %s", model, provider, normalizeRequestType(requestType)) + // Get pricing for the model + pricing, exists := mc.getPricing(model, provider, requestType) + if !exists { + mc.logger.Debug("pricing not found for model %s and provider %s of request type %s, skipping cost calculation", model, provider, normalizeRequestType(requestType)) + return 0.0 + } + + var inputCost, outputCost float64 + + // Helper function to safely get token counts with zero defaults + safeTokenCount := func(usage *schemas.BifrostLLMUsage, getter func(*schemas.BifrostLLMUsage) int) int { + if usage == nil { + return 0 + } + return getter(usage) + } + + totalTokens := safeTokenCount(usage, func(u *schemas.BifrostLLMUsage) int { return u.TotalTokens }) + promptTokens := safeTokenCount(usage, func(u *schemas.BifrostLLMUsage) int { + return u.PromptTokens + }) + completionTokens := safeTokenCount(usage, func(u *schemas.BifrostLLMUsage) int { + return u.CompletionTokens + }) + + // Special handling for audio operations with duration-based pricing + if (requestType == schemas.SpeechRequest || requestType == schemas.TranscriptionRequest) && audioSeconds != nil && *audioSeconds > 0 { + // Determine if this is above TokenTierAbove128K for pricing tier selection + isAbove128k := totalTokens > TokenTierAbove128K + + // Use duration-based pricing for audio when available + var audioPerSecondRate *float64 + if isAbove128k && pricing.InputCostPerAudioPerSecondAbove128kTokens != nil { + audioPerSecondRate = pricing.InputCostPerAudioPerSecondAbove128kTokens + } else if pricing.InputCostPerAudioPerSecond != nil { + audioPerSecondRate = pricing.InputCostPerAudioPerSecond + } + + if audioPerSecondRate != nil { + inputCost = float64(*audioSeconds) * *audioPerSecondRate + } else { + // Fall back to token-based pricing + inputCost = float64(promptTokens) * pricing.InputCostPerToken + } + + // For audio operations, output cost is typically based on tokens (if any) + outputCost = float64(completionTokens) * pricing.OutputCostPerToken + + return inputCost + outputCost + } + + // Handle audio token details if available (for token-based audio pricing) + if audioTokenDetails != nil && (requestType == schemas.SpeechRequest || requestType == schemas.TranscriptionRequest) { + // Use audio-specific token pricing if available + audioTokens := float64(audioTokenDetails.AudioTokens) + textTokens := float64(audioTokenDetails.TextTokens) + isAbove128k := totalTokens > TokenTierAbove128K + + // Determine the appropriate token pricing rates + var inputTokenRate, outputTokenRate float64 + + if isAbove128k { + inputTokenRate = getSafeFloat64(pricing.InputCostPerTokenAbove128kTokens, pricing.InputCostPerToken) + outputTokenRate = getSafeFloat64(pricing.OutputCostPerTokenAbove128kTokens, pricing.OutputCostPerToken) + } else { + inputTokenRate = pricing.InputCostPerToken + outputTokenRate = pricing.OutputCostPerToken + } + + // Calculate costs using token-based pricing with audio/text breakdown + inputCost = audioTokens*inputTokenRate + textTokens*inputTokenRate + outputCost = float64(completionTokens) * outputTokenRate + + return inputCost + outputCost + } + + // Use conditional pricing based on request characteristics + if isBatch { + // Use batch pricing if available, otherwise fall back to regular pricing + if pricing.InputCostPerTokenBatches != nil { + inputCost = float64(promptTokens) * *pricing.InputCostPerTokenBatches + } else { + inputCost = float64(promptTokens) * pricing.InputCostPerToken + } + + if pricing.OutputCostPerTokenBatches != nil { + outputCost = float64(completionTokens) * *pricing.OutputCostPerTokenBatches + } else { + outputCost = float64(completionTokens) * pricing.OutputCostPerToken + } + } else if isCacheRead { + // Use cache read pricing for input tokens if available, regular pricing for output + if pricing.CacheReadInputTokenCost != nil { + inputCost = float64(promptTokens) * *pricing.CacheReadInputTokenCost + } else { + inputCost = float64(promptTokens) * pricing.InputCostPerToken + } + + // Output tokens always use regular pricing for cache reads + outputCost = float64(completionTokens) * pricing.OutputCostPerToken + } else { + // Use regular pricing + inputCost = float64(promptTokens) * pricing.InputCostPerToken + outputCost = float64(completionTokens) * pricing.OutputCostPerToken + } + + totalCost := inputCost + outputCost + + return totalCost +} + +// getPricing returns pricing information for a model (thread-safe) +func (mc *ModelCatalog) getPricing(model, provider string, requestType schemas.RequestType) (*configstoreTables.TableModelPricing, bool) { + mc.mu.RLock() + defer mc.mu.RUnlock() + + pricing, ok := mc.pricingData[makeKey(model, provider, normalizeRequestType(requestType))] + if !ok { + // Lookup in vertex if gemini not found + if provider == string(schemas.Gemini) { + mc.logger.Debug("primary lookup failed, trying vertex provider for the same model") + pricing, ok = mc.pricingData[makeKey(model, "vertex", normalizeRequestType(requestType))] + if ok { + return &pricing, true + } + + // Lookup in chat if responses not found + if requestType == schemas.ResponsesRequest || requestType == schemas.ResponsesStreamRequest { + mc.logger.Debug("secondary lookup failed, trying vertex provider for the same model in chat completion") + pricing, ok = mc.pricingData[makeKey(model, "vertex", normalizeRequestType(schemas.ChatCompletionRequest))] + if ok { + return &pricing, true + } + } + } + + if provider == string(schemas.Vertex) { + // Vertex models can be of the form "provider/model", so try to lookup the model without the provider prefix and keep the original provider + if strings.Contains(model, "/") { + modelWithoutProvider := strings.SplitN(model, "/", 2)[1] + mc.logger.Debug("primary lookup failed, trying vertex provider for the same model with provider/model format %s", modelWithoutProvider) + pricing, ok = mc.pricingData[makeKey(modelWithoutProvider, "vertex", normalizeRequestType(requestType))] + if ok { + return &pricing, true + } + + // Lookup in chat if responses not found + if requestType == schemas.ResponsesRequest || requestType == schemas.ResponsesStreamRequest { + mc.logger.Debug("secondary lookup failed, trying vertex provider for the same model in chat completion") + pricing, ok = mc.pricingData[makeKey(modelWithoutProvider, "vertex", normalizeRequestType(schemas.ChatCompletionRequest))] + if ok { + return &pricing, true + } + } + } + } + + // Lookup in chat if responses not found + if requestType == schemas.ResponsesRequest || requestType == schemas.ResponsesStreamRequest { + mc.logger.Debug("primary lookup failed, trying chat provider for the same model in chat completion") + pricing, ok = mc.pricingData[makeKey(model, provider, normalizeRequestType(schemas.ChatCompletionRequest))] + if ok { + return &pricing, true + } + } + + return nil, false + } + return &pricing, true +} diff --git a/framework/modelcatalog/sync.go b/framework/modelcatalog/sync.go new file mode 100644 index 000000000..59a717f8f --- /dev/null +++ b/framework/modelcatalog/sync.go @@ -0,0 +1,245 @@ +package modelcatalog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "gorm.io/gorm" +) + +// checkAndSyncPricing determines if pricing data needs to be synced and performs the sync if needed. +// It syncs pricing data in the following scenarios: +// - No config store available (returns early with no error) +// - No previous sync record exists +// - Previous sync timestamp is invalid/corrupted +// - Sync interval has elapsed since last successful sync +func (mc *ModelCatalog) checkAndSyncPricing(ctx context.Context) error { + // Skip sync if no config store is available + if mc.configStore == nil { + return nil + } + + // Determine if sync is needed and perform it + needsSync, reason := mc.shouldSyncPricing(ctx) + if needsSync { + mc.logger.Debug("pricing sync needed: %s", reason) + return mc.syncPricing(ctx) + } + + return nil +} + +// shouldSyncPricing determines if pricing data should be synced and returns the reason +func (mc *ModelCatalog) shouldSyncPricing(ctx context.Context) (bool, string) { + config, err := mc.configStore.GetConfig(ctx, ConfigLastPricingSyncKey) + if err != nil { + return true, "no previous sync record found" + } + + lastSync, err := time.Parse(time.RFC3339, config.Value) + if err != nil { + mc.logger.Warn("invalid last sync timestamp: %v", err) + return true, "corrupted sync timestamp" + } + + if time.Since(lastSync) >= mc.getPricingSyncInterval() { + return true, "sync interval elapsed" + } + + return false, "sync not needed" +} + +// syncPricing syncs pricing data from URL to database and updates cache +func (mc *ModelCatalog) syncPricing(ctx context.Context) error { + mc.logger.Debug("starting pricing data synchronization for governance") + + // Load pricing data from URL + pricingData, err := mc.loadPricingFromURL(ctx) + if err != nil { + // Check if we have existing data in database + pricingRecords, pricingErr := mc.configStore.GetModelPrices(ctx) + if pricingErr != nil { + return fmt.Errorf("failed to get pricing records: %w", pricingErr) + } + if len(pricingRecords) > 0 { + mc.logger.Error("failed to load pricing data from URL, but existing data found in database: %v", err) + return nil + } else { + return fmt.Errorf("failed to load pricing data from URL and no existing data in database: %w", err) + } + } + + // Update database in transaction + err = mc.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Clear existing pricing data + if err := mc.configStore.DeleteModelPrices(ctx, tx); err != nil { + return fmt.Errorf("failed to clear existing pricing data: %v", err) + } + + // Deduplicate and insert new pricing data + seen := make(map[string]bool) + for modelKey, entry := range pricingData { + pricing := convertPricingDataToTableModelPricing(modelKey, entry) + + // Create composite key for deduplication + key := makeKey(pricing.Model, pricing.Provider, pricing.Mode) + + // Skip if already seen + if exists, ok := seen[key]; ok && exists { + continue + } + + // Mark as seen + seen[key] = true + + if err := mc.configStore.CreateModelPrices(ctx, &pricing, tx); err != nil { + return fmt.Errorf("failed to create pricing record for model %s: %w", pricing.Model, err) + } + } + + // Clear seen map + seen = nil + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to sync pricing data to database: %w", err) + } + + config := &configstoreTables.TableGovernanceConfig{ + Key: ConfigLastPricingSyncKey, + Value: time.Now().Format(time.RFC3339), + } + + // Update last sync time + if err := mc.configStore.UpdateConfig(ctx, config); err != nil { + mc.logger.Warn("Failed to update last sync time: %v", err) + } + + // Reload cache from database + if err := mc.loadPricingFromDatabase(ctx); err != nil { + return fmt.Errorf("failed to reload pricing cache: %w", err) + } + + mc.logger.Info("successfully synced %d pricing records", len(pricingData)) + return nil +} + +// loadPricingFromURL loads pricing data from the remote URL +func (mc *ModelCatalog) loadPricingFromURL(ctx context.Context) (map[string]PricingEntry, error) { + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 30 * time.Second, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mc.getPricingURL(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + // Make HTTP request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download pricing data: %w", err) + } + defer resp.Body.Close() + + // Check HTTP status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download pricing data: HTTP %d", resp.StatusCode) + } + + // Read response body + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read pricing data response: %w", err) + } + + // Unmarshal JSON data + var pricingData map[string]PricingEntry + if err := json.Unmarshal(data, &pricingData); err != nil { + return nil, fmt.Errorf("failed to unmarshal pricing data: %w", err) + } + + mc.logger.Debug("successfully downloaded and parsed %d pricing records", len(pricingData)) + return pricingData, nil +} + +// loadPricingIntoMemory loads pricing data from URL into memory cache +func (mc *ModelCatalog) loadPricingIntoMemory(ctx context.Context) error { + pricingData, err := mc.loadPricingFromURL(ctx) + if err != nil { + return fmt.Errorf("failed to load pricing data from URL: %w", err) + } + + mc.mu.Lock() + defer mc.mu.Unlock() + + // Clear and rebuild the pricing map + mc.pricingData = make(map[string]configstoreTables.TableModelPricing, len(pricingData)) + for modelKey, entry := range pricingData { + pricing := convertPricingDataToTableModelPricing(modelKey, entry) + key := makeKey(pricing.Model, pricing.Provider, pricing.Mode) + mc.pricingData[key] = pricing + } + + return nil +} + +// loadPricingFromDatabase loads pricing data from database into memory cache +func (mc *ModelCatalog) loadPricingFromDatabase(ctx context.Context) error { + if mc.configStore == nil { + return nil + } + + pricingRecords, err := mc.configStore.GetModelPrices(ctx) + if err != nil { + return fmt.Errorf("failed to load pricing from database: %w", err) + } + + mc.mu.Lock() + defer mc.mu.Unlock() + + // Clear and rebuild the pricing map + mc.pricingData = make(map[string]configstoreTables.TableModelPricing, len(pricingRecords)) + for _, pricing := range pricingRecords { + key := makeKey(pricing.Model, pricing.Provider, pricing.Mode) + mc.pricingData[key] = pricing + } + + mc.logger.Debug("loaded %d pricing records into cache", len(pricingRecords)) + return nil +} + +// startSyncWorker starts the background sync worker +func (mc *ModelCatalog) startSyncWorker(ctx context.Context) { + // Use a ticker that checks every hour, but only sync when needed + mc.syncTicker = time.NewTicker(1 * time.Hour) + mc.wg.Add(1) + go mc.syncWorker(ctx) +} + +// syncWorker runs the background sync check +func (mc *ModelCatalog) syncWorker(ctx context.Context) { + defer mc.wg.Done() + defer mc.syncTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-mc.syncTicker.C: + // Check and sync pricing data - this handles the sync internally + if err := mc.checkAndSyncPricing(ctx); err != nil { + mc.logger.Error("background pricing sync failed: %v", err) + } + + case <-mc.done: + return + } + } +} diff --git a/framework/modelcatalog/utils.go b/framework/modelcatalog/utils.go new file mode 100644 index 000000000..399913662 --- /dev/null +++ b/framework/modelcatalog/utils.go @@ -0,0 +1,153 @@ +package modelcatalog + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" +) + +// makeKey creates a unique key for a model, provider, and mode for pricingData map +func makeKey(model, provider, mode string) string { return model + "|" + provider + "|" + mode } + +// isBatchRequest checks if the request is for batch processing +func isBatchRequest(req *schemas.BifrostRequest) bool { + // Check for batch endpoints or batch-specific headers + // This could be detected via specific endpoint patterns or headers + // For now, return false + return false +} + +// isCacheReadRequest checks if the request involves cache reading +func isCacheReadRequest(req *schemas.BifrostRequest, headers map[string]string) bool { + // Check for cache-related headers or request parameters + if cacheHeader := headers["x-cache-read"]; cacheHeader == "true" { + return true + } + + // Check for anthropic cache headers + if cacheControl := headers["anthropic-beta"]; cacheControl != "" { + return true + } + + // TODO: Add message-level cache control detection when ChatMessage schema supports it + // For now, cache detection relies on headers only + + return false +} + +// normalizeProvider normalizes the provider name to a consistent format +func normalizeProvider(p string) string { + if strings.Contains(p, "vertex_ai") || p == "google-vertex" { + return string(schemas.Vertex) + } else { + return p + } +} + +// normalizeRequestType normalizes the request type to a consistent format +func normalizeRequestType(reqType schemas.RequestType) string { + baseType := "unknown" + + switch reqType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + baseType = "completion" + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + baseType = "chat" + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + baseType = "responses" + case schemas.EmbeddingRequest: + baseType = "embedding" + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + baseType = "audio_speech" + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + baseType = "audio_transcription" + } + + // TODO: Check for batch processing indicators + // if isBatchRequest(reqType) { + // return baseType + "_batch" + // } + + return baseType +} + +// convertPricingDataToTableModelPricing converts the pricing data to a TableModelPricing struct +func convertPricingDataToTableModelPricing(modelKey string, entry PricingEntry) configstoreTables.TableModelPricing { + provider := normalizeProvider(entry.Provider) + + // Handle provider/model format - extract just the model name + modelName := modelKey + if strings.Contains(modelKey, "/") { + parts := strings.Split(modelKey, "/") + if len(parts) > 1 { + modelName = strings.Join(parts[1:], "/") + } + } + + pricing := configstoreTables.TableModelPricing{ + Model: modelName, + Provider: provider, + InputCostPerToken: entry.InputCostPerToken, + OutputCostPerToken: entry.OutputCostPerToken, + Mode: entry.Mode, + + // Additional pricing for media + InputCostPerImage: entry.InputCostPerImage, + InputCostPerVideoPerSecond: entry.InputCostPerVideoPerSecond, + InputCostPerAudioPerSecond: entry.InputCostPerAudioPerSecond, + + // Character-based pricing + InputCostPerCharacter: entry.InputCostPerCharacter, + OutputCostPerCharacter: entry.OutputCostPerCharacter, + + // Pricing above 128k tokens + InputCostPerTokenAbove128kTokens: entry.InputCostPerTokenAbove128kTokens, + InputCostPerCharacterAbove128kTokens: entry.InputCostPerCharacterAbove128kTokens, + InputCostPerImageAbove128kTokens: entry.InputCostPerImageAbove128kTokens, + InputCostPerVideoPerSecondAbove128kTokens: entry.InputCostPerVideoPerSecondAbove128kTokens, + InputCostPerAudioPerSecondAbove128kTokens: entry.InputCostPerAudioPerSecondAbove128kTokens, + OutputCostPerTokenAbove128kTokens: entry.OutputCostPerTokenAbove128kTokens, + OutputCostPerCharacterAbove128kTokens: entry.OutputCostPerCharacterAbove128kTokens, + + // Cache and batch pricing + CacheReadInputTokenCost: entry.CacheReadInputTokenCost, + InputCostPerTokenBatches: entry.InputCostPerTokenBatches, + OutputCostPerTokenBatches: entry.OutputCostPerTokenBatches, + } + + return pricing +} + +// convertTableModelPricingToPricingData converts the TableModelPricing struct to a DataSheetPricingEntry struct +func convertTableModelPricingToPricingData(pricing *configstoreTables.TableModelPricing) *PricingEntry { + return &PricingEntry{ + Provider: pricing.Provider, + Mode: pricing.Mode, + InputCostPerToken: pricing.InputCostPerToken, + OutputCostPerToken: pricing.OutputCostPerToken, + InputCostPerImage: pricing.InputCostPerImage, + InputCostPerVideoPerSecond: pricing.InputCostPerVideoPerSecond, + InputCostPerAudioPerSecond: pricing.InputCostPerAudioPerSecond, + InputCostPerCharacter: pricing.InputCostPerCharacter, + OutputCostPerCharacter: pricing.OutputCostPerCharacter, + InputCostPerTokenAbove128kTokens: pricing.InputCostPerTokenAbove128kTokens, + InputCostPerCharacterAbove128kTokens: pricing.InputCostPerCharacterAbove128kTokens, + InputCostPerImageAbove128kTokens: pricing.InputCostPerImageAbove128kTokens, + InputCostPerVideoPerSecondAbove128kTokens: pricing.InputCostPerVideoPerSecondAbove128kTokens, + InputCostPerAudioPerSecondAbove128kTokens: pricing.InputCostPerAudioPerSecondAbove128kTokens, + OutputCostPerTokenAbove128kTokens: pricing.OutputCostPerTokenAbove128kTokens, + OutputCostPerCharacterAbove128kTokens: pricing.OutputCostPerCharacterAbove128kTokens, + CacheReadInputTokenCost: pricing.CacheReadInputTokenCost, + InputCostPerTokenBatches: pricing.InputCostPerTokenBatches, + OutputCostPerTokenBatches: pricing.OutputCostPerTokenBatches, + } +} + +// getSafeFloat64 returns the value of a float64 pointer or fallback if nil +func getSafeFloat64(ptr *float64, fallback float64) float64 { + if ptr != nil { + return *ptr + } + return fallback +} diff --git a/framework/plugins/dynamicplugin.go b/framework/plugins/dynamicplugin.go new file mode 100644 index 000000000..abee0b005 --- /dev/null +++ b/framework/plugins/dynamicplugin.go @@ -0,0 +1,175 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "plugin" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// DynamicPlugin is the interface for a dynamic plugin +type DynamicPlugin struct { + Enabled bool + Path string + + Config any + + filename string + plugin *plugin.Plugin + + getName func() string + transportInterceptor func(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) + preHook func(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) + postHook func(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) + cleanup func() error +} + +// GetName returns the name of the plugin +func (dp *DynamicPlugin) GetName() string { + return dp.getName() +} + +// TransportInterceptor is not used for dynamic plugins +func (dp *DynamicPlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return dp.transportInterceptor(ctx, url, headers, body) +} + +// PreHook is not used for dynamic plugins +func (dp *DynamicPlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + return dp.preHook(ctx, req) +} + +// PostHook is not used for dynamic plugins +func (dp *DynamicPlugin) PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + return dp.postHook(ctx, resp, bifrostErr) +} + +// Cleanup is not used for dynamic plugins +func (dp *DynamicPlugin) Cleanup() error { + return dp.cleanup() +} + +// loadDynamicPlugin loads a dynamic plugin from a path +func loadDynamicPlugin(path string, config any) (schemas.Plugin, error) { + dp := &DynamicPlugin{ + Path: path, + } + // Checking if path is URL or file path + if strings.HasPrefix(dp.Path, "http") { + // Download the file + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + response := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(response) + + req.SetRequestURI(dp.Path) + req.Header.SetMethod(fasthttp.MethodGet) + req.Header.Set("Accept", "application/octet-stream") + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + err := fasthttp.DoTimeout(req, response, 120*time.Second) + if err != nil { + return nil, err + } + if response.StatusCode() != fasthttp.StatusOK { + return nil, fmt.Errorf("failed to download plugin: %d", response.StatusCode()) + } + // Create a unique temporary file for the plugin + tempFile, err := os.CreateTemp(os.TempDir(), "bifrost-plugin-*.so") + if err != nil { + return nil, fmt.Errorf("failed to create temporary file: %w", err) + } + tempPath := tempFile.Name() + // Write the downloaded body to the temporary file + _, err = tempFile.Write(response.Body()) + if err != nil { + tempFile.Close() + os.Remove(tempPath) + return nil, fmt.Errorf("failed to write plugin to temporary file: %w", err) + } + // Close the file + err = tempFile.Close() + if err != nil { + os.Remove(tempPath) + return nil, fmt.Errorf("failed to close temporary file: %w", err) + } + // Set file permissions to be executable + err = os.Chmod(tempPath, 0755) + if err != nil { + os.Remove(tempPath) + return nil, fmt.Errorf("failed to set executable permissions on plugin: %w", err) + } + dp.Path = tempPath + } + plugin, err := plugin.Open(dp.Path) + if err != nil { + return nil, err + } + ok := false + // Looking up for optional Init method + initSym, err := plugin.Lookup("Init") + if err != nil { + if strings.Contains(err.Error(), "symbol Init not found") { + initSym = nil + } else { + return nil, err + } + } + if initSym != nil { + initFunc, ok := initSym.(func(config any) error) + if !ok { + return nil, fmt.Errorf("failed to cast Init to func(config any) error") + } + err := initFunc(config) + if err != nil { + return nil, err + } + } + // Looking up for GetName method + getNameSym, err := plugin.Lookup("GetName") + if err != nil { + return nil, err + } + if dp.getName, ok = getNameSym.(func() string); !ok { + return nil, fmt.Errorf("failed to cast GetName to func() string") + } + // Looking up for TransportInterceptor method + transportInterceptorSym, err := plugin.Lookup("TransportInterceptor") + if err != nil { + return nil, err + } + if dp.transportInterceptor, ok = transportInterceptorSym.(func(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error)); !ok { + return nil, fmt.Errorf("failed to cast TransportInterceptor to func(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error)") + } + // Looking up for PreHook method + preHookSym, err := plugin.Lookup("PreHook") + if err != nil { + return nil, err + } + if dp.preHook, ok = preHookSym.(func(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error)); !ok { + return nil, fmt.Errorf("failed to cast PreHook to func(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error)") + } + // Looking up for PostHook method + postHookSym, err := plugin.Lookup("PostHook") + if err != nil { + return nil, err + } + if dp.postHook, ok = postHookSym.(func(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error)); !ok { + return nil, fmt.Errorf("failed to cast PostHook to func(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error)") + } + // Looking up for Cleanup method + cleanupSym, err := plugin.Lookup("Cleanup") + if err != nil { + return nil, err + } + if dp.cleanup, ok = cleanupSym.(func() error); !ok { + return nil, fmt.Errorf("failed to cast Cleanup to func() error") + } + dp.plugin = plugin + return dp, nil +} diff --git a/framework/plugins/dynamicplugin_test.go b/framework/plugins/dynamicplugin_test.go new file mode 100644 index 000000000..716f43b0d --- /dev/null +++ b/framework/plugins/dynamicplugin_test.go @@ -0,0 +1,541 @@ +package plugins + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + helloWorldPluginDir = "../../examples/plugins/hello-world" + helloWorldBuildDir = "../../examples/plugins/hello-world/build" +) + +// TestDynamicPluginLifecycle tests the complete lifecycle of a dynamic plugin +func TestDynamicPluginLifecycle(t *testing.T) { + // Build the hello-world plugin first + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + // Test loading the plugin + config := &Config{ + Plugins: []DynamicPluginConfig{ + { + Path: pluginPath, + Name: "hello-world", + Enabled: true, + Config: map[string]interface{}{"test": "config"}, + }, + }, + } + + plugins, err := LoadPlugins(config) + require.NoError(t, err, "Failed to load plugins") + require.Len(t, plugins, 1, "Expected exactly one plugin to be loaded") + + plugin := plugins[0] + + // Test GetName + t.Run("GetName", func(t *testing.T) { + name := plugin.GetName() + assert.Equal(t, "Hello World Plugin", name, "Plugin name should match") + }) + + // Test TransportInterceptor + t.Run("TransportInterceptor", func(t *testing.T) { + ctx := context.Background() + url := "http://example.com/api" + headers := map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer token123", + } + body := map[string]any{ + "model": "gpt-4", + "messages": []map[string]string{ + {"role": "user", "content": "Hello"}, + }, + } + + modifiedHeaders, modifiedBody, err := plugin.TransportInterceptor(&ctx, url, headers, body) + require.NoError(t, err, "TransportInterceptor should not return error") + assert.Equal(t, headers, modifiedHeaders, "Headers should be unchanged") + assert.Equal(t, body, modifiedBody, "Body should be unchanged") + }) + + // Test PreHook + t.Run("PreHook", func(t *testing.T) { + ctx := context.Background() + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: "openai", + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: stringPtr("Hello"), + }, + }, + }, + }, + } + + modifiedReq, shortCircuit, err := plugin.PreHook(&ctx, req) + require.NoError(t, err, "PreHook should not return error") + assert.Nil(t, shortCircuit, "PreHook should not return short circuit") + assert.Equal(t, req, modifiedReq, "Request should be unchanged") + }) + + // Test PostHook + t.Run("PostHook", func(t *testing.T) { + ctx := context.Background() + resp := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: "test-id", + Model: "gpt-4", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: stringPtr("Hello! How can I help you?"), + }, + }, + }, + }, + }, + }, + } + bifrostErr := (*schemas.BifrostError)(nil) + + modifiedResp, modifiedErr, err := plugin.PostHook(&ctx, resp, bifrostErr) + require.NoError(t, err, "PostHook should not return error") + assert.Equal(t, resp, modifiedResp, "Response should be unchanged") + assert.Equal(t, bifrostErr, modifiedErr, "Error should be unchanged") + }) + + // Test PostHook with error + t.Run("PostHook_WithError", func(t *testing.T) { + ctx := context.Background() + statusCode := 500 + bifrostErr := &schemas.BifrostError{ + StatusCode: &statusCode, + Error: &schemas.ErrorField{ + Message: "Test error", + }, + } + + modifiedResp, modifiedErr, err := plugin.PostHook(&ctx, nil, bifrostErr) + require.NoError(t, err, "PostHook should not return error") + assert.Nil(t, modifiedResp, "Response should be nil") + assert.Equal(t, bifrostErr, modifiedErr, "Error should be unchanged") + }) + + // Test Cleanup + t.Run("Cleanup", func(t *testing.T) { + err := plugin.Cleanup() + assert.NoError(t, err, "Cleanup should not return error") + }) +} + +// TestLoadPlugins_DisabledPlugin tests that disabled plugins are not loaded +func TestLoadPlugins_DisabledPlugin(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + config := &Config{ + Plugins: []DynamicPluginConfig{ + { + Path: pluginPath, + Name: "hello-world", + Enabled: false, // Plugin is disabled + Config: nil, + }, + }, + } + + plugins, err := LoadPlugins(config) + require.NoError(t, err, "LoadPlugins should not error for disabled plugins") + assert.Len(t, plugins, 0, "No plugins should be loaded when all are disabled") +} + +// TestLoadPlugins_MultiplePlugins tests loading multiple plugins +func TestLoadPlugins_MultiplePlugins(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + config := &Config{ + Plugins: []DynamicPluginConfig{ + { + Path: pluginPath, + Name: "hello-world-1", + Enabled: true, + Config: nil, + }, + { + Path: pluginPath, + Name: "hello-world-2", + Enabled: true, + Config: map[string]interface{}{"key": "value"}, + }, + }, + } + + plugins, err := LoadPlugins(config) + require.NoError(t, err, "LoadPlugins should succeed for multiple plugins") + assert.Len(t, plugins, 2, "Two plugins should be loaded") + + for _, plugin := range plugins { + assert.Equal(t, "Hello World Plugin", plugin.GetName()) + } +} + +// TestLoadPlugins_InvalidPath tests loading a plugin with invalid path +func TestLoadPlugins_InvalidPath(t *testing.T) { + config := &Config{ + Plugins: []DynamicPluginConfig{ + { + Path: "/nonexistent/path/plugin.so", + Name: "invalid-plugin", + Enabled: true, + Config: nil, + }, + }, + } + + plugins, err := LoadPlugins(config) + assert.Error(t, err, "LoadPlugins should return error for invalid path") + assert.Nil(t, plugins, "No plugins should be loaded on error") +} + +// TestLoadPlugins_EmptyConfig tests loading plugins with empty config +func TestLoadPlugins_EmptyConfig(t *testing.T) { + config := &Config{ + Plugins: []DynamicPluginConfig{}, + } + + plugins, err := LoadPlugins(config) + require.NoError(t, err, "LoadPlugins should succeed with empty config") + assert.Len(t, plugins, 0, "No plugins should be loaded with empty config") +} + +// TestDynamicPlugin_ContextPropagation tests that context is properly propagated +func TestDynamicPlugin_ContextPropagation(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(t, err, "Failed to load plugin") + + // Create a context with a value + ctx := context.WithValue(context.Background(), "test-key", "test-value") + + // Test PreHook with context + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: "openai", + Model: "gpt-4", + }, + } + _, _, err = plugin.PreHook(&ctx, req) + require.NoError(t, err, "PreHook should succeed with context") + + // Test PostHook with context + resp := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: "test-id", + Model: "gpt-4", + }, + } + _, _, err = plugin.PostHook(&ctx, resp, nil) + require.NoError(t, err, "PostHook should succeed with context") +} + +// TestDynamicPlugin_ConcurrentCalls tests concurrent plugin calls +func TestDynamicPlugin_ConcurrentCalls(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(t, err, "Failed to load plugin") + + // Run multiple goroutines calling plugin methods + const numGoroutines = 10 + done := make(chan bool, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer func() { done <- true }() + + ctx := context.Background() + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: "openai", + Model: "gpt-4", + }, + } + + // Call PreHook + _, _, err := plugin.PreHook(&ctx, req) + assert.NoError(t, err, "PreHook should succeed in goroutine %d", id) + + // Call PostHook + resp := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: "test-id", + Model: "gpt-4", + }, + } + _, _, err = plugin.PostHook(&ctx, resp, nil) + assert.NoError(t, err, "PostHook should succeed in goroutine %d", id) + + // Call GetName + name := plugin.GetName() + assert.Equal(t, "Hello World Plugin", name, "GetName should return correct name in goroutine %d", id) + }(i) + } + + // Wait for all goroutines to complete + for i := 0; i < numGoroutines; i++ { + <-done + } +} + +// Helper function to build the hello-world plugin +func buildHelloWorldPlugin(t *testing.T) string { + t.Helper() + + // Get absolute path to the hello-world plugin directory + absPluginDir, err := filepath.Abs(helloWorldPluginDir) + require.NoError(t, err, "Failed to get absolute path") + + // Determine plugin extension based on OS + pluginExt := ".so" + if runtime.GOOS == "windows" { + pluginExt = ".dll" + } + + // Build the plugin using make + cmd := exec.Command("make", "build") + cmd.Dir = absPluginDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Make output: %s", string(output)) + require.NoError(t, err, "Failed to build hello-world plugin") + } + + // Verify the plugin was built + pluginPath := filepath.Join(absPluginDir, "build", "hello-world"+pluginExt) + _, err = os.Stat(pluginPath) + require.NoError(t, err, "Plugin file should exist after build") + + return pluginPath +} + +// Helper function to clean up the hello-world plugin build +func cleanupHelloWorldPlugin(t *testing.T) { + t.Helper() + + absPluginDir, err := filepath.Abs(helloWorldPluginDir) + if err != nil { + t.Logf("Failed to get absolute path for cleanup: %v", err) + return + } + + cmd := exec.Command("make", "clean") + cmd.Dir = absPluginDir + if err := cmd.Run(); err != nil { + t.Logf("Failed to clean hello-world plugin: %v", err) + } +} + +// TestLoadDynamicPlugin_DirectCall tests loading a plugin directly +func TestLoadDynamicPlugin_DirectCall(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + plugin, err := loadDynamicPlugin(pluginPath, map[string]interface{}{ + "test": "config", + }) + require.NoError(t, err, "loadDynamicPlugin should succeed") + assert.NotNil(t, plugin, "Plugin should not be nil") + + // Verify it's a DynamicPlugin + dynamicPlugin, ok := plugin.(*DynamicPlugin) + assert.True(t, ok, "Plugin should be a DynamicPlugin") + assert.Equal(t, pluginPath, dynamicPlugin.Path) +} + +// TestDynamicPlugin_NilConfig tests loading a plugin with nil config +func TestDynamicPlugin_NilConfig(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(t, err, "loadDynamicPlugin should succeed with nil config") + assert.NotNil(t, plugin, "Plugin should not be nil") + + // Verify plugin works correctly + name := plugin.GetName() + assert.Equal(t, "Hello World Plugin", name) +} + +// TestDynamicPlugin_ShortCircuitNil tests that nil short circuit is handled properly +func TestDynamicPlugin_ShortCircuitNil(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(t, err, "Failed to load plugin") + + ctx := context.Background() + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: "openai", + Model: "gpt-4", + }, + } + + modifiedReq, shortCircuit, err := plugin.PreHook(&ctx, req) + require.NoError(t, err, "PreHook should succeed") + assert.Nil(t, shortCircuit, "Short circuit should be nil") + assert.NotNil(t, modifiedReq, "Modified request should not be nil") +} + +// BenchmarkDynamicPlugin_PreHook benchmarks the PreHook method +func BenchmarkDynamicPlugin_PreHook(b *testing.B) { + pluginPath := buildHelloWorldPluginForBenchmark(b) + defer cleanupHelloWorldPluginForBenchmark(b) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(b, err, "Failed to load plugin") + + ctx := context.Background() + req := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: "openai", + Model: "gpt-4", + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PreHook(&ctx, req) + } +} + +// BenchmarkDynamicPlugin_PostHook benchmarks the PostHook method +func BenchmarkDynamicPlugin_PostHook(b *testing.B) { + pluginPath := buildHelloWorldPluginForBenchmark(b) + defer cleanupHelloWorldPluginForBenchmark(b) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(b, err, "Failed to load plugin") + + ctx := context.Background() + resp := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: "test-id", + Model: "gpt-4", + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PostHook(&ctx, resp, nil) + } +} + +// BenchmarkDynamicPlugin_GetName benchmarks the GetName method +func BenchmarkDynamicPlugin_GetName(b *testing.B) { + pluginPath := buildHelloWorldPluginForBenchmark(b) + defer cleanupHelloWorldPluginForBenchmark(b) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(b, err, "Failed to load plugin") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = plugin.GetName() + } +} + +// Helper function to build plugin for benchmarks +func buildHelloWorldPluginForBenchmark(b *testing.B) string { + b.Helper() + + absPluginDir, err := filepath.Abs(helloWorldPluginDir) + require.NoError(b, err, "Failed to get absolute path") + + pluginExt := ".so" + if runtime.GOOS == "windows" { + pluginExt = ".dll" + } + + // Check if plugin already exists + pluginPath := filepath.Join(absPluginDir, "build", "hello-world"+pluginExt) + if _, err := os.Stat(pluginPath); err == nil { + return pluginPath + } + + // Build the plugin + cmd := exec.Command("make", "build") + cmd.Dir = absPluginDir + output, err := cmd.CombinedOutput() + if err != nil { + b.Logf("Make output: %s", string(output)) + require.NoError(b, err, "Failed to build hello-world plugin") + } + + return pluginPath +} + +// Helper function to clean up plugin for benchmarks +func cleanupHelloWorldPluginForBenchmark(b *testing.B) { + b.Helper() + + absPluginDir, err := filepath.Abs(helloWorldPluginDir) + if err != nil { + b.Logf("Failed to get absolute path for cleanup: %v", err) + return + } + + cmd := exec.Command("make", "clean") + cmd.Dir = absPluginDir + if err := cmd.Run(); err != nil { + b.Logf("Failed to clean hello-world plugin: %v", err) + } +} + +// TestDynamicPlugin_GetNameNotEmpty tests that GetName returns non-empty string +func TestDynamicPlugin_GetNameNotEmpty(t *testing.T) { + pluginPath := buildHelloWorldPlugin(t) + defer cleanupHelloWorldPlugin(t) + + plugin, err := loadDynamicPlugin(pluginPath, nil) + require.NoError(t, err, "Failed to load plugin") + + name := plugin.GetName() + assert.NotEmpty(t, name, "Plugin name should not be empty") + assert.True(t, strings.Contains(name, "Plugin"), "Plugin name should contain 'Plugin'") +} + +// Helper function to create a pointer to a string +func stringPtr(s string) *string { + return &s +} diff --git a/framework/plugins/main.go b/framework/plugins/main.go new file mode 100644 index 000000000..dde83b4cc --- /dev/null +++ b/framework/plugins/main.go @@ -0,0 +1,37 @@ +// Package plugins provides a framework for dynamically loading and managing plugins +package plugins + +import ( + "github.com/maximhq/bifrost/core/schemas" +) + +type DynamicPluginConfig struct { + Path string `json:"path"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` +} + +// Config is the configuration for the plugins framework +type Config struct { + Plugins []DynamicPluginConfig `json:"plugins"` +} + +// LoadPlugins loads the plugins from the config +func LoadPlugins(config *Config) ([]schemas.Plugin, error) { + plugins := []schemas.Plugin{} + if config == nil { + return plugins, nil + } + for _, dp := range config.Plugins { + if !dp.Enabled { + continue + } + plugin, err := loadDynamicPlugin(dp.Path, dp.Config) + if err != nil { + return nil, err + } + plugins = append(plugins, plugin) + } + return plugins, nil +} diff --git a/framework/streaming/accumulator.go b/framework/streaming/accumulator.go new file mode 100644 index 000000000..00e14f920 --- /dev/null +++ b/framework/streaming/accumulator.go @@ -0,0 +1,434 @@ +// Package streaming provides functionality for accumulating streaming chunks and other chunk-related workflows +package streaming + +import ( + "context" + "fmt" + "sync" + "time" + + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/modelcatalog" +) + +// Accumulator manages accumulation of streaming chunks +type Accumulator struct { + logger schemas.Logger + + streamAccumulators sync.Map // Track accumulators by request ID (atomic) + + chatStreamChunkPool sync.Pool // Pool for reusing StreamChunk structs + responsesStreamChunkPool sync.Pool // Pool for reusing ResponsesStreamChunk structs + audioStreamChunkPool sync.Pool // Pool for reusing AudioStreamChunk structs + transcriptionStreamChunkPool sync.Pool // Pool for reusing TranscriptionStreamChunk structs + + pricingManager *modelcatalog.ModelCatalog + + stopCleanup chan struct{} + cleanupWg sync.WaitGroup + ttl time.Duration + cleanupTicker *time.Ticker +} + +// getChatStreamChunk gets a chat stream chunk from the pool +func (a *Accumulator) getChatStreamChunk() *ChatStreamChunk { + return a.chatStreamChunkPool.Get().(*ChatStreamChunk) +} + +// putChatStreamChunk returns a chat stream chunk to the pool +func (a *Accumulator) putChatStreamChunk(chunk *ChatStreamChunk) { + chunk.Timestamp = time.Time{} + chunk.Delta = nil + chunk.Cost = nil + chunk.SemanticCacheDebug = nil + chunk.ErrorDetails = nil + chunk.FinishReason = nil + chunk.TokenUsage = nil + a.chatStreamChunkPool.Put(chunk) +} + +// GetAudioStreamChunk gets an audio stream chunk from the pool +func (a *Accumulator) getAudioStreamChunk() *AudioStreamChunk { + return a.audioStreamChunkPool.Get().(*AudioStreamChunk) +} + +// PutAudioStreamChunk returns an audio stream chunk to the pool +func (a *Accumulator) putAudioStreamChunk(chunk *AudioStreamChunk) { + chunk.Timestamp = time.Time{} + chunk.Delta = nil + chunk.Cost = nil + chunk.SemanticCacheDebug = nil + chunk.ErrorDetails = nil + chunk.FinishReason = nil + chunk.TokenUsage = nil + a.audioStreamChunkPool.Put(chunk) +} + +// getTranscriptionStreamChunk gets a transcription stream chunk from the pool +func (a *Accumulator) getTranscriptionStreamChunk() *TranscriptionStreamChunk { + return a.transcriptionStreamChunkPool.Get().(*TranscriptionStreamChunk) +} + +// putTranscriptionStreamChunk returns a transcription stream chunk to the pool +func (a *Accumulator) putTranscriptionStreamChunk(chunk *TranscriptionStreamChunk) { + chunk.Timestamp = time.Time{} + chunk.Delta = nil + chunk.Cost = nil + chunk.SemanticCacheDebug = nil + chunk.ErrorDetails = nil + chunk.FinishReason = nil + chunk.TokenUsage = nil + a.transcriptionStreamChunkPool.Put(chunk) +} + +// getResponsesStreamChunk gets a responses stream chunk from the pool +func (a *Accumulator) getResponsesStreamChunk() *ResponsesStreamChunk { + return a.responsesStreamChunkPool.Get().(*ResponsesStreamChunk) +} + +// putResponsesStreamChunk returns a responses stream chunk to the pool +func (a *Accumulator) putResponsesStreamChunk(chunk *ResponsesStreamChunk) { + chunk.Timestamp = time.Time{} + chunk.StreamResponse = nil + chunk.Cost = nil + chunk.SemanticCacheDebug = nil + chunk.ErrorDetails = nil + chunk.FinishReason = nil + chunk.TokenUsage = nil + a.responsesStreamChunkPool.Put(chunk) +} + +// CreateStreamAccumulator creates a new stream accumulator for a request +func (a *Accumulator) createStreamAccumulator(requestID string) *StreamAccumulator { + sc := &StreamAccumulator{ + RequestID: requestID, + ChatStreamChunks: make([]*ChatStreamChunk, 0), + ResponsesStreamChunks: make([]*ResponsesStreamChunk, 0), + IsComplete: false, + Timestamp: time.Now(), + } + a.streamAccumulators.Store(requestID, sc) + return sc +} + +// GetOrCreateStreamAccumulator gets or creates a stream accumulator for a request +func (a *Accumulator) getOrCreateStreamAccumulator(requestID string) *StreamAccumulator { + if accumulator, exists := a.streamAccumulators.Load(requestID); exists { + return accumulator.(*StreamAccumulator) + } + // Create new accumulator if it doesn't exist + return a.createStreamAccumulator(requestID) +} + +// AddStreamChunk adds a chunk to the stream accumulator +func (a *Accumulator) addChatStreamChunk(requestID string, chunk *ChatStreamChunk, isFinalChunk bool) error { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer accumulator.mu.Unlock() + if accumulator.StartTimestamp.IsZero() { + accumulator.StartTimestamp = chunk.Timestamp + } + // Add chunk to the list (chunks arrive in order) + accumulator.ChatStreamChunks = append(accumulator.ChatStreamChunks, chunk) + // Check if this is the final chunk + // Set FinalTimestamp when either FinishReason is present or token usage exists + // This handles both normal completion chunks and usage-only last chunks + if isFinalChunk { + accumulator.FinalTimestamp = chunk.Timestamp + } + return nil +} + +// AddTranscriptionStreamChunk adds a transcription stream chunk to the stream accumulator +func (a *Accumulator) addTranscriptionStreamChunk(requestID string, chunk *TranscriptionStreamChunk, isFinalChunk bool) error { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer accumulator.mu.Unlock() + if accumulator.StartTimestamp.IsZero() { + accumulator.StartTimestamp = chunk.Timestamp + } + // Add chunk to the list (chunks arrive in order) + accumulator.TranscriptionStreamChunks = append(accumulator.TranscriptionStreamChunks, chunk) + // Check if this is the final chunk + // Set FinalTimestamp when either FinishReason is present or token usage exists + // This handles both normal completion chunks and usage-only last chunks + if isFinalChunk { + accumulator.FinalTimestamp = chunk.Timestamp + } + return nil +} + +// AddAudioStreamChunk adds an audio stream chunk to the stream accumulator +func (a *Accumulator) addAudioStreamChunk(requestID string, chunk *AudioStreamChunk, isFinalChunk bool) error { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer accumulator.mu.Unlock() + if accumulator.StartTimestamp.IsZero() { + accumulator.StartTimestamp = chunk.Timestamp + } + // Add chunk to the list (chunks arrive in order) + accumulator.AudioStreamChunks = append(accumulator.AudioStreamChunks, chunk) + // Check if this is the final chunk + // Set FinalTimestamp when either FinishReason is present or token usage exists + // This handles both normal completion chunks and usage-only last chunks + if isFinalChunk { + accumulator.FinalTimestamp = chunk.Timestamp + } + return nil +} + +// addResponsesStreamChunk adds a responses stream chunk to the stream accumulator +func (a *Accumulator) addResponsesStreamChunk(requestID string, chunk *ResponsesStreamChunk, isFinalChunk bool) error { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer accumulator.mu.Unlock() + if accumulator.StartTimestamp.IsZero() { + accumulator.StartTimestamp = chunk.Timestamp + } + // Add chunk to the list (chunks arrive in order) + accumulator.ResponsesStreamChunks = append(accumulator.ResponsesStreamChunks, chunk) + // Check if this is the final chunk + // Set FinalTimestamp when either FinishReason is present or token usage exists + // This handles both normal completion chunks and usage-only last chunks + if isFinalChunk { + accumulator.FinalTimestamp = chunk.Timestamp + } + return nil +} + +// cleanupStreamAccumulator removes the stream accumulator for a request +func (a *Accumulator) cleanupStreamAccumulator(requestID string) { + if accumulator, exists := a.streamAccumulators.Load(requestID); exists { + // Return all chunks to the pool before deleting + acc := accumulator.(*StreamAccumulator) + for _, chunk := range acc.ChatStreamChunks { + a.putChatStreamChunk(chunk) + } + for _, chunk := range acc.ResponsesStreamChunks { + a.putResponsesStreamChunk(chunk) + } + for _, chunk := range acc.AudioStreamChunks { + a.putAudioStreamChunk(chunk) + } + for _, chunk := range acc.TranscriptionStreamChunks { + a.putTranscriptionStreamChunk(chunk) + } + a.streamAccumulators.Delete(requestID) + } +} + +// accumulateToolCallsInMessage efficiently accumulates tool calls in a message +func (a *Accumulator) accumulateToolCallsInMessage(message *schemas.ChatMessage, deltaToolCalls []schemas.ChatAssistantMessageToolCall) { + if message == nil { + return + } + if message.ChatAssistantMessage == nil { + message.ChatAssistantMessage = &schemas.ChatAssistantMessage{} + } + existingToolCalls := message.ChatAssistantMessage.ToolCalls + for _, deltaToolCall := range deltaToolCalls { + var toolCallToModify *schemas.ChatAssistantMessageToolCall + // Checking if delta tool name is present, + // If present, then it could be different tool call + if deltaToolCall.Function.Name != nil { + // Creating a new tool call + // Only set arguments if they're not empty or just empty braces + args := deltaToolCall.Function.Arguments + if args == "{}" { + args = "" // Reset empty braces to empty string to avoid duplication + } + toolCallToModify = &schemas.ChatAssistantMessageToolCall{ + Index: uint16(len(existingToolCalls)), + ID: deltaToolCall.ID, + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: deltaToolCall.Function.Name, + Arguments: args, + }, + } + existingToolCalls = append(existingToolCalls, *toolCallToModify) + } else { + // Ensure there's at least one tool call to modify + if len(existingToolCalls) == 0 { + a.logger.Warn("received tool call delta without name, but no existing tool calls to append to") + continue + } + // Otherwise we will modify the last tool call + toolCallToModify = &existingToolCalls[len(existingToolCalls)-1] + toolCallToModify.Function.Arguments += deltaToolCall.Function.Arguments + } + } + message.ChatAssistantMessage.ToolCalls = existingToolCalls +} + +// appendContentToMessage efficiently appends content to a message +func (a *Accumulator) appendContentToMessage(message *schemas.ChatMessage, newContent string) { + if message == nil { + return + } + if message.Content.ContentStr != nil { + // Append to existing string content + *message.Content.ContentStr += newContent + } else if message.Content.ContentBlocks != nil { + // Find the last text block and append, or create new one + blocks := message.Content.ContentBlocks + if len(blocks) > 0 && blocks[len(blocks)-1].Type == schemas.ChatContentBlockTypeText && blocks[len(blocks)-1].Text != nil { + // Append to last text block + *blocks[len(blocks)-1].Text += newContent + } else { + // Create new text block + blocks = append(blocks, schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: &newContent, + }) + message.Content.ContentBlocks = blocks + } + } else { + // Initialize with string content + message.Content.ContentStr = &newContent + } +} + +// ProcessStreamingResponse processes a streaming response +// It handles chat, audio, and responses streaming responses +func (a *Accumulator) ProcessStreamingResponse(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*ProcessedStreamResponse, error) { + // Check if this is a streaming response + if result == nil { + return nil, fmt.Errorf("result is nil") + } + extraFields := result.GetExtraFields() + requestType := extraFields.RequestType + isAudioStreaming := requestType == schemas.SpeechStreamRequest || requestType == schemas.TranscriptionStreamRequest + isChatStreaming := requestType == schemas.ChatCompletionStreamRequest || requestType == schemas.TextCompletionStreamRequest + isResponsesStreaming := requestType == schemas.ResponsesStreamRequest + + if isChatStreaming { + // Handle text-based streaming with ordered accumulation + return a.processChatStreamingResponse(ctx, result, bifrostErr) + } else if isAudioStreaming { + // Handle speech/transcription streaming with original flow + if requestType == schemas.TranscriptionStreamRequest { + return a.processTranscriptionStreamingResponse(ctx, result, bifrostErr) + } + if requestType == schemas.SpeechStreamRequest { + return a.processAudioStreamingResponse(ctx, result, bifrostErr) + } + } else if isResponsesStreaming { + // Handle responses streaming with responses accumulation + return a.processResponsesStreamingResponse(ctx, result, bifrostErr) + } + return nil, fmt.Errorf("request type missing/invalid for accumulator: %s", requestType) +} + +// Cleanup cleans up the accumulator +func (a *Accumulator) Cleanup() { + // Clean up all stream accumulators + a.streamAccumulators.Range(func(key, value interface{}) bool { + accumulator := value.(*StreamAccumulator) + for _, chunk := range accumulator.ChatStreamChunks { + a.chatStreamChunkPool.Put(chunk) + } + for _, chunk := range accumulator.ResponsesStreamChunks { + a.responsesStreamChunkPool.Put(chunk) + } + for _, chunk := range accumulator.TranscriptionStreamChunks { + a.transcriptionStreamChunkPool.Put(chunk) + } + for _, chunk := range accumulator.AudioStreamChunks { + a.audioStreamChunkPool.Put(chunk) + } + a.streamAccumulators.Delete(key) + return true + }) + close(a.stopCleanup) + a.cleanupTicker.Stop() + a.cleanupWg.Wait() +} + +// CreateStreamAccumulator creates a new stream accumulator for a request +func (a *Accumulator) CreateStreamAccumulator(requestID string, startTimestamp time.Time) *StreamAccumulator { + sc := a.getOrCreateStreamAccumulator(requestID) + sc.StartTimestamp = startTimestamp + return sc +} + +// CleanupStreamAccumulator cleans up the stream accumulator for a request +func (a *Accumulator) CleanupStreamAccumulator(requestID string) error { + a.cleanupStreamAccumulator(requestID) + return nil +} + +// cleanupOldAccumulators removes old accumulators +func (a *Accumulator) cleanupOldAccumulators() { + count := 0 + a.streamAccumulators.Range(func(key, value interface{}) bool { + accumulator := value.(*StreamAccumulator) + if accumulator.Timestamp.Before(time.Now().Add(-a.ttl)) { + a.cleanupStreamAccumulator(key.(string)) + } + count++ + return true + }) + + a.logger.Debug("[streaming] cleanup old accumulators done. current size: %d entries", count) +} + +// startCleanup runs in a background goroutine to periodically remove expired entries +func (a *Accumulator) startAccumulatorMapCleanup() { + defer a.cleanupWg.Done() + + for { + select { + case <-a.cleanupTicker.C: + a.cleanupOldAccumulators() + case <-a.stopCleanup: + return + } + } +} + +// NewAccumulator creates a new accumulator +func NewAccumulator(pricingManager *modelcatalog.ModelCatalog, logger schemas.Logger) *Accumulator { + a := &Accumulator{ + streamAccumulators: sync.Map{}, + chatStreamChunkPool: sync.Pool{ + New: func() any { + return &ChatStreamChunk{} + }, + }, + responsesStreamChunkPool: sync.Pool{ + New: func() any { + return &ResponsesStreamChunk{} + }, + }, + audioStreamChunkPool: sync.Pool{ + New: func() any { + return &AudioStreamChunk{} + }, + }, + transcriptionStreamChunkPool: sync.Pool{ + New: func() any { + return &TranscriptionStreamChunk{} + }, + }, + pricingManager: pricingManager, + logger: logger, + ttl: 30 * time.Minute, + cleanupTicker: time.NewTicker(1 * time.Minute), + cleanupWg: sync.WaitGroup{}, + stopCleanup: make(chan struct{}), + } + a.cleanupWg.Add(1) + // Prewarm the pools for better performance at startup + for range 1000 { + a.chatStreamChunkPool.Put(&ChatStreamChunk{}) + a.responsesStreamChunkPool.Put(&ResponsesStreamChunk{}) + a.audioStreamChunkPool.Put(&AudioStreamChunk{}) + a.transcriptionStreamChunkPool.Put(&TranscriptionStreamChunk{}) + } + go a.startAccumulatorMapCleanup() + return a +} diff --git a/framework/streaming/audio.go b/framework/streaming/audio.go new file mode 100644 index 000000000..37ed74f79 --- /dev/null +++ b/framework/streaming/audio.go @@ -0,0 +1,176 @@ +package streaming + +import ( + "context" + "fmt" + "sort" + "time" + + bifrost "github.com/maximhq/bifrost/core" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// buildCompleteMessageFromAudioStreamChunks builds a complete message from accumulated audio chunks +func (a *Accumulator) buildCompleteMessageFromAudioStreamChunks(chunks []*AudioStreamChunk) *schemas.BifrostSpeechResponse { + completeMessage := &schemas.BifrostSpeechResponse{} + sort.Slice(chunks, func(i, j int) bool { + return chunks[i].ChunkIndex < chunks[j].ChunkIndex + }) + for _, chunk := range chunks { + if chunk.Delta != nil { + completeMessage.Audio = append(completeMessage.Audio, chunk.Delta.Audio...) + } + } + return completeMessage +} + +// processAccumulatedAudioStreamingChunks processes all accumulated audio chunks in order +func (a *Accumulator) processAccumulatedAudioStreamingChunks(requestID string, bifrostErr *schemas.BifrostError, isFinalChunk bool) (*AccumulatedData, error) { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer func() { + accumulator.mu.Unlock() + if isFinalChunk { + // Before unlocking, we cleanup + defer a.cleanupStreamAccumulator(requestID) + } + }() + data := &AccumulatedData{ + RequestID: requestID, + Status: "success", + Stream: true, + StartTimestamp: accumulator.StartTimestamp, + EndTimestamp: accumulator.FinalTimestamp, + Latency: 0, + OutputMessage: nil, + ToolCalls: nil, + ErrorDetails: nil, + TokenUsage: nil, + CacheDebug: nil, + Cost: nil, + } + completeMessage := a.buildCompleteMessageFromAudioStreamChunks(accumulator.AudioStreamChunks) + if !isFinalChunk { + data.AudioOutput = completeMessage + return data, nil + } + data.Status = "success" + if bifrostErr != nil { + data.Status = "error" + } + if accumulator.StartTimestamp.IsZero() || accumulator.FinalTimestamp.IsZero() { + data.Latency = 0 + } else { + data.Latency = accumulator.FinalTimestamp.Sub(accumulator.StartTimestamp).Nanoseconds() / 1e6 + } + data.EndTimestamp = accumulator.FinalTimestamp + data.AudioOutput = completeMessage + data.ErrorDetails = bifrostErr + // Update token usage from final chunk if available + if len(accumulator.AudioStreamChunks) > 0 { + lastChunk := accumulator.AudioStreamChunks[len(accumulator.AudioStreamChunks)-1] + if lastChunk.TokenUsage != nil { + data.TokenUsage = &schemas.BifrostLLMUsage{ + PromptTokens: lastChunk.TokenUsage.InputTokens, + CompletionTokens: lastChunk.TokenUsage.OutputTokens, + TotalTokens: lastChunk.TokenUsage.TotalTokens, + } + } + } + // Update cost from final chunk if available + if len(accumulator.AudioStreamChunks) > 0 { + lastChunk := accumulator.AudioStreamChunks[len(accumulator.AudioStreamChunks)-1] + if lastChunk.Cost != nil { + data.Cost = lastChunk.Cost + } + } + // Update semantic cache debug from final chunk if available + if len(accumulator.AudioStreamChunks) > 0 { + lastChunk := accumulator.AudioStreamChunks[len(accumulator.AudioStreamChunks)-1] + if lastChunk.SemanticCacheDebug != nil { + data.CacheDebug = lastChunk.SemanticCacheDebug + } + } + return data, nil +} + +// processAudioStreamingResponse processes a audio streaming response +func (a *Accumulator) processAudioStreamingResponse(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*ProcessedStreamResponse, error) { + // Extract request ID from context + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + // Log error but don't fail the request + return nil, fmt.Errorf("request-id not found in context or is empty") + } + _, provider, model := bifrost.GetResponseFields(result, bifrostErr) + isFinalChunk := bifrost.IsFinalChunk(ctx) + // For audio, all the data comes in the final chunk + chunk := a.getAudioStreamChunk() + chunk.Timestamp = time.Now() + chunk.ErrorDetails = bifrostErr + if bifrostErr != nil { + chunk.FinishReason = bifrost.Ptr("error") + } else if result != nil && result.SpeechStreamResponse != nil { + // We create a deep copy of the delta to avoid pointing to stack memory + newDelta := &schemas.BifrostSpeechStreamResponse{ + Type: result.SpeechStreamResponse.Type, + Usage: result.SpeechStreamResponse.Usage, + Audio: result.SpeechStreamResponse.Audio, + } + chunk.Delta = newDelta + if result.SpeechStreamResponse.Usage != nil { + chunk.TokenUsage = result.SpeechStreamResponse.Usage + } + chunk.ChunkIndex = result.SpeechStreamResponse.ExtraFields.ChunkIndex + if isFinalChunk { + if a.pricingManager != nil { + cost := a.pricingManager.CalculateCostWithCacheDebug(result) + chunk.Cost = bifrost.Ptr(cost) + } + chunk.SemanticCacheDebug = result.GetExtraFields().CacheDebug + } + } + if addErr := a.addAudioStreamChunk(requestID, chunk, isFinalChunk); addErr != nil { + return nil, fmt.Errorf("failed to add stream chunk for request %s: %w", requestID, addErr) + } + if isFinalChunk { + shouldProcess := false + accumulator := a.getOrCreateStreamAccumulator(requestID) + accumulator.mu.Lock() + shouldProcess = !accumulator.IsComplete + if shouldProcess { + accumulator.IsComplete = true + } + accumulator.mu.Unlock() + if shouldProcess { + data, processErr := a.processAccumulatedAudioStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + return &ProcessedStreamResponse{ + Type: StreamResponseTypeFinal, + RequestID: requestID, + StreamType: StreamTypeAudio, + Model: model, + Provider: provider, + Data: data, + }, nil + } + return nil, nil + } + data, processErr := a.processAccumulatedAudioStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + return &ProcessedStreamResponse{ + Type: StreamResponseTypeDelta, + RequestID: requestID, + StreamType: StreamTypeAudio, + Model: model, + Provider: provider, + Data: data, + }, nil +} diff --git a/framework/streaming/chat.go b/framework/streaming/chat.go new file mode 100644 index 000000000..4dc017f70 --- /dev/null +++ b/framework/streaming/chat.go @@ -0,0 +1,225 @@ +package streaming + +import ( + "context" + "fmt" + "sort" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// buildCompleteMessageFromChunks builds a complete message from accumulated chunks +func (a *Accumulator) buildCompleteMessageFromChatStreamChunks(chunks []*ChatStreamChunk) *schemas.ChatMessage { + completeMessage := &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{}, + } + sort.Slice(chunks, func(i, j int) bool { + return chunks[i].ChunkIndex < chunks[j].ChunkIndex + }) + for _, chunk := range chunks { + if chunk.Delta == nil { + continue + } + // Handle role (usually in first chunk) + if chunk.Delta.Role != nil { + completeMessage.Role = schemas.ChatMessageRole(*chunk.Delta.Role) + } + // Append content + if chunk.Delta.Content != nil && *chunk.Delta.Content != "" { + a.appendContentToMessage(completeMessage, *chunk.Delta.Content) + } + // Handle refusal + if chunk.Delta.Refusal != nil && *chunk.Delta.Refusal != "" { + if completeMessage.ChatAssistantMessage == nil { + completeMessage.ChatAssistantMessage = &schemas.ChatAssistantMessage{} + } + if completeMessage.ChatAssistantMessage.Refusal == nil { + completeMessage.ChatAssistantMessage.Refusal = chunk.Delta.Refusal + } else { + *completeMessage.ChatAssistantMessage.Refusal += *chunk.Delta.Refusal + } + } + // Accumulate tool calls + if len(chunk.Delta.ToolCalls) > 0 { + a.accumulateToolCallsInMessage(completeMessage, chunk.Delta.ToolCalls) + } + } + return completeMessage +} + +// processAccumulatedChunks processes all accumulated chunks in order +func (a *Accumulator) processAccumulatedChatStreamingChunks(requestID string, respErr *schemas.BifrostError, isFinalChunk bool) (*AccumulatedData, error) { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer func() { + accumulator.mu.Unlock() + if isFinalChunk { + // Before unlocking, we cleanup + defer a.cleanupStreamAccumulator(requestID) + } + }() + // Initialize accumulated data + data := &AccumulatedData{ + RequestID: requestID, + Status: "success", + Stream: true, + StartTimestamp: accumulator.StartTimestamp, + EndTimestamp: accumulator.FinalTimestamp, + Latency: 0, + OutputMessage: nil, + ToolCalls: nil, + ErrorDetails: nil, + TokenUsage: nil, + CacheDebug: nil, + Cost: nil, + } + // Build complete message from accumulated chunks + completeMessage := a.buildCompleteMessageFromChatStreamChunks(accumulator.ChatStreamChunks) + if !isFinalChunk { + data.OutputMessage = completeMessage + return data, nil + } + // Update database with complete message + data.Status = "success" + if respErr != nil { + data.Status = "error" + } + if accumulator.StartTimestamp.IsZero() || accumulator.FinalTimestamp.IsZero() { + data.Latency = 0 + } else { + data.Latency = accumulator.FinalTimestamp.Sub(accumulator.StartTimestamp).Nanoseconds() / 1e6 + } + data.EndTimestamp = accumulator.FinalTimestamp + data.OutputMessage = completeMessage + if data.OutputMessage.ChatAssistantMessage != nil && data.OutputMessage.ChatAssistantMessage.ToolCalls != nil { + data.ToolCalls = data.OutputMessage.ChatAssistantMessage.ToolCalls + } + data.ErrorDetails = respErr + // Update token usage from final chunk if available + if len(accumulator.ChatStreamChunks) > 0 { + lastChunk := accumulator.ChatStreamChunks[len(accumulator.ChatStreamChunks)-1] + if lastChunk.TokenUsage != nil { + data.TokenUsage = lastChunk.TokenUsage + } + // Handle cache debug + if lastChunk.SemanticCacheDebug != nil { + data.CacheDebug = lastChunk.SemanticCacheDebug + } + } + // Update cost from final chunk if available + if len(accumulator.ChatStreamChunks) > 0 { + lastChunk := accumulator.ChatStreamChunks[len(accumulator.ChatStreamChunks)-1] + if lastChunk.Cost != nil { + data.Cost = lastChunk.Cost + } + data.FinishReason = lastChunk.FinishReason + } + return data, nil +} + +// processChatStreamingResponse processes a chat streaming response +func (a *Accumulator) processChatStreamingResponse(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*ProcessedStreamResponse, error) { + a.logger.Debug("[streaming] processing chat streaming response") + // Extract request ID from context + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + // Log error but don't fail the request + return nil, fmt.Errorf("request-id not found in context or is empty") + } + requestType, provider, model := bifrost.GetResponseFields(result, bifrostErr) + + streamType := StreamTypeChat + if requestType == schemas.TextCompletionStreamRequest { + streamType = StreamTypeText + } + + isFinalChunk := bifrost.IsFinalChunk(ctx) + chunk := a.getChatStreamChunk() + chunk.Timestamp = time.Now() + chunk.ErrorDetails = bifrostErr + if bifrostErr != nil { + chunk.FinishReason = bifrost.Ptr("error") + } else if result != nil && result.ChatResponse != nil { + // Extract delta and other information + if len(result.ChatResponse.Choices) > 0 { + choice := result.ChatResponse.Choices[0] + if choice.ChatStreamResponseChoice != nil { + // Shallow-copy struct and deep-copy slices to avoid aliasing + copied := choice.ChatStreamResponseChoice.Delta + chunk.Delta = copied + chunk.FinishReason = choice.FinishReason + } + if choice.TextCompletionResponseChoice != nil { + deltaCopy := choice.TextCompletionResponseChoice.Text + chunk.Delta = &schemas.ChatStreamResponseChoiceDelta{ + Content: deltaCopy, + } + chunk.FinishReason = choice.FinishReason + } + } + // Extract token usage + if result.ChatResponse.Usage != nil && result.ChatResponse.Usage.TotalTokens > 0 { + chunk.TokenUsage = result.ChatResponse.Usage + } + chunk.ChunkIndex = result.ChatResponse.ExtraFields.ChunkIndex + if isFinalChunk { + if a.pricingManager != nil { + cost := a.pricingManager.CalculateCostWithCacheDebug(result) + chunk.Cost = bifrost.Ptr(cost) + } + chunk.SemanticCacheDebug = result.GetExtraFields().CacheDebug + } + } + if addErr := a.addChatStreamChunk(requestID, chunk, isFinalChunk); addErr != nil { + return nil, fmt.Errorf("failed to add stream chunk for request %s: %w", requestID, addErr) + } + // If this is the final chunk, process accumulated chunks asynchronously + // Use the IsComplete flag to prevent duplicate processing + if isFinalChunk { + shouldProcess := false + // Get the accumulator to check if processing has already been triggered + accumulator := a.getOrCreateStreamAccumulator(requestID) + accumulator.mu.Lock() + shouldProcess = !accumulator.IsComplete + // Mark as complete when we're about to process + if shouldProcess { + accumulator.IsComplete = true + } + accumulator.mu.Unlock() + if shouldProcess { + data, processErr := a.processAccumulatedChatStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + return &ProcessedStreamResponse{ + Type: StreamResponseTypeFinal, + RequestID: requestID, + StreamType: streamType, + Provider: provider, + Model: model, + Data: data, + }, nil + } + return nil, nil + } + // This is going to be a delta response + data, processErr := a.processAccumulatedChatStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + // This is not the final chunk, so we will send back the delta + return &ProcessedStreamResponse{ + Type: StreamResponseTypeDelta, + RequestID: requestID, + StreamType: streamType, + Provider: provider, + Model: model, + Data: data, + }, nil +} diff --git a/framework/streaming/responses.go b/framework/streaming/responses.go new file mode 100644 index 000000000..fb366aeef --- /dev/null +++ b/framework/streaming/responses.go @@ -0,0 +1,830 @@ +package streaming + +import ( + "context" + "fmt" + "sort" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// deepCopyResponsesStreamResponse creates a deep copy of BifrostResponsesStreamResponse +// to prevent shared data mutation between different plugin accumulators +func deepCopyResponsesStreamResponse(original *schemas.BifrostResponsesStreamResponse) *schemas.BifrostResponsesStreamResponse { + if original == nil { + return nil + } + + copy := &schemas.BifrostResponsesStreamResponse{ + Type: original.Type, + SequenceNumber: original.SequenceNumber, + ExtraFields: original.ExtraFields, // ExtraFields can be safely shared as they're typically read-only + } + + // Deep copy Response if present + if original.Response != nil { + copy.Response = &schemas.BifrostResponsesResponse{} + *copy.Response = *original.Response // Shallow copy the struct + + // Deep copy the Output slice if present + if original.Response.Output != nil { + copy.Response.Output = make([]schemas.ResponsesMessage, len(original.Response.Output)) + for i, msg := range original.Response.Output { + copy.Response.Output[i] = deepCopyResponsesMessage(msg) + } + } + + // Copy Usage if present (Usage can be shallow copied as it's typically immutable) + if original.Response.Usage != nil { + copyUsage := *original.Response.Usage + copy.Response.Usage = ©Usage + } + } + + // Copy pointer fields + if original.OutputIndex != nil { + copyOutputIndex := *original.OutputIndex + copy.OutputIndex = ©OutputIndex + } + + if original.Item != nil { + copyItem := deepCopyResponsesMessage(*original.Item) + copy.Item = ©Item + } + + if original.ContentIndex != nil { + copyContentIndex := *original.ContentIndex + copy.ContentIndex = ©ContentIndex + } + + if original.ItemID != nil { + copyItemID := *original.ItemID + copy.ItemID = ©ItemID + } + + if original.Part != nil { + copyPart := deepCopyResponsesMessageContentBlock(*original.Part) + copy.Part = ©Part + } + + if original.Delta != nil { + copyDelta := *original.Delta + copy.Delta = ©Delta + } + + // Deep copy LogProbs slice if present + if original.LogProbs != nil { + copy.LogProbs = make([]schemas.ResponsesOutputMessageContentTextLogProb, len(original.LogProbs)) + for i, logProb := range original.LogProbs { + copiedLogProb := schemas.ResponsesOutputMessageContentTextLogProb{ + LogProb: logProb.LogProb, + Token: logProb.Token, + } + // Deep copy Bytes slice + if logProb.Bytes != nil { + copiedLogProb.Bytes = make([]int, len(logProb.Bytes)) + for j, byteValue := range logProb.Bytes { + copiedLogProb.Bytes[j] = byteValue + } + } + // Deep copy TopLogProbs slice + if logProb.TopLogProbs != nil { + copiedLogProb.TopLogProbs = make([]schemas.LogProb, len(logProb.TopLogProbs)) + for j, topLogProb := range logProb.TopLogProbs { + copiedLogProb.TopLogProbs[j] = schemas.LogProb{ + Bytes: topLogProb.Bytes, + LogProb: topLogProb.LogProb, + Token: topLogProb.Token, + } + } + } + copy.LogProbs[i] = copiedLogProb + } + } + + if original.Text != nil { + copyText := *original.Text + copy.Text = ©Text + } + + if original.Refusal != nil { + copyRefusal := *original.Refusal + copy.Refusal = ©Refusal + } + + if original.Arguments != nil { + copyArguments := *original.Arguments + copy.Arguments = ©Arguments + } + + if original.PartialImageB64 != nil { + copyPartialImageB64 := *original.PartialImageB64 + copy.PartialImageB64 = ©PartialImageB64 + } + + if original.PartialImageIndex != nil { + copyPartialImageIndex := *original.PartialImageIndex + copy.PartialImageIndex = ©PartialImageIndex + } + + if original.Annotation != nil { + copyAnnotation := *original.Annotation + copy.Annotation = ©Annotation + } + + if original.AnnotationIndex != nil { + copyAnnotationIndex := *original.AnnotationIndex + copy.AnnotationIndex = ©AnnotationIndex + } + + if original.Code != nil { + copyCode := *original.Code + copy.Code = ©Code + } + + if original.Message != nil { + copyMessage := *original.Message + copy.Message = ©Message + } + + if original.Param != nil { + copyParam := *original.Param + copy.Param = ©Param + } + + return copy +} + +// deepCopyResponsesMessage creates a deep copy of a ResponsesMessage +func deepCopyResponsesMessage(original schemas.ResponsesMessage) schemas.ResponsesMessage { + copy := schemas.ResponsesMessage{} + + if original.ID != nil { + copyID := *original.ID + copy.ID = ©ID + } + + if original.Type != nil { + copyType := *original.Type + copy.Type = ©Type + } + + if original.Role != nil { + copyRole := *original.Role + copy.Role = ©Role + } + + if original.Content != nil { + copy.Content = &schemas.ResponsesMessageContent{} + + if original.Content.ContentStr != nil { + copyContentStr := *original.Content.ContentStr + copy.Content.ContentStr = ©ContentStr + } + + if original.Content.ContentBlocks != nil { + copy.Content.ContentBlocks = make([]schemas.ResponsesMessageContentBlock, len(original.Content.ContentBlocks)) + for i, block := range original.Content.ContentBlocks { + copy.Content.ContentBlocks[i] = deepCopyResponsesMessageContentBlock(block) + } + } + } + + if original.ResponsesToolMessage != nil { + copy.ResponsesToolMessage = &schemas.ResponsesToolMessage{} + + // Deep copy primitive fields + if original.ResponsesToolMessage.CallID != nil { + copyCallID := *original.ResponsesToolMessage.CallID + copy.ResponsesToolMessage.CallID = ©CallID + } + + if original.ResponsesToolMessage.Name != nil { + copyName := *original.ResponsesToolMessage.Name + copy.ResponsesToolMessage.Name = ©Name + } + + if original.ResponsesToolMessage.Arguments != nil { + copyArguments := *original.ResponsesToolMessage.Arguments + copy.ResponsesToolMessage.Arguments = ©Arguments + } + + if original.ResponsesToolMessage.Error != nil { + copyError := *original.ResponsesToolMessage.Error + copy.ResponsesToolMessage.Error = ©Error + } + + // Deep copy Output + if original.ResponsesToolMessage.Output != nil { + copy.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{} + + if original.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil { + copyStr := *original.ResponsesToolMessage.Output.ResponsesToolCallOutputStr + copy.ResponsesToolMessage.Output.ResponsesToolCallOutputStr = ©Str + } + + if original.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks != nil { + copy.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks = make([]schemas.ResponsesMessageContentBlock, len(original.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks)) + for i, block := range original.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks { + copy.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks[i] = deepCopyResponsesMessageContentBlock(block) + } + } + + if original.ResponsesToolMessage.Output.ResponsesComputerToolCallOutput != nil { + copyOutput := *original.ResponsesToolMessage.Output.ResponsesComputerToolCallOutput + copy.ResponsesToolMessage.Output.ResponsesComputerToolCallOutput = ©Output + } + } + + // Deep copy Action + if original.ResponsesToolMessage.Action != nil { + copy.ResponsesToolMessage.Action = &schemas.ResponsesToolMessageActionStruct{} + + if original.ResponsesToolMessage.Action.ResponsesComputerToolCallAction != nil { + copyAction := *original.ResponsesToolMessage.Action.ResponsesComputerToolCallAction + // Deep copy Path slice + if copyAction.Path != nil { + copyAction.Path = make([]schemas.ResponsesComputerToolCallActionPath, len(copyAction.Path)) + for i, path := range original.ResponsesToolMessage.Action.ResponsesComputerToolCallAction.Path { + copyAction.Path[i] = path // struct copy is fine for simple structs + } + } + // Deep copy Keys slice + if copyAction.Keys != nil { + copyAction.Keys = make([]string, len(copyAction.Keys)) + for i, key := range original.ResponsesToolMessage.Action.ResponsesComputerToolCallAction.Keys { + copyAction.Keys[i] = key + } + } + copy.ResponsesToolMessage.Action.ResponsesComputerToolCallAction = ©Action + } + + if original.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction != nil { + copyAction := *original.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction + copy.ResponsesToolMessage.Action.ResponsesWebSearchToolCallAction = ©Action + } + + if original.ResponsesToolMessage.Action.ResponsesLocalShellToolCallAction != nil { + copyAction := *original.ResponsesToolMessage.Action.ResponsesLocalShellToolCallAction + copy.ResponsesToolMessage.Action.ResponsesLocalShellToolCallAction = ©Action + } + + if original.ResponsesToolMessage.Action.ResponsesMCPApprovalRequestAction != nil { + copyAction := *original.ResponsesToolMessage.Action.ResponsesMCPApprovalRequestAction + copy.ResponsesToolMessage.Action.ResponsesMCPApprovalRequestAction = ©Action + } + } + + // Deep copy embedded tool call structs + if original.ResponsesToolMessage.ResponsesFileSearchToolCall != nil { + copyToolCall := *original.ResponsesToolMessage.ResponsesFileSearchToolCall + // Deep copy Queries slice + if copyToolCall.Queries != nil { + copyToolCall.Queries = make([]string, len(copyToolCall.Queries)) + for i, query := range original.ResponsesToolMessage.ResponsesFileSearchToolCall.Queries { + copyToolCall.Queries[i] = query + } + } + // Deep copy Results slice + if copyToolCall.Results != nil { + copyToolCall.Results = make([]schemas.ResponsesFileSearchToolCallResult, len(copyToolCall.Results)) + for i, result := range original.ResponsesToolMessage.ResponsesFileSearchToolCall.Results { + copyResult := result + // Deep copy Attributes map if present + if result.Attributes != nil { + copyAttrs := make(map[string]any, len(*result.Attributes)) + for k, v := range *result.Attributes { + copyAttrs[k] = v + } + copyResult.Attributes = ©Attrs + } + copyToolCall.Results[i] = copyResult + } + } + copy.ResponsesToolMessage.ResponsesFileSearchToolCall = ©ToolCall + } + + if original.ResponsesToolMessage.ResponsesComputerToolCall != nil { + copyToolCall := *original.ResponsesToolMessage.ResponsesComputerToolCall + // Deep copy PendingSafetyChecks slice + if copyToolCall.PendingSafetyChecks != nil { + copyToolCall.PendingSafetyChecks = make([]schemas.ResponsesComputerToolCallPendingSafetyCheck, len(copyToolCall.PendingSafetyChecks)) + for i, check := range original.ResponsesToolMessage.ResponsesComputerToolCall.PendingSafetyChecks { + copyToolCall.PendingSafetyChecks[i] = check + } + } + copy.ResponsesToolMessage.ResponsesComputerToolCall = ©ToolCall + } + + if original.ResponsesToolMessage.ResponsesComputerToolCallOutput != nil { + copyOutput := *original.ResponsesToolMessage.ResponsesComputerToolCallOutput + // Deep copy AcknowledgedSafetyChecks slice + if copyOutput.AcknowledgedSafetyChecks != nil { + copyOutput.AcknowledgedSafetyChecks = make([]schemas.ResponsesComputerToolCallAcknowledgedSafetyCheck, len(copyOutput.AcknowledgedSafetyChecks)) + for i, check := range original.ResponsesToolMessage.ResponsesComputerToolCallOutput.AcknowledgedSafetyChecks { + copyOutput.AcknowledgedSafetyChecks[i] = check + } + } + copy.ResponsesToolMessage.ResponsesComputerToolCallOutput = ©Output + } + + if original.ResponsesToolMessage.ResponsesCodeInterpreterToolCall != nil { + copyToolCall := *original.ResponsesToolMessage.ResponsesCodeInterpreterToolCall + // Deep copy Outputs slice + if copyToolCall.Outputs != nil { + copyToolCall.Outputs = make([]schemas.ResponsesCodeInterpreterOutput, len(copyToolCall.Outputs)) + for i, output := range original.ResponsesToolMessage.ResponsesCodeInterpreterToolCall.Outputs { + copyToolCall.Outputs[i] = output + } + } + copy.ResponsesToolMessage.ResponsesCodeInterpreterToolCall = ©ToolCall + } + + if original.ResponsesToolMessage.ResponsesMCPToolCall != nil { + copyToolCall := *original.ResponsesToolMessage.ResponsesMCPToolCall + copy.ResponsesToolMessage.ResponsesMCPToolCall = ©ToolCall + } + + if original.ResponsesToolMessage.ResponsesCustomToolCall != nil { + copyToolCall := *original.ResponsesToolMessage.ResponsesCustomToolCall + copy.ResponsesToolMessage.ResponsesCustomToolCall = ©ToolCall + } + + if original.ResponsesToolMessage.ResponsesImageGenerationCall != nil { + copyCall := *original.ResponsesToolMessage.ResponsesImageGenerationCall + copy.ResponsesToolMessage.ResponsesImageGenerationCall = ©Call + } + + if original.ResponsesToolMessage.ResponsesMCPListTools != nil { + copyListTools := *original.ResponsesToolMessage.ResponsesMCPListTools + // Deep copy Tools slice + if copyListTools.Tools != nil { + copyListTools.Tools = make([]schemas.ResponsesMCPTool, len(copyListTools.Tools)) + for i, tool := range original.ResponsesToolMessage.ResponsesMCPListTools.Tools { + copyListTools.Tools[i] = tool + } + } + copy.ResponsesToolMessage.ResponsesMCPListTools = ©ListTools + } + + if original.ResponsesToolMessage.ResponsesMCPApprovalResponse != nil { + copyApproval := *original.ResponsesToolMessage.ResponsesMCPApprovalResponse + copy.ResponsesToolMessage.ResponsesMCPApprovalResponse = ©Approval + } + } + + return copy +} + +// deepCopyResponsesMessageContentBlock creates a deep copy of a ResponsesMessageContentBlock +func deepCopyResponsesMessageContentBlock(original schemas.ResponsesMessageContentBlock) schemas.ResponsesMessageContentBlock { + copy := schemas.ResponsesMessageContentBlock{ + Type: original.Type, + } + + if original.Text != nil { + copyText := *original.Text + copy.Text = ©Text + } + + // Copy other specific content type fields as needed + if original.ResponsesOutputMessageContentText != nil { + t := *original.ResponsesOutputMessageContentText + // Annotations + if t.Annotations != nil { + t.Annotations = append([]schemas.ResponsesOutputMessageContentTextAnnotation(nil), t.Annotations...) + } + // LogProbs (and their inner slices) + if t.LogProbs != nil { + newLP := make([]schemas.ResponsesOutputMessageContentTextLogProb, len(t.LogProbs)) + for i := range t.LogProbs { + lp := t.LogProbs[i] + if lp.Bytes != nil { + lp.Bytes = append([]int(nil), lp.Bytes...) + } + if lp.TopLogProbs != nil { + lp.TopLogProbs = append([]schemas.LogProb(nil), lp.TopLogProbs...) + } + newLP[i] = lp + } + t.LogProbs = newLP + } + copy.ResponsesOutputMessageContentText = &t + } + + if original.ResponsesOutputMessageContentRefusal != nil { + copyRefusal := schemas.ResponsesOutputMessageContentRefusal{ + Refusal: original.ResponsesOutputMessageContentRefusal.Refusal, + } + copy.ResponsesOutputMessageContentRefusal = ©Refusal + } + + return copy +} + +// buildCompleteMessageFromResponsesStreamChunks builds complete messages from accumulated responses stream chunks +func (a *Accumulator) buildCompleteMessageFromResponsesStreamChunks(chunks []*ResponsesStreamChunk) []schemas.ResponsesMessage { + var messages []schemas.ResponsesMessage + + // Sort chunks by chunk index to ensure correct processing order + sort.Slice(chunks, func(i, j int) bool { + if chunks[i].StreamResponse == nil || chunks[j].StreamResponse == nil { + return false + } + return chunks[i].ChunkIndex < chunks[j].ChunkIndex + }) + + for _, chunk := range chunks { + if chunk.StreamResponse == nil { + continue + } + + resp := chunk.StreamResponse + switch resp.Type { + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + // Always append new items - this fixes multiple function calls issue + if resp.Item != nil { + messages = append(messages, *resp.Item) + } + + case schemas.ResponsesStreamResponseTypeContentPartAdded: + // Add content part to the most recent message, create message if none exists + if resp.Part != nil { + if len(messages) == 0 { + messages = append(messages, createNewMessage()) + } + + lastMsg := &messages[len(messages)-1] + if lastMsg.Content == nil { + lastMsg.Content = &schemas.ResponsesMessageContent{} + } + if lastMsg.Content.ContentBlocks == nil { + lastMsg.Content.ContentBlocks = make([]schemas.ResponsesMessageContentBlock, 0) + } + lastMsg.Content.ContentBlocks = append(lastMsg.Content.ContentBlocks, *resp.Part) + } + + case schemas.ResponsesStreamResponseTypeOutputTextDelta: + if len(messages) == 0 { + messages = append(messages, createNewMessage()) + } + // Append text delta to the most recent message + if resp.Delta != nil && resp.ContentIndex != nil && len(messages) > 0 { + a.appendTextDeltaToResponsesMessage(&messages[len(messages)-1], *resp.Delta, *resp.ContentIndex) + } + + case schemas.ResponsesStreamResponseTypeRefusalDelta: + if len(messages) == 0 { + messages = append(messages, createNewMessage()) + } + // Append refusal delta to the most recent message + if resp.Refusal != nil && resp.ContentIndex != nil && len(messages) > 0 { + a.appendRefusalDeltaToResponsesMessage(&messages[len(messages)-1], *resp.Refusal, *resp.ContentIndex) + } + + case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta: + if len(messages) == 0 { + messages = append(messages, createNewMessage()) + } + if resp.Item != nil { + messages = append(messages, *resp.Item) + } + // Append arguments to the most recent message + if resp.Delta != nil && len(messages) > 0 { + a.appendFunctionArgumentsDeltaToResponsesMessage(&messages[len(messages)-1], *resp.Delta) + } + } + } + + return messages +} + +func createNewMessage() schemas.ResponsesMessage { + return schemas.ResponsesMessage{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: make([]schemas.ResponsesMessageContentBlock, 0), + }, + } +} + +// appendTextDeltaToResponsesMessage appends text delta to a responses message +func (a *Accumulator) appendTextDeltaToResponsesMessage(message *schemas.ResponsesMessage, delta string, contentIndex int) { + if message.Content == nil { + message.Content = &schemas.ResponsesMessageContent{} + } + + // If we don't have content blocks yet, create them + if message.Content.ContentBlocks == nil { + message.Content.ContentBlocks = make([]schemas.ResponsesMessageContentBlock, contentIndex+1) + } + + // Ensure we have enough content blocks + for len(message.Content.ContentBlocks) <= contentIndex { + message.Content.ContentBlocks = append(message.Content.ContentBlocks, schemas.ResponsesMessageContentBlock{}) + } + + // Initialize the content block if needed + if message.Content.ContentBlocks[contentIndex].Type == "" { + message.Content.ContentBlocks[contentIndex].Type = schemas.ResponsesOutputMessageContentTypeText + message.Content.ContentBlocks[contentIndex].ResponsesOutputMessageContentText = &schemas.ResponsesOutputMessageContentText{} + } + + // Append to existing text or create new text + if message.Content.ContentBlocks[contentIndex].Text == nil { + message.Content.ContentBlocks[contentIndex].Text = &delta + } else { + *message.Content.ContentBlocks[contentIndex].Text += delta + } +} + +// appendRefusalDeltaToResponsesMessage appends refusal delta to a responses message +func (a *Accumulator) appendRefusalDeltaToResponsesMessage(message *schemas.ResponsesMessage, refusal string, contentIndex int) { + if message.Content == nil { + message.Content = &schemas.ResponsesMessageContent{} + } + + // If we don't have content blocks yet, create them + if message.Content.ContentBlocks == nil { + message.Content.ContentBlocks = make([]schemas.ResponsesMessageContentBlock, contentIndex+1) + } + + // Ensure we have enough content blocks + for len(message.Content.ContentBlocks) <= contentIndex { + message.Content.ContentBlocks = append(message.Content.ContentBlocks, schemas.ResponsesMessageContentBlock{}) + } + + // Initialize the content block if needed + if message.Content.ContentBlocks[contentIndex].Type == "" { + message.Content.ContentBlocks[contentIndex].Type = schemas.ResponsesOutputMessageContentTypeRefusal + message.Content.ContentBlocks[contentIndex].ResponsesOutputMessageContentRefusal = &schemas.ResponsesOutputMessageContentRefusal{} + } + + // Append to existing refusal text + if message.Content.ContentBlocks[contentIndex].ResponsesOutputMessageContentRefusal == nil { + message.Content.ContentBlocks[contentIndex].ResponsesOutputMessageContentRefusal = &schemas.ResponsesOutputMessageContentRefusal{ + Refusal: refusal, + } + } else { + message.Content.ContentBlocks[contentIndex].ResponsesOutputMessageContentRefusal.Refusal += refusal + } +} + +// appendFunctionArgumentsDeltaToResponsesMessage appends function arguments delta to a responses message +func (a *Accumulator) appendFunctionArgumentsDeltaToResponsesMessage(message *schemas.ResponsesMessage, arguments string) { + if message.ResponsesToolMessage == nil { + message.ResponsesToolMessage = &schemas.ResponsesToolMessage{} + } + + if message.ResponsesToolMessage.Arguments == nil { + message.ResponsesToolMessage.Arguments = &arguments + } else { + *message.ResponsesToolMessage.Arguments += arguments + } +} + +// processAccumulatedResponsesStreamingChunks processes all accumulated responses streaming chunks in order +func (a *Accumulator) processAccumulatedResponsesStreamingChunks(requestID string, respErr *schemas.BifrostError, isFinalChunk bool) (*AccumulatedData, error) { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer func() { + accumulator.mu.Unlock() + if isFinalChunk { + // Before unlocking, we cleanup + defer a.cleanupStreamAccumulator(requestID) + } + }() + + // Initialize accumulated data + data := &AccumulatedData{ + RequestID: requestID, + Status: "success", + Stream: true, + StartTimestamp: accumulator.StartTimestamp, + EndTimestamp: accumulator.FinalTimestamp, + Latency: 0, + OutputMessages: nil, + ToolCalls: nil, + ErrorDetails: respErr, + TokenUsage: nil, + CacheDebug: nil, + Cost: nil, + } + + // Build complete messages from accumulated chunks + completeMessages := a.buildCompleteMessageFromResponsesStreamChunks(accumulator.ResponsesStreamChunks) + + if !isFinalChunk { + data.OutputMessages = completeMessages + return data, nil + } + + // Update database with complete messages + data.Status = "success" + if respErr != nil { + data.Status = "error" + } + + if accumulator.StartTimestamp.IsZero() || accumulator.FinalTimestamp.IsZero() { + data.Latency = 0 + } else { + data.Latency = accumulator.FinalTimestamp.Sub(accumulator.StartTimestamp).Nanoseconds() / 1e6 + } + + data.EndTimestamp = accumulator.FinalTimestamp + data.OutputMessages = completeMessages + + // Extract tool calls from messages + for _, msg := range completeMessages { + if msg.ResponsesToolMessage != nil { + // Add tool call info to accumulated data + // This is simplified - you might want to extract specific tool call info + } + } + + data.ErrorDetails = respErr + + // Update token usage from final chunk if available + if len(accumulator.ResponsesStreamChunks) > 0 { + lastChunk := accumulator.ResponsesStreamChunks[len(accumulator.ResponsesStreamChunks)-1] + if lastChunk.TokenUsage != nil { + data.TokenUsage = lastChunk.TokenUsage + } + // Handle cache debug + if lastChunk.SemanticCacheDebug != nil { + data.CacheDebug = lastChunk.SemanticCacheDebug + } + } + + // Update cost from final chunk if available + if len(accumulator.ResponsesStreamChunks) > 0 { + lastChunk := accumulator.ResponsesStreamChunks[len(accumulator.ResponsesStreamChunks)-1] + if lastChunk.Cost != nil { + data.Cost = lastChunk.Cost + } + data.FinishReason = lastChunk.FinishReason + } + + return data, nil +} + +// processResponsesStreamingResponse processes a responses streaming response +func (a *Accumulator) processResponsesStreamingResponse(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*ProcessedStreamResponse, error) { + a.logger.Debug("[streaming] processing responses streaming response") + + // Extract request ID from context + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + return nil, fmt.Errorf("request-id not found in context or is empty") + } + + _, provider, model := bifrost.GetResponseFields(result, bifrostErr) + + accumulator := a.getOrCreateStreamAccumulator(requestID) + accumulator.mu.Lock() + startTimestamp := accumulator.StartTimestamp + endTimestamp := accumulator.FinalTimestamp + accumulator.mu.Unlock() + + // For OpenAI-compatible providers, the last chunk already contains the whole accumulated response + // so just return it as is + if provider == schemas.OpenAI || provider == schemas.OpenRouter || provider == schemas.Azure { + isFinalChunk := bifrost.IsFinalChunk(ctx) + if isFinalChunk { + // For OpenAI, the final chunk contains the complete response + // Extract the complete response and return it + if result != nil && result.ResponsesStreamResponse != nil { + // Build the complete response from the final chunk + data := &AccumulatedData{ + RequestID: requestID, + Status: "success", + Stream: true, + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + Latency: result.GetExtraFields().Latency, + ErrorDetails: bifrostErr, + } + + if bifrostErr != nil { + data.Status = "error" + } + + // Extract the complete response from the stream response + if result.ResponsesStreamResponse.Response != nil { + data.OutputMessages = result.ResponsesStreamResponse.Response.Output + if result.ResponsesStreamResponse.Response.Usage != nil { + // Convert ResponsesResponseUsage to schemas.LLMUsage + data.TokenUsage = &schemas.BifrostLLMUsage{ + PromptTokens: result.ResponsesStreamResponse.Response.Usage.InputTokens, + CompletionTokens: result.ResponsesStreamResponse.Response.Usage.OutputTokens, + TotalTokens: result.ResponsesStreamResponse.Response.Usage.TotalTokens, + } + } + } + + if a.pricingManager != nil { + cost := a.pricingManager.CalculateCostWithCacheDebug(result) + data.Cost = bifrost.Ptr(cost) + } + + return &ProcessedStreamResponse{ + Type: StreamResponseTypeFinal, + RequestID: requestID, + StreamType: StreamTypeResponses, + Provider: provider, + Model: model, + Data: data, + }, nil + } + } + + // For non-final chunks from OpenAI, just pass through + return &ProcessedStreamResponse{ + Type: StreamResponseTypeDelta, + RequestID: requestID, + StreamType: StreamTypeResponses, + Provider: provider, + Model: model, + Data: nil, // No accumulated data for delta responses + }, nil + } + + // For non-OpenAI providers, use the accumulation logic + isFinalChunk := bifrost.IsFinalChunk(ctx) + chunk := a.getResponsesStreamChunk() + chunk.Timestamp = time.Now() + chunk.ErrorDetails = bifrostErr + + if bifrostErr != nil { + chunk.FinishReason = bifrost.Ptr("error") + } else if result != nil && result.ResponsesStreamResponse != nil { + // Store a deep copy of the stream response to prevent shared data mutation between plugins + chunk.StreamResponse = deepCopyResponsesStreamResponse(result.ResponsesStreamResponse) + // Extract token usage from stream response if available + if result.ResponsesStreamResponse.Response != nil && + result.ResponsesStreamResponse.Response.Usage != nil { + chunk.TokenUsage = &schemas.BifrostLLMUsage{ + PromptTokens: result.ResponsesStreamResponse.Response.Usage.InputTokens, + CompletionTokens: result.ResponsesStreamResponse.Response.Usage.OutputTokens, + TotalTokens: result.ResponsesStreamResponse.Response.Usage.TotalTokens, + } + } + chunk.ChunkIndex = result.ResponsesStreamResponse.ExtraFields.ChunkIndex + if isFinalChunk { + if a.pricingManager != nil { + cost := a.pricingManager.CalculateCostWithCacheDebug(result) + chunk.Cost = bifrost.Ptr(cost) + } + chunk.SemanticCacheDebug = result.GetExtraFields().CacheDebug + } + } + + if addErr := a.addResponsesStreamChunk(requestID, chunk, isFinalChunk); addErr != nil { + return nil, fmt.Errorf("failed to add responses stream chunk for request %s: %w", requestID, addErr) + } + + // If this is the final chunk, process accumulated chunks + if isFinalChunk { + shouldProcess := false + // Get the accumulator to check if processing has already been triggered + accumulator := a.getOrCreateStreamAccumulator(requestID) + accumulator.mu.Lock() + shouldProcess = !accumulator.IsComplete + // Mark as complete when we're about to process + if shouldProcess { + accumulator.IsComplete = true + } + accumulator.mu.Unlock() + + if shouldProcess { + data, processErr := a.processAccumulatedResponsesStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated responses chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + + return &ProcessedStreamResponse{ + Type: StreamResponseTypeFinal, + RequestID: requestID, + StreamType: StreamTypeResponses, + Provider: provider, + Model: model, + Data: data, + }, nil + } + return nil, nil + } + + return &ProcessedStreamResponse{ + Type: StreamResponseTypeDelta, + RequestID: requestID, + StreamType: StreamTypeResponses, + Provider: provider, + Model: model, + Data: nil, + }, nil +} diff --git a/framework/streaming/transcription.go b/framework/streaming/transcription.go new file mode 100644 index 000000000..2ff3d25a3 --- /dev/null +++ b/framework/streaming/transcription.go @@ -0,0 +1,189 @@ +package streaming + +import ( + "context" + "fmt" + "sort" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// buildCompleteMessageFromTranscriptionStreamChunks builds a complete message from accumulated transcription chunks +func (a *Accumulator) buildCompleteMessageFromTranscriptionStreamChunks(chunks []*TranscriptionStreamChunk) *schemas.BifrostTranscriptionResponse { + completeMessage := &schemas.BifrostTranscriptionResponse{} + finalContent := "" + sort.Slice(chunks, func(i, j int) bool { + return chunks[i].ChunkIndex < chunks[j].ChunkIndex + }) + for _, chunk := range chunks { + if chunk.Delta == nil { + continue + } + if chunk.Delta.Type == schemas.TranscriptionStreamResponseTypeDelta && chunk.Delta.Delta != nil { + finalContent += *chunk.Delta.Delta + } + } + // Add final content to the message + completeMessage.Text = finalContent + return completeMessage +} + +// processAccumulatedTranscriptionStreamingChunks processes all accumulated transcription chunks in order +func (a *Accumulator) processAccumulatedTranscriptionStreamingChunks(requestID string, bifrostErr *schemas.BifrostError, isFinalChunk bool) (*AccumulatedData, error) { + accumulator := a.getOrCreateStreamAccumulator(requestID) + // Lock the accumulator + accumulator.mu.Lock() + defer func() { + accumulator.mu.Unlock() + if isFinalChunk { + // Before unlocking, we cleanup + defer a.cleanupStreamAccumulator(requestID) + } + }() + data := &AccumulatedData{ + RequestID: requestID, + Status: "success", + Stream: true, + StartTimestamp: accumulator.StartTimestamp, + EndTimestamp: accumulator.FinalTimestamp, + Latency: 0, + OutputMessage: nil, + ToolCalls: nil, + ErrorDetails: nil, + TokenUsage: nil, + CacheDebug: nil, + Cost: nil, + } + // Build complete message from accumulated chunks + completeMessage := a.buildCompleteMessageFromTranscriptionStreamChunks(accumulator.TranscriptionStreamChunks) + if !isFinalChunk { + data.TranscriptionOutput = completeMessage + return data, nil + } + data.Status = "success" + if bifrostErr != nil { + data.Status = "error" + } + if accumulator.StartTimestamp.IsZero() || accumulator.FinalTimestamp.IsZero() { + data.Latency = 0 + } else { + data.Latency = accumulator.FinalTimestamp.Sub(accumulator.StartTimestamp).Nanoseconds() / 1e6 + } + data.EndTimestamp = accumulator.FinalTimestamp + data.TranscriptionOutput = completeMessage + data.ErrorDetails = bifrostErr + // Update token usage from final chunk if available + if len(accumulator.TranscriptionStreamChunks) > 0 { + lastChunk := accumulator.TranscriptionStreamChunks[len(accumulator.TranscriptionStreamChunks)-1] + if lastChunk.TokenUsage != nil { + data.TokenUsage = &schemas.BifrostLLMUsage{} + if lastChunk.TokenUsage.InputTokens != nil { + data.TokenUsage.PromptTokens = *lastChunk.TokenUsage.InputTokens + } + if lastChunk.TokenUsage.OutputTokens != nil { + data.TokenUsage.CompletionTokens = *lastChunk.TokenUsage.OutputTokens + } + if lastChunk.TokenUsage.TotalTokens != nil { + data.TokenUsage.TotalTokens = *lastChunk.TokenUsage.TotalTokens + } + } + } + // Update cost from final chunk if available + if len(accumulator.TranscriptionStreamChunks) > 0 { + lastChunk := accumulator.TranscriptionStreamChunks[len(accumulator.TranscriptionStreamChunks)-1] + if lastChunk.Cost != nil { + data.Cost = lastChunk.Cost + } + } + // Update semantic cache debug from final chunk if available + if len(accumulator.TranscriptionStreamChunks) > 0 { + lastChunk := accumulator.TranscriptionStreamChunks[len(accumulator.TranscriptionStreamChunks)-1] + if lastChunk.SemanticCacheDebug != nil { + data.CacheDebug = lastChunk.SemanticCacheDebug + } + } + return data, nil +} + +// processTranscriptionStreamingResponse processes a transcription streaming response +func (a *Accumulator) processTranscriptionStreamingResponse(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*ProcessedStreamResponse, error) { + // Extract request ID from context + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + // Log error but don't fail the request + return nil, fmt.Errorf("request-id not found in context or is empty") + } + _, provider, model := bifrost.GetResponseFields(result, bifrostErr) + isFinalChunk := bifrost.IsFinalChunk(ctx) + // For audio, all the data comes in the final chunk + chunk := a.getTranscriptionStreamChunk() + chunk.Timestamp = time.Now() + chunk.ErrorDetails = bifrostErr + if bifrostErr != nil { + chunk.FinishReason = bifrost.Ptr("error") + } else if result != nil && result.TranscriptionStreamResponse != nil { + if result.TranscriptionStreamResponse.Usage != nil { + chunk.TokenUsage = result.TranscriptionStreamResponse.Usage + + // For Transcription, entire delta is sent in the final chunk which also has usage information + // We create a deep copy of the delta to avoid pointing to stack memory + newDelta := &schemas.BifrostTranscriptionStreamResponse{ + Type: result.TranscriptionStreamResponse.Type, + Delta: result.TranscriptionStreamResponse.Delta, + } + chunk.Delta = newDelta + } + chunk.ChunkIndex = result.TranscriptionStreamResponse.ExtraFields.ChunkIndex + if isFinalChunk { + if a.pricingManager != nil { + cost := a.pricingManager.CalculateCostWithCacheDebug(result) + chunk.Cost = bifrost.Ptr(cost) + } + chunk.SemanticCacheDebug = result.GetExtraFields().CacheDebug + } + } + if addErr := a.addTranscriptionStreamChunk(requestID, chunk, isFinalChunk); addErr != nil { + return nil, fmt.Errorf("failed to add stream chunk for request %s: %w", requestID, addErr) + } + if isFinalChunk { + shouldProcess := false + accumulator := a.getOrCreateStreamAccumulator(requestID) + accumulator.mu.Lock() + shouldProcess = !accumulator.IsComplete + if shouldProcess { + accumulator.IsComplete = true + } + accumulator.mu.Unlock() + if shouldProcess { + data, processErr := a.processAccumulatedTranscriptionStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + return &ProcessedStreamResponse{ + Type: StreamResponseTypeFinal, + RequestID: requestID, + StreamType: StreamTypeTranscription, + Provider: provider, + Model: model, + Data: data, + }, nil + } + return nil, nil + } + data, processErr := a.processAccumulatedTranscriptionStreamingChunks(requestID, bifrostErr, isFinalChunk) + if processErr != nil { + a.logger.Error("failed to process accumulated chunks for request %s: %v", requestID, processErr) + return nil, processErr + } + return &ProcessedStreamResponse{ + Type: StreamResponseTypeDelta, + RequestID: requestID, + StreamType: StreamTypeTranscription, + Provider: provider, + Model: model, + Data: data, + }, nil +} diff --git a/framework/streaming/types.go b/framework/streaming/types.go new file mode 100644 index 000000000..eee638824 --- /dev/null +++ b/framework/streaming/types.go @@ -0,0 +1,244 @@ +package streaming + +import ( + "sync" + "time" + + schemas "github.com/maximhq/bifrost/core/schemas" +) + +type StreamType string + +const ( + StreamTypeText StreamType = "text.completion" + StreamTypeChat StreamType = "chat.completion" + StreamTypeAudio StreamType = "audio.speech" + StreamTypeTranscription StreamType = "audio.transcription" + StreamTypeResponses StreamType = "responses" +) + +type StreamResponseType string + +const ( + StreamResponseTypeDelta StreamResponseType = "delta" + StreamResponseTypeFinal StreamResponseType = "final" +) + +// AccumulatedData contains the accumulated data for a stream +type AccumulatedData struct { + RequestID string + Model string + Status string + Stream bool + Latency int64 // in milliseconds + StartTimestamp time.Time + EndTimestamp time.Time + OutputMessage *schemas.ChatMessage + OutputMessages []schemas.ResponsesMessage // For responses API + ToolCalls []schemas.ChatAssistantMessageToolCall + ErrorDetails *schemas.BifrostError + TokenUsage *schemas.BifrostLLMUsage + CacheDebug *schemas.BifrostCacheDebug + Cost *float64 + AudioOutput *schemas.BifrostSpeechResponse + TranscriptionOutput *schemas.BifrostTranscriptionResponse + FinishReason *string +} + +// AudioStreamChunk represents a single streaming chunk +type AudioStreamChunk struct { + Timestamp time.Time // When chunk was received + Delta *schemas.BifrostSpeechStreamResponse // The actual delta content + FinishReason *string // If this is the final chunk + TokenUsage *schemas.SpeechUsage // Token usage if available + SemanticCacheDebug *schemas.BifrostCacheDebug // Semantic cache debug if available + Cost *float64 // Cost in dollars from pricing plugin + ErrorDetails *schemas.BifrostError // Error if any + ChunkIndex int // Index of the chunk in the stream +} + +// TranscriptionStreamChunk represents a single transcription streaming chunk +type TranscriptionStreamChunk struct { + Timestamp time.Time // When chunk was received + Delta *schemas.BifrostTranscriptionStreamResponse // The actual delta content + FinishReason *string // If this is the final chunk + TokenUsage *schemas.TranscriptionUsage // Token usage if available + SemanticCacheDebug *schemas.BifrostCacheDebug // Semantic cache debug if available + Cost *float64 // Cost in dollars from pricing plugin + ErrorDetails *schemas.BifrostError // Error if any + ChunkIndex int // Index of the chunk in the stream +} + +// ChatStreamChunk represents a single streaming chunk +type ChatStreamChunk struct { + Timestamp time.Time // When chunk was received + Delta *schemas.ChatStreamResponseChoiceDelta // The actual delta content + FinishReason *string // If this is the final chunk + TokenUsage *schemas.BifrostLLMUsage // Token usage if available + SemanticCacheDebug *schemas.BifrostCacheDebug // Semantic cache debug if available + Cost *float64 // Cost in dollars from pricing plugin + ErrorDetails *schemas.BifrostError // Error if any + ChunkIndex int // Index of the chunk in the stream +} + +// ResponsesStreamChunk represents a single responses streaming chunk +type ResponsesStreamChunk struct { + Timestamp time.Time // When chunk was received + StreamResponse *schemas.BifrostResponsesStreamResponse // The actual stream response + FinishReason *string // If this is the final chunk + TokenUsage *schemas.BifrostLLMUsage // Token usage if available + SemanticCacheDebug *schemas.BifrostCacheDebug // Semantic cache debug if available + Cost *float64 // Cost in dollars from pricing plugin + ErrorDetails *schemas.BifrostError // Error if any + ChunkIndex int // Index of the chunk in the stream +} + +// StreamAccumulator manages accumulation of streaming chunks +type StreamAccumulator struct { + RequestID string + StartTimestamp time.Time + ChatStreamChunks []*ChatStreamChunk + ResponsesStreamChunks []*ResponsesStreamChunk + TranscriptionStreamChunks []*TranscriptionStreamChunk + AudioStreamChunks []*AudioStreamChunk + IsComplete bool + FinalTimestamp time.Time + mu sync.Mutex + Timestamp time.Time +} + +// ProcessedStreamResponse represents a processed streaming response +type ProcessedStreamResponse struct { + Type StreamResponseType + RequestID string + StreamType StreamType + Provider schemas.ModelProvider + Model string + Data *AccumulatedData +} + +// ToBifrostResponse converts a ProcessedStreamResponse to a BifrostResponse +func (p *ProcessedStreamResponse) ToBifrostResponse() *schemas.BifrostResponse { + resp := &schemas.BifrostResponse{} + + switch p.StreamType { + case StreamTypeText: + text := "" + if p.Data.OutputMessage != nil && p.Data.OutputMessage.Content != nil && p.Data.OutputMessage.Content.ContentStr != nil { + text = *p.Data.OutputMessage.Content.ContentStr + } + textResp := &schemas.BifrostTextCompletionResponse{ + ID: p.RequestID, + Object: "text_completion", + Model: p.Model, + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + FinishReason: p.Data.FinishReason, + TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{ + Text: &text, + }, + }, + }, + Usage: p.Data.TokenUsage, + } + + resp.TextCompletionResponse = textResp + resp.TextCompletionResponse.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.TextCompletionRequest, + Provider: p.Provider, + ModelRequested: p.Model, + Latency: p.Data.Latency, + } + case StreamTypeChat: + chatResp := &schemas.BifrostChatResponse{ + ID: p.RequestID, + Object: "chat.completion", + Model: p.Model, + Created: int(p.Data.StartTimestamp.Unix()), + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + FinishReason: p.Data.FinishReason, + }, + }, + Usage: p.Data.TokenUsage, + } + + // Get reference to the choice in the slice so we can modify it + choice := &chatResp.Choices[0] + + if p.Data.OutputMessage.Content.ContentStr != nil { + choice.ChatNonStreamResponseChoice = &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: p.Data.OutputMessage.Content.ContentStr, + }, + }, + } + } + if p.Data.OutputMessage.ChatAssistantMessage != nil { + if choice.ChatNonStreamResponseChoice == nil { + choice.ChatNonStreamResponseChoice = &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + ChatAssistantMessage: p.Data.OutputMessage.ChatAssistantMessage, + }, + } + } else { + // If we already have a message, we need to add the ChatAssistantMessage to it + choice.ChatNonStreamResponseChoice.Message.ChatAssistantMessage = p.Data.OutputMessage.ChatAssistantMessage + } + } + + resp.ChatResponse = chatResp + resp.ChatResponse.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: p.Provider, + ModelRequested: p.Model, + Latency: p.Data.Latency, + } + case StreamTypeResponses: + responsesResp := &schemas.BifrostResponsesResponse{} + + if p.Data.OutputMessages != nil { + responsesResp.Output = p.Data.OutputMessages + } + if p.Data.TokenUsage != nil { + responsesResp.Usage = p.Data.TokenUsage.ToResponsesResponseUsage() + } + responsesResp.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesRequest, + Provider: p.Provider, + ModelRequested: p.Model, + Latency: p.Data.Latency, + } + resp.ResponsesResponse = responsesResp + case StreamTypeAudio: + speechResp := p.Data.AudioOutput + if speechResp == nil { + speechResp = &schemas.BifrostSpeechResponse{} + } + resp.SpeechResponse = speechResp + resp.SpeechResponse.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.SpeechRequest, + Provider: p.Provider, + ModelRequested: p.Model, + Latency: p.Data.Latency, + } + case StreamTypeTranscription: + transcriptionResp := p.Data.TranscriptionOutput + if transcriptionResp == nil { + transcriptionResp = &schemas.BifrostTranscriptionResponse{} + } + resp.TranscriptionResponse = transcriptionResp + resp.TranscriptionResponse.ExtraFields = schemas.BifrostResponseExtraFields{ + RequestType: schemas.TranscriptionRequest, + Provider: p.Provider, + ModelRequested: p.Model, + Latency: p.Data.Latency, + } + } + return resp +} diff --git a/framework/vectorstore/errors.go b/framework/vectorstore/errors.go new file mode 100644 index 000000000..ffcd9cf41 --- /dev/null +++ b/framework/vectorstore/errors.go @@ -0,0 +1,8 @@ +package vectorstore + +import "errors" + +var ( + ErrNotFound = errors.New("vectorstore: not found") + ErrNotSupported = errors.New("vectorstore: operation not supported on this store") +) diff --git a/framework/vectorstore/redis.go b/framework/vectorstore/redis.go new file mode 100644 index 000000000..389a40b04 --- /dev/null +++ b/framework/vectorstore/redis.go @@ -0,0 +1,857 @@ +package vectorstore + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/redis/go-redis/v9" +) + +const ( + // defaultLimit is the default limit used for pagination and batch operations + BatchLimit = 100 +) + +type RedisConfig struct { + // Connection settings + Addr string `json:"addr"` // Redis server address (host:port) - REQUIRED + Username string `json:"username,omitempty"` // Username for Redis AUTH (optional) + Password string `json:"password,omitempty"` // Password for Redis AUTH (optional) + DB int `json:"db,omitempty"` // Redis database number (default: 0) + + // Connection pool and timeout settings (passed directly to Redis client) + PoolSize int `json:"pool_size,omitempty"` // Maximum number of socket connections (optional) + MaxActiveConns int `json:"max_active_conns,omitempty"` // Maximum number of active connections (optional) + MinIdleConns int `json:"min_idle_conns,omitempty"` // Minimum number of idle connections (optional) + MaxIdleConns int `json:"max_idle_conns,omitempty"` // Maximum number of idle connections (optional) + ConnMaxLifetime time.Duration `json:"conn_max_lifetime,omitempty"` // Connection maximum lifetime (optional) + ConnMaxIdleTime time.Duration `json:"conn_max_idle_time,omitempty"` // Connection maximum idle time (optional) + DialTimeout time.Duration `json:"dial_timeout,omitempty"` // Timeout for socket connection (optional) + ReadTimeout time.Duration `json:"read_timeout,omitempty"` // Timeout for socket reads (optional) + WriteTimeout time.Duration `json:"write_timeout,omitempty"` // Timeout for socket writes (optional) + ContextTimeout time.Duration `json:"context_timeout,omitempty"` // Timeout for Redis operations (optional) +} + +// RedisStore represents the Redis vector store. +type RedisStore struct { + client *redis.Client + config RedisConfig + logger schemas.Logger +} + +// Ping checks if the Redis server is reachable. +func (s *RedisStore) Ping(ctx context.Context) error { + return s.client.Ping(ctx).Err() +} + +// CreateNamespace creates a new namespace in the Redis vector store. +func (s *RedisStore) CreateNamespace(ctx context.Context, namespace string, dimension int, properties map[string]VectorStoreProperties) error { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + // Check if index already exists + infoResult := s.client.Do(ctx, "FT.INFO", namespace) + if infoResult.Err() == nil { + return nil // Index already exists + } + if err := infoResult.Err(); err != nil && strings.Contains(strings.ToLower(err.Error()), "unknown command") { + return fmt.Errorf("RediSearch module not available: please use Redis Stack or enable RediSearch (FT.*). Original error: %w", err) + } + + // Extract metadata field names from properties + var metadataFields []string + for fieldName := range properties { + metadataFields = append(metadataFields, fieldName) + } + + // Create index with VECTOR field + metadata fields + keyPrefix := fmt.Sprintf("%s:", namespace) + + if dimension <= 0 { + return fmt.Errorf("redis vector index %q: dimension must be > 0 (got %d)", namespace, dimension) + } + + args := []interface{}{ + "FT.CREATE", namespace, + "ON", "HASH", + "PREFIX", "1", keyPrefix, + "SCHEMA", + // Native vector field with HNSW algorithm + "embedding", "VECTOR", "HNSW", "6", + "TYPE", "FLOAT32", + "DIM", dimension, + "DISTANCE_METRIC", "COSINE", + } + + // Add all metadata fields as TEXT with exact matching + // All values are converted to strings for consistent searching + for _, field := range metadataFields { + // Detect field type from VectorStoreProperties + prop := properties[field] + switch prop.DataType { + case VectorStorePropertyTypeInteger: + args = append(args, field, "NUMERIC") + default: + args = append(args, field, "TAG") + } + } + + // Create the index + if err := s.client.Do(ctx, args...).Err(); err != nil { + return fmt.Errorf("failed to create semantic vector index %s: %w", namespace, err) + } + + return nil +} + +// GetChunk retrieves a chunk from the Redis vector store. +func (s *RedisStore) GetChunk(ctx context.Context, namespace string, id string) (SearchResult, error) { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + if strings.TrimSpace(id) == "" { + return SearchResult{}, fmt.Errorf("id is required") + } + + // Create key with namespace + key := buildKey(namespace, id) + + // Get all fields from the hash + result := s.client.HGetAll(ctx, key) + if result.Err() != nil { + return SearchResult{}, fmt.Errorf("failed to get chunk: %w", result.Err()) + } + + fields := result.Val() + if len(fields) == 0 { + return SearchResult{}, fmt.Errorf("chunk not found: %s", id) + } + + // Build SearchResult + searchResult := SearchResult{ + ID: id, + Properties: make(map[string]interface{}), + } + + // Parse fields + for k, v := range fields { + searchResult.Properties[k] = v + } + + return searchResult, nil +} + +// GetChunks retrieves multiple chunks from the Redis vector store. +func (s *RedisStore) GetChunks(ctx context.Context, namespace string, ids []string) ([]SearchResult, error) { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + if len(ids) == 0 { + return []SearchResult{}, nil + } + + // Create keys with namespace + keys := make([]string, len(ids)) + for i, id := range ids { + if strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("id cannot be empty at index %d", i) + } + keys[i] = buildKey(namespace, id) + } + + // Use pipeline for efficient batch retrieval + pipe := s.client.Pipeline() + cmds := make([]*redis.MapStringStringCmd, len(keys)) + + for i, key := range keys { + cmds[i] = pipe.HGetAll(ctx, key) + } + + // Execute pipeline + _, err := pipe.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to execute pipeline: %w", err) + } + + // Process results + var results []SearchResult + for i, cmd := range cmds { + if cmd.Err() != nil { + // Log error but continue with other results + s.logger.Debug(fmt.Sprintf("failed to get chunk %s: %v", ids[i], cmd.Err())) + continue + } + + fields := cmd.Val() + if len(fields) == 0 { + // Chunk not found, skip + continue + } + + // Build SearchResult + searchResult := SearchResult{ + ID: ids[i], + Properties: make(map[string]interface{}), + } + + // Parse fields + for k, v := range fields { + searchResult.Properties[k] = v + } + + results = append(results, searchResult) + } + + return results, nil +} + +// GetAll retrieves all chunks from the Redis vector store. +func (s *RedisStore) GetAll(ctx context.Context, namespace string, queries []Query, selectFields []string, cursor *string, limit int64) ([]SearchResult, *string, error) { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + // Set default limit if not provided + if limit < 0 { + limit = BatchLimit + } + + // Build Redis query from the provided queries + redisQuery := buildRedisQuery(queries) + + // Build FT.SEARCH command + args := []interface{}{ + "FT.SEARCH", namespace, + redisQuery, + } + + // Add RETURN only if specific fields were requested + if len(selectFields) > 0 { + args = append(args, "RETURN", len(selectFields)) + for _, field := range selectFields { + args = append(args, field) + } + } + + // Add LIMIT clause - use large limit for "all" (limit=0) + searchLimit := limit + if limit == 0 { + searchLimit = math.MaxInt32 // Use large limit to get all results + } + + // Add OFFSET for pagination if cursor is provided + offset := 0 + if cursor != nil && *cursor != "" { + if parsedOffset, err := strconv.ParseInt(*cursor, 10, 64); err == nil { + offset = int(parsedOffset) + } + } + + args = append(args, "LIMIT", offset, int(searchLimit), "DIALECT", "2") + + // Execute search + result := s.client.Do(ctx, args...) + if result.Err() != nil { + return nil, nil, fmt.Errorf("failed to search: %w", result.Err()) + } + + // Parse search results + results, err := s.parseSearchResults(result.Val(), selectFields) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse search results: %w", err) + } + + // Implement cursor-based pagination using OFFSET + var nextCursor *string = nil + if cursor != nil && *cursor != "" { + // If we have a cursor, we've already applied pagination + // Check if there might be more results + if len(results) == int(limit) && limit > 0 { + // There might be more results, create next cursor + offset, err := strconv.ParseInt(*cursor, 10, 64) + if err == nil { + nextOffset := offset + limit + nextCursorStr := strconv.FormatInt(nextOffset, 10) + nextCursor = &nextCursorStr + } + } + } else if len(results) == int(limit) && limit > 0 { + // First page and we got exactly the limit, there might be more + nextCursorStr := strconv.FormatInt(limit, 10) + nextCursor = &nextCursorStr + } + + return results, nextCursor, nil +} + +// parseSearchResults parses FT.SEARCH results into SearchResult slice +func (s *RedisStore) parseSearchResults(result interface{}, selectFields []string) ([]SearchResult, error) { + // FT.SEARCH returns a map with results array + resultMap, ok := result.(map[interface{}]interface{}) + if !ok { + return []SearchResult{}, nil + } + + resultsArray, ok := resultMap["results"].([]interface{}) + if !ok { + return []SearchResult{}, nil + } + + results := []SearchResult{} + + for _, resultItem := range resultsArray { + resultMap, ok := resultItem.(map[interface{}]interface{}) + if !ok { + continue + } + + // Get the document ID + id, ok := resultMap["id"].(string) + if !ok { + continue + } + + // Extract ID from key (remove namespace prefix) + keyParts := strings.Split(id, ":") + if len(keyParts) < 2 { + continue + } + documentID := strings.Join(keyParts[1:], ":") // Handle IDs that might contain colons + + // Get the extra_attributes (metadata) + extraAttributes, ok := resultMap["extra_attributes"].(map[interface{}]interface{}) + if !ok { + continue + } + + // Build SearchResult + searchResult := SearchResult{ + ID: documentID, + Properties: make(map[string]interface{}), + } + + // Parse extra_attributes + for fieldNameInterface, fieldValue := range extraAttributes { + fieldName, ok := fieldNameInterface.(string) + if !ok { + continue + } + + // Always include score field for vector searches + if fieldName == "score" { + searchResult.Properties[fieldName] = fieldValue + // Also set the Score field for proper access + if scoreFloat, ok := fieldValue.(float64); ok { + searchResult.Score = &scoreFloat + } + continue + } + + // Apply field selection if specified + if len(selectFields) > 0 { + // Check if this field should be included + include := false + for _, selectField := range selectFields { + if fieldName == selectField { + include = true + break + } + } + if !include { + continue + } + } + + searchResult.Properties[fieldName] = fieldValue + } + + results = append(results, searchResult) + } + + return results, nil +} + +// buildRedisQuery converts []Query to Redis query syntax +func buildRedisQuery(queries []Query) string { + if len(queries) == 0 { + return "*" + } + + var conditions []string + for _, query := range queries { + condition := buildRedisQueryCondition(query) + if condition != "" { + conditions = append(conditions, condition) + } + } + + if len(conditions) == 0 { + return "*" + } + + // Join conditions with space (AND operation in Redis) + return strings.Join(conditions, " ") +} + +// buildRedisQueryCondition builds a single Redis query condition +func buildRedisQueryCondition(query Query) string { + field := query.Field + operator := query.Operator + value := query.Value + + // Convert value to string + var stringValue string + switch val := value.(type) { + case string: + stringValue = val + case int, int64, float64, bool: + stringValue = fmt.Sprintf("%v", val) + default: + jsonData, _ := json.Marshal(val) + stringValue = string(jsonData) + } + + // Escape special characters for TAG fields + escapedValue := escapeSearchValue(stringValue) // new function for TAG escaping + + switch operator { + case QueryOperatorEqual: + // TAG exact match + return fmt.Sprintf("@%s:{%s}", field, escapedValue) + case QueryOperatorNotEqual: + // TAG negation + return fmt.Sprintf("-@%s:{%s}", field, escapedValue) + case QueryOperatorLike: + // Cannot do LIKE with TAGs directly; fallback to exact match + return fmt.Sprintf("@%s:{%s}", field, escapedValue) + case QueryOperatorGreaterThan: + return fmt.Sprintf("@%s:[(%s +inf]", field, escapedValue) + case QueryOperatorGreaterThanOrEqual: + return fmt.Sprintf("@%s:[%s +inf]", field, escapedValue) + case QueryOperatorLessThan: + return fmt.Sprintf("@%s:[-inf (%s]", field, escapedValue) + case QueryOperatorLessThanOrEqual: + return fmt.Sprintf("@%s:[-inf %s]", field, escapedValue) + case QueryOperatorIsNull: + // Field not present + return fmt.Sprintf("-@%s:*", field) + case QueryOperatorIsNotNull: + // Field exists + return fmt.Sprintf("@%s:*", field) + case QueryOperatorContainsAny: + if values, ok := value.([]interface{}); ok { + var orConditions []string + for _, v := range values { + vStr := fmt.Sprintf("%v", v) + orConditions = append(orConditions, fmt.Sprintf("@%s:{%s}", field, escapeSearchValue(vStr))) + } + return fmt.Sprintf("(%s)", strings.Join(orConditions, " | ")) + } + return fmt.Sprintf("@%s:{%s}", field, escapedValue) + case QueryOperatorContainsAll: + if values, ok := value.([]interface{}); ok { + var andConditions []string + for _, v := range values { + vStr := fmt.Sprintf("%v", v) + andConditions = append(andConditions, fmt.Sprintf("@%s:{%s}", field, escapeSearchValue(vStr))) + } + return strings.Join(andConditions, " ") + } + return fmt.Sprintf("@%s:{%s}", field, escapedValue) + default: + return fmt.Sprintf("@%s:{%s}", field, escapedValue) + } +} + +// GetNearest retrieves the nearest chunks from the Redis vector store. +func (s *RedisStore) GetNearest(ctx context.Context, namespace string, vector []float32, queries []Query, selectFields []string, threshold float64, limit int64) ([]SearchResult, error) { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + // Build Redis query from the provided queries + redisQuery := buildRedisQuery(queries) + + // Convert query embedding to binary format + queryBytes := float32SliceToBytes(vector) + + // Build hybrid FT.SEARCH query: metadata filters + KNN vector search + // The correct syntax is: (metadata_filter)=>[KNN k @embedding $vec AS score] + var hybridQuery string + if len(queries) > 0 { + // Wrap metadata query in parentheses for hybrid syntax + hybridQuery = fmt.Sprintf("(%s)", redisQuery) + } else { + // Wildcard for pure vector search + hybridQuery = "*" + } + + // Execute FT.SEARCH with KNN + // Use large limit for "all" (limit=0) in KNN query + knnLimit := limit + if limit == 0 { + knnLimit = math.MaxInt32 + } + + args := []interface{}{ + "FT.SEARCH", namespace, + fmt.Sprintf("%s=>[KNN %d @embedding $vec AS score]", hybridQuery, knnLimit), + "PARAMS", "2", "vec", queryBytes, + "SORTBY", "score", + } + + // Add RETURN clause - always include score for vector search + // For vector search, we need to include the score field generated by KNN + returnFields := []string{"score"} + if len(selectFields) > 0 { + returnFields = append(returnFields, selectFields...) + } + + args = append(args, "RETURN", len(returnFields)) + for _, field := range returnFields { + args = append(args, field) + } + + // Add LIMIT clause and DIALECT 2 for better query parsing + searchLimit := limit + if limit == 0 { + searchLimit = math.MaxInt32 + } + args = append(args, "LIMIT", 0, int(searchLimit), "DIALECT", "2") + + result := s.client.Do(ctx, args...) + if result.Err() != nil { + return nil, fmt.Errorf("native vector search failed: %w", result.Err()) + } + + // Parse search results + results, err := s.parseSearchResults(result.Val(), selectFields) + if err != nil { + return nil, err + } + + // Apply threshold filter and extract scores + var filteredResults []SearchResult + for _, result := range results { + // Extract score from the result + if scoreValue, exists := result.Properties["score"]; exists { + var score float64 + switch v := scoreValue.(type) { + case float64: + score = v + case float32: + score = float64(v) + case int: + score = float64(v) + case int64: + score = float64(v) + case string: + if parsedScore, err := strconv.ParseFloat(v, 64); err == nil { + score = parsedScore + } + } + + // Convert cosine distance to similarity: similarity = 1 - distance + similarity := 1.0 - score + result.Score = &similarity + + // Apply threshold filter + if similarity >= threshold { + filteredResults = append(filteredResults, result) + } + } else { + // If no score, include the result (shouldn't happen with KNN queries) + filteredResults = append(filteredResults, result) + } + } + + results = filteredResults + + return results, nil +} + +// Add stores a new chunk in the Redis vector store. +func (s *RedisStore) Add(ctx context.Context, namespace string, id string, embedding []float32, metadata map[string]interface{}) error { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + if strings.TrimSpace(id) == "" { + return fmt.Errorf("id is required") + } + + // Create key with namespace + key := buildKey(namespace, id) + + // Prepare hash fields: binary embedding + metadata + fields := make(map[string]interface{}) + + // Only add embedding if it's not empty + if len(embedding) > 0 { + // Convert float32 slice to bytes for Redis storage + embeddingBytes := float32SliceToBytes(embedding) + fields["embedding"] = embeddingBytes + } + + // Add metadata fields directly (no prefix needed with proper indexing) + for k, v := range metadata { + switch val := v.(type) { + case string: + fields[k] = val + case int, int64, float64, bool: + fields[k] = fmt.Sprintf("%v", val) + case []interface{}: + // Preserve arrays as JSON to support round-trips (e.g., stream_chunks) + b, err := json.Marshal(val) + if err != nil { + return fmt.Errorf("failed to marshal array metadata %s: %w", k, err) + } + fields[k] = string(b) + default: + // JSON encode complex types + jsonData, err := json.Marshal(val) + if err != nil { + return fmt.Errorf("failed to marshal metadata field %s: %w", k, err) + } + fields[k] = string(jsonData) + } + } + + // Store as hash for efficient native vector search + if err := s.client.HSet(ctx, key, fields).Err(); err != nil { + return fmt.Errorf("failed to store semantic cache entry: %w", err) + } + + return nil +} + +// Delete deletes a chunk from the Redis vector store. +func (s *RedisStore) Delete(ctx context.Context, namespace string, id string) error { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + if strings.TrimSpace(id) == "" { + return fmt.Errorf("id is required") + } + + // Create key with namespace + key := buildKey(namespace, id) + + // Delete the hash key + result := s.client.Del(ctx, key) + if result.Err() != nil { + return fmt.Errorf("failed to delete chunk %s: %w", id, result.Err()) + } + + // Check if the key actually existed + if result.Val() == 0 { + return fmt.Errorf("chunk not found: %s", id) + } + + return nil +} + +// DeleteAll deletes all chunks from the Redis vector store. +func (s *RedisStore) DeleteAll(ctx context.Context, namespace string, queries []Query) ([]DeleteResult, error) { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + // Use cursor-based deletion to handle large datasets efficiently + return s.deleteAllWithCursor(ctx, namespace, queries, nil) +} + +// deleteAllWithCursor performs cursor-based deletion for large datasets +func (s *RedisStore) deleteAllWithCursor(ctx context.Context, namespace string, queries []Query, cursor *string) ([]DeleteResult, error) { + // Get a batch of documents to delete (using pagination) + results, nextCursor, err := s.GetAll(ctx, namespace, queries, []string{}, cursor, BatchLimit) + if err != nil { + return nil, fmt.Errorf("failed to find documents to delete: %w", err) + } + + if len(results) == 0 { + return []DeleteResult{}, nil + } + + // Extract IDs from results + ids := make([]string, len(results)) + for i, result := range results { + ids[i] = result.ID + } + + // Delete this batch of documents + var deleteResults []DeleteResult + batchSize := BatchLimit // Process in batches to avoid overwhelming Redis + + for i := 0; i < len(ids); i += batchSize { + end := i + batchSize + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + + // Create pipeline for batch deletion + pipe := s.client.Pipeline() + cmds := make([]*redis.IntCmd, len(batch)) + + for j, id := range batch { + key := buildKey(namespace, id) + cmds[j] = pipe.Del(ctx, key) + } + + // Execute pipeline + _, err := pipe.Exec(ctx) + if err != nil { + // If pipeline fails, mark all in this batch as failed + for _, id := range batch { + deleteResults = append(deleteResults, DeleteResult{ + ID: id, + Status: DeleteStatusError, + Error: fmt.Sprintf("pipeline execution failed: %v", err), + }) + } + continue + } + + // Process results for this batch + for j, cmd := range cmds { + id := batch[j] + if cmd.Err() != nil { + deleteResults = append(deleteResults, DeleteResult{ + ID: id, + Status: DeleteStatusError, + Error: cmd.Err().Error(), + }) + } else if cmd.Val() > 0 { + // Key existed and was deleted + deleteResults = append(deleteResults, DeleteResult{ + ID: id, + Status: DeleteStatusSuccess, + }) + } else { + // Key didn't exist + deleteResults = append(deleteResults, DeleteResult{ + ID: id, + Status: DeleteStatusError, + Error: "document not found", + }) + } + } + } + + // If there are more results, continue with next cursor + if nextCursor != nil { + nextResults, err := s.deleteAllWithCursor(ctx, namespace, queries, nextCursor) + if err != nil { + return nil, fmt.Errorf("failed to delete remaining documents: %w", err) + } + // Combine results from this batch and subsequent batches + deleteResults = append(deleteResults, nextResults...) + } + + return deleteResults, nil +} + +// DeleteNamespace deletes a namespace from the Redis vector store. +func (s *RedisStore) DeleteNamespace(ctx context.Context, namespace string) error { + ctx, cancel := withTimeout(ctx, s.config.ContextTimeout) + defer cancel() + + // Drop the index using FT.DROPINDEX + if err := s.client.Do(ctx, "FT.DROPINDEX", namespace).Err(); err != nil { + // Check if error is "Unknown Index name" - that's OK, index doesn't exist + if strings.Contains(err.Error(), "Unknown Index name") { + return nil // Index doesn't exist, nothing to drop + } + return fmt.Errorf("failed to drop semantic index %s: %w", namespace, err) + } + + return nil +} + +// Close closes the Redis vector store. +func (s *RedisStore) Close(ctx context.Context, namespace string) error { + // Close the Redis client connection + return s.client.Close() +} + +// escapeSearchValue escapes special characters in search values. +func escapeSearchValue(value string) string { + // Escape special RediSearch characters + replacer := strings.NewReplacer( + "(", "\\(", + ")", "\\)", + "[", "\\[", + "]", "\\]", + "{", "\\{", + "}", "\\}", + "*", "\\*", + "?", "\\?", + "|", "\\|", + "&", "\\&", + "!", "\\!", + "@", "\\@", + "#", "\\#", + "$", "\\$", + "%", "\\%", + "^", "\\^", + "~", "\\~", + "`", "\\`", + "\"", "\\\"", + "'", "\\'", + " ", "\\ ", + "-", "\\-", + ",", "|", + ) + return replacer.Replace(value) +} + +// Binary embedding conversion helpers +func float32SliceToBytes(floats []float32) []byte { + bytes := make([]byte, len(floats)*4) + for i, f := range floats { + binary.LittleEndian.PutUint32(bytes[i*4:], math.Float32bits(f)) + } + return bytes +} + +// buildKey creates a Redis key by combining namespace and id. +func buildKey(namespace, id string) string { + return fmt.Sprintf("%s:%s", namespace, id) +} + +// newRedisStore creates a new Redis vector store. +func newRedisStore(ctx context.Context, config RedisConfig, logger schemas.Logger) (*RedisStore, error) { + // Validate required fields + if config.Addr == "" { + return nil, fmt.Errorf("redis addr is required") + } + + client := redis.NewClient(&redis.Options{ + Addr: config.Addr, + Username: config.Username, + Password: config.Password, + DB: config.DB, + Protocol: 3, // Explicitly use RESP3 protocol + PoolSize: config.PoolSize, + MaxActiveConns: config.MaxActiveConns, + MinIdleConns: config.MinIdleConns, + MaxIdleConns: config.MaxIdleConns, + ConnMaxLifetime: config.ConnMaxLifetime, + ConnMaxIdleTime: config.ConnMaxIdleTime, + DialTimeout: config.DialTimeout, + ReadTimeout: config.ReadTimeout, + WriteTimeout: config.WriteTimeout, + }) + + store := &RedisStore{ + client: client, + config: config, + logger: logger, + } + + return store, nil +} diff --git a/framework/vectorstore/redis_test.go b/framework/vectorstore/redis_test.go new file mode 100644 index 000000000..94052346f --- /dev/null +++ b/framework/vectorstore/redis_test.go @@ -0,0 +1,889 @@ +package vectorstore + +import ( + "context" + "os" + "testing" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test constants +const ( + RedisTestTimeout = 30 * time.Second + TestNamespace = "TestRedis" + DefaultTestAddr = "localhost:6379" + DefaultRedisTestTimeout = 10 * time.Second + RedisTestDimension = 1536 +) + +// TestSetup provides common test infrastructure +type RedisTestSetup struct { + Store *RedisStore + Logger schemas.Logger + Config RedisConfig + ctx context.Context + cancel context.CancelFunc +} + +// NewRedisTestSetup creates a test setup with environment-driven configuration +func NewRedisTestSetup(t *testing.T) *RedisTestSetup { + // Get configuration from environment variables + addr := getEnvWithDefault("REDIS_ADDR", DefaultTestAddr) + username := os.Getenv("REDIS_USERNAME") + password := os.Getenv("REDIS_PASSWORD") + db, err := getEnvWithDefaultInt("REDIS_DB", 0) + if err != nil { + t.Fatalf("Failed to get REDIS_DB: %v", err) + } + + timeoutStr := getEnvWithDefault("REDIS_TIMEOUT", "10s") + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + timeout = DefaultRedisTestTimeout + } + + config := RedisConfig{ + Addr: addr, + Username: username, + Password: password, + DB: db, + ContextTimeout: timeout, + } + + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + ctx, cancel := context.WithTimeout(context.Background(), RedisTestTimeout) + + store, err := newRedisStore(ctx, config, logger) + if err != nil { + cancel() + t.Fatalf("Failed to create Redis store: %v", err) + } + + setup := &RedisTestSetup{ + Store: store, + Logger: logger, + Config: config, + ctx: ctx, + cancel: cancel, + } + + // Ensure namespace exists for integration tests + if !testing.Short() { + setup.ensureNamespaceExists(t) + } + + return setup +} + +// Cleanup cleans up test resources +func (ts *RedisTestSetup) Cleanup(t *testing.T) { + defer ts.cancel() + + if !testing.Short() { + // Clean up test data + ts.cleanupTestData(t) + } + + if err := ts.Store.Close(ts.ctx, TestNamespace); err != nil { + t.Logf("Warning: Failed to close store: %v", err) + } +} + +// ensureNamespaceExists creates the test namespace in Redis +func (ts *RedisTestSetup) ensureNamespaceExists(t *testing.T) { + // Create namespace with test properties + properties := map[string]VectorStoreProperties{ + "key": { + DataType: VectorStorePropertyTypeString, + }, + "type": { + DataType: VectorStorePropertyTypeString, + }, + "test_type": { + DataType: VectorStorePropertyTypeString, + }, + "size": { + DataType: VectorStorePropertyTypeInteger, + }, + "public": { + DataType: VectorStorePropertyTypeBoolean, + }, + "author": { + DataType: VectorStorePropertyTypeString, + }, + "request_hash": { + DataType: VectorStorePropertyTypeString, + }, + "user": { + DataType: VectorStorePropertyTypeString, + }, + "lang": { + DataType: VectorStorePropertyTypeString, + }, + "category": { + DataType: VectorStorePropertyTypeString, + }, + "content": { + DataType: VectorStorePropertyTypeString, + }, + "response": { + DataType: VectorStorePropertyTypeString, + }, + "from_bifrost_semantic_cache_plugin": { + DataType: VectorStorePropertyTypeBoolean, + }, + } + + err := ts.Store.CreateNamespace(ts.ctx, TestNamespace, RedisTestDimension, properties) + if err != nil { + t.Fatalf("Failed to create namespace %q: %v", TestNamespace, err) + } + t.Logf("Created test namespace: %s", TestNamespace) +} + +// cleanupTestData removes all test objects from the namespace +func (ts *RedisTestSetup) cleanupTestData(t *testing.T) { + // Delete all objects in the test namespace + allTestKeys, _, err := ts.Store.GetAll(ts.ctx, TestNamespace, []Query{}, []string{}, nil, 1000) + if err != nil { + t.Logf("Warning: Failed to get all test keys: %v", err) + return + } + + for _, key := range allTestKeys { + err := ts.Store.Delete(ts.ctx, TestNamespace, key.ID) + if err != nil { + t.Logf("Warning: Failed to delete test key %s: %v", key.ID, err) + } + } + + t.Logf("Cleaned up test namespace: %s", TestNamespace) +} + +// ============================================================================ +// UNIT TESTS +// ============================================================================ + +func TestRedisConfig_Validation(t *testing.T) { + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + ctx := context.Background() + + tests := []struct { + name string + config RedisConfig + expectError bool + errorMsg string + }{ + { + name: "valid config", + config: RedisConfig{ + Addr: "localhost:6379", + }, + expectError: false, + }, + { + name: "missing addr", + config: RedisConfig{ + Username: "user", + }, + expectError: true, + errorMsg: "redis addr is required", + }, + { + name: "with credentials", + config: RedisConfig{ + Addr: "localhost:6379", + Username: "default", + Password: "", + }, + expectError: false, + }, + { + name: "with custom db", + config: RedisConfig{ + Addr: "localhost:6379", + DB: 1, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, err := newRedisStore(ctx, tt.config, logger) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, store) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + // For valid configs, store creation should succeed + // (connection will fail later when actually using Redis) + assert.NoError(t, err) + assert.NotNil(t, store) + } + }) + } +} + +// ============================================================================ +// INTEGRATION TESTS (require real Redis instance with RediSearch) +// ============================================================================ + +func TestRedisStore_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + t.Run("Add and GetChunk", func(t *testing.T) { + testKey := generateUUID() + embedding := generateTestEmbedding(RedisTestDimension) + metadata := map[string]interface{}{ + "type": "document", + "size": 1024, + "public": true, + } + + // Add object + err := setup.Store.Add(setup.ctx, TestNamespace, testKey, embedding, metadata) + require.NoError(t, err) + + // Small delay to ensure consistency + time.Sleep(100 * time.Millisecond) + + // Get single chunk + result, err := setup.Store.GetChunk(setup.ctx, TestNamespace, testKey) + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Equal(t, "document", result.Properties["type"]) // Should contain metadata + }) + + t.Run("Add without embedding", func(t *testing.T) { + testKey := generateUUID() + metadata := map[string]interface{}{ + "type": "metadata-only", + } + + // Add object without embedding + err := setup.Store.Add(setup.ctx, TestNamespace, testKey, nil, metadata) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Retrieve it + result, err := setup.Store.GetChunk(setup.ctx, TestNamespace, testKey) + require.NoError(t, err) + assert.Equal(t, "metadata-only", result.Properties["type"]) + }) + + t.Run("GetChunks batch retrieval", func(t *testing.T) { + // Add multiple objects + keys := []string{generateUUID(), generateUUID(), generateUUID()} + embeddings := [][]float32{ + generateTestEmbedding(RedisTestDimension), + generateTestEmbedding(RedisTestDimension), + nil, + } + metadata := []map[string]interface{}{ + {"type": "doc1", "size": 100}, + {"type": "doc2", "size": 200}, + {"type": "doc3", "size": 300}, + } + + for i, key := range keys { + emb := embeddings[i] + err := setup.Store.Add(setup.ctx, TestNamespace, key, emb, metadata[i]) + require.NoError(t, err) + } + + time.Sleep(100 * time.Millisecond) + + // Get all chunks + results, err := setup.Store.GetChunks(setup.ctx, TestNamespace, keys) + require.NoError(t, err) + assert.Len(t, results, 3) + + // Verify each result + for i, result := range results { + assert.Equal(t, keys[i], result.ID) + assert.Equal(t, metadata[i]["type"], result.Properties["type"]) + } + }) +} + +func TestRedisStore_FilteringScenarios(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + // Setup test data for filtering scenarios + testData := []struct { + key string + metadata map[string]interface{} + }{ + { + generateUUID(), + map[string]interface{}{ + "type": "pdf", + "size": 1024, + "public": true, + "author": "alice", + }, + }, + { + generateUUID(), + map[string]interface{}{ + "type": "docx", + "size": 2048, + "public": false, + "author": "bob", + }, + }, + { + generateUUID(), + map[string]interface{}{ + "type": "pdf", + "size": 512, + "public": true, + "author": "alice", + }, + }, + { + generateUUID(), + map[string]interface{}{ + "type": "txt", + "size": 256, + "public": true, + "author": "charlie", + }, + }, + } + + filterFields := []string{"type", "size", "public", "author"} + + // Add all test data + for _, item := range testData { + embedding := generateTestEmbedding(RedisTestDimension) + err := setup.Store.Add(setup.ctx, TestNamespace, item.key, embedding, item.metadata) + require.NoError(t, err) + } + + time.Sleep(500 * time.Millisecond) // Wait for consistency + + t.Run("Filter by numeric comparison", func(t *testing.T) { + queries := []Query{ + {Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1 (1024) and doc2 (2048) + }) + + t.Run("Filter by boolean", func(t *testing.T) { + queries := []Query{ + {Field: "public", Operator: QueryOperatorEqual, Value: true}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 3) // doc1, doc3, doc4 + }) + + t.Run("Multiple filters (AND)", func(t *testing.T) { + queries := []Query{ + {Field: "type", Operator: QueryOperatorEqual, Value: "pdf"}, + {Field: "public", Operator: QueryOperatorEqual, Value: true}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1 and doc3 + }) + + t.Run("Complex multi-condition filter", func(t *testing.T) { + queries := []Query{ + {Field: "author", Operator: QueryOperatorEqual, Value: "alice"}, + {Field: "size", Operator: QueryOperatorLessThan, Value: 2000}, + {Field: "public", Operator: QueryOperatorEqual, Value: true}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1 and doc3 (both by alice, < 2000 size, public) + }) + + t.Run("Pagination test", func(t *testing.T) { + // Test with limit of 2 + results, cursor, err := setup.Store.GetAll(setup.ctx, TestNamespace, nil, filterFields, nil, 2) + require.NoError(t, err) + assert.Len(t, results, 2) + + if cursor != nil { + // Get next page + nextResults, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, nil, filterFields, cursor, 2) + require.NoError(t, err) + assert.LessOrEqual(t, len(nextResults), 2) + t.Logf("First page: %d results, Next page: %d results", len(results), len(nextResults)) + } + }) +} + +func TestRedisStore_VectorSearch(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + // Add test documents with embeddings + testDocs := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{ + "type": "tech", + "category": "programming", + "content": "Go programming language", + }, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{ + "type": "tech", + "category": "programming", + "content": "Python programming language", + }, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{ + "type": "sports", + "category": "football", + "content": "Football match results", + }, + }, + } + + for _, doc := range testDocs { + err := setup.Store.Add(setup.ctx, TestNamespace, doc.key, doc.embedding, doc.metadata) + require.NoError(t, err) + } + + time.Sleep(500 * time.Millisecond) + + t.Run("Vector similarity search", func(t *testing.T) { + // Search for similar content to the first document + queryEmbedding := testDocs[0].embedding + results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, nil, []string{"type", "category", "content"}, 0.1, 10) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(results), 1) + + // Check that results have scores and are not nil + require.NotEmpty(t, results) + require.NotNil(t, results[0].Score) + assert.InDelta(t, 1.0, *results[0].Score, 1e-4) + }) + + t.Run("Vector search with metadata filters", func(t *testing.T) { + // Search for tech content only + queries := []Query{ + {Field: "type", Operator: QueryOperatorEqual, Value: "tech"}, + } + + queryEmbedding := testDocs[0].embedding + results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, queries, []string{"type", "category", "content"}, 0.1, 10) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(results), 1) + + // All results should be tech type + for _, result := range results { + assert.Equal(t, "tech", result.Properties["type"]) + } + }) + + t.Run("Vector search with threshold", func(t *testing.T) { + // Use a very high threshold to get only very similar results + queryEmbedding := testDocs[0].embedding + results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, nil, []string{"type", "category", "content"}, 0.99, 10) + require.NoError(t, err) + // Should return fewer results due to high threshold + t.Logf("High threshold search returned %d results", len(results)) + }) +} + +func TestRedisStore_CompleteUseCases(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + t.Run("Document Storage & Retrieval Scenario", func(t *testing.T) { + // Add documents with different types + documents := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{"type": "pdf", "size": 1024, "public": true}, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{"type": "docx", "size": 2048, "public": false}, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{"type": "pdf", "size": 512, "public": true}, + }, + } + + filterFields := []string{"type", "size", "public"} + + for _, doc := range documents { + err := setup.Store.Add(setup.ctx, TestNamespace, doc.key, doc.embedding, doc.metadata) + require.NoError(t, err) + } + + time.Sleep(300 * time.Millisecond) + + // Test various retrieval patterns + + // Get PDF documents + pdfQuery := []Query{{Field: "type", Operator: QueryOperatorEqual, Value: "pdf"}} + results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, pdfQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1, doc3 + + // Get large documents (size > 1000) + sizeQuery := []Query{{Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}} + results, _, err = setup.Store.GetAll(setup.ctx, TestNamespace, sizeQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1, doc2 + + // Get public PDFs + combinedQuery := []Query{ + {Field: "public", Operator: QueryOperatorEqual, Value: true}, + {Field: "type", Operator: QueryOperatorEqual, Value: "pdf"}, + } + results, _, err = setup.Store.GetAll(setup.ctx, TestNamespace, combinedQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1, doc3 + + // Vector similarity search + queryEmbedding := documents[0].embedding // Similar to doc1 + vectorResults, err := setup.Store.GetNearest(setup.ctx, TestNamespace, queryEmbedding, nil, filterFields, 0.8, 10) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(vectorResults), 1) + }) + + t.Run("Semantic Cache-like Workflow", func(t *testing.T) { + // Add request-response pairs with parameters + cacheEntries := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{ + "request_hash": "abc123", + "user": "u1", + "lang": "en", + "response": "answer1", + "from_bifrost_semantic_cache_plugin": true, + }, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{ + "request_hash": "def456", + "user": "u1", + "lang": "es", + "response": "answer2", + "from_bifrost_semantic_cache_plugin": true, + }, + }, + } + + filterFields := []string{"request_hash", "user", "lang", "response", "from_bifrost_semantic_cache_plugin"} + + for _, entry := range cacheEntries { + err := setup.Store.Add(setup.ctx, TestNamespace, entry.key, entry.embedding, entry.metadata) + require.NoError(t, err) + } + + time.Sleep(300 * time.Millisecond) + + // Test hash-based direct retrieval (exact match) + hashQuery := []Query{{Field: "request_hash", Operator: QueryOperatorEqual, Value: "abc123"}} + results, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, hashQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 1) + + // Test semantic search with user and language filters + userLangFilter := []Query{ + {Field: "user", Operator: QueryOperatorEqual, Value: "u1"}, + {Field: "lang", Operator: QueryOperatorEqual, Value: "en"}, + } + similarEmbedding := generateSimilarEmbedding(cacheEntries[0].embedding, 0.9) + vectorResults, err := setup.Store.GetNearest(setup.ctx, TestNamespace, similarEmbedding, userLangFilter, filterFields, 0.7, 10) + require.NoError(t, err) + assert.Len(t, vectorResults, 1) // Should find English content for u1 + }) +} + +func TestRedisStore_DeleteOperations(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + t.Run("Delete single item", func(t *testing.T) { + // Add an item + key := generateUUID() + embedding := generateTestEmbedding(RedisTestDimension) + metadata := map[string]interface{}{"type": "test", "value": "delete_me"} + + err := setup.Store.Add(setup.ctx, TestNamespace, key, embedding, metadata) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Verify it exists + result, err := setup.Store.GetChunk(setup.ctx, TestNamespace, key) + require.NoError(t, err) + assert.Equal(t, "test", result.Properties["type"]) + + // Delete it + err = setup.Store.Delete(setup.ctx, TestNamespace, key) + require.NoError(t, err) + + // Verify it's gone + _, err = setup.Store.GetChunk(setup.ctx, TestNamespace, key) + assert.Error(t, err) + }) + + t.Run("DeleteAll with filters", func(t *testing.T) { + // Add multiple items with different types + testItems := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{"type": "delete_me", "category": "test"}, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{"type": "delete_me", "category": "test"}, + }, + { + generateUUID(), + generateTestEmbedding(RedisTestDimension), + map[string]interface{}{"type": "keep_me", "category": "test"}, + }, + } + + for _, item := range testItems { + err := setup.Store.Add(setup.ctx, TestNamespace, item.key, item.embedding, item.metadata) + require.NoError(t, err) + } + + time.Sleep(300 * time.Millisecond) + + // Delete all items with type "delete_me" + queries := []Query{ + {Field: "type", Operator: QueryOperatorEqual, Value: "delete_me"}, + } + + deleteResults, err := setup.Store.DeleteAll(setup.ctx, TestNamespace, queries) + require.NoError(t, err) + assert.Len(t, deleteResults, 2) // Should delete 2 items + + // Verify only "keep_me" items remain + allResults, _, err := setup.Store.GetAll(setup.ctx, TestNamespace, nil, []string{"type"}, nil, 10) + require.NoError(t, err) + assert.Len(t, allResults, 1) // Only the "keep_me" item should remain + assert.Equal(t, "keep_me", allResults[0].Properties["type"]) + }) +} + +// ============================================================================ +// INTERFACE COMPLIANCE TESTS +// ============================================================================ + +func TestRedisStore_InterfaceCompliance(t *testing.T) { + // Verify that RedisStore implements VectorStore interface + var _ VectorStore = (*RedisStore)(nil) +} + +func TestVectorStoreFactory_Redis(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + config := &Config{ + Enabled: true, + Type: VectorStoreTypeRedis, + Config: RedisConfig{ + Addr: getEnvWithDefault("REDIS_ADDR", DefaultTestAddr), + Username: os.Getenv("REDIS_USERNAME"), + Password: os.Getenv("REDIS_PASSWORD"), + }, + } + + store, err := NewVectorStore(context.Background(), config, logger) + if err != nil { + t.Skipf("Could not create Redis store: %v", err) + } + defer store.Close(context.Background(), TestNamespace) + + // Verify it's actually a RedisStore + redisStore, ok := store.(*RedisStore) + assert.True(t, ok) + assert.NotNil(t, redisStore) +} + +// ============================================================================ +// ERROR HANDLING TESTS +// ============================================================================ + +func TestRedisStore_ErrorHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + t.Run("GetChunk with non-existent key", func(t *testing.T) { + _, err := setup.Store.GetChunk(setup.ctx, TestNamespace, "non-existent-key") + assert.Error(t, err) + }) + + t.Run("Delete non-existent key", func(t *testing.T) { + err := setup.Store.Delete(setup.ctx, TestNamespace, "non-existent-key") + assert.Error(t, err) + }) + + t.Run("Add with empty ID", func(t *testing.T) { + embedding := generateTestEmbedding(RedisTestDimension) + metadata := map[string]interface{}{"type": "test"} + + err := setup.Store.Add(setup.ctx, TestNamespace, "", embedding, metadata) + assert.Error(t, err) + }) + + t.Run("GetNearest with empty namespace", func(t *testing.T) { + embedding := generateTestEmbedding(RedisTestDimension) + _, err := setup.Store.GetNearest(setup.ctx, "", embedding, nil, []string{}, 0.8, 10) + assert.Error(t, err) + }) +} + +func TestRedisStore_NamespaceDimensionHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewRedisTestSetup(t) + defer setup.Cleanup(t) + + testNamespace := "TestDimensionHandling" + + t.Run("Recreate namespace with different dimension should not crash", func(t *testing.T) { + properties := map[string]VectorStoreProperties{ + "type": {DataType: VectorStorePropertyTypeString}, + "test": {DataType: VectorStorePropertyTypeString}, + } + + // Step 1: Create namespace with dimension 512 + err := setup.Store.CreateNamespace(setup.ctx, testNamespace, 512, properties) + require.NoError(t, err) + + // Add a document with 512-dimensional embedding + embedding512 := generateTestEmbedding(512) + metadata := map[string]interface{}{ + "type": "test_doc", + "test": "dimension_512", + } + + err = setup.Store.Add(setup.ctx, testNamespace, "test-key-512", embedding512, metadata) + require.NoError(t, err) + + // Verify it was added + result, err := setup.Store.GetChunk(setup.ctx, testNamespace, "test-key-512") + require.NoError(t, err) + assert.Equal(t, "dimension_512", result.Properties["test"]) + + // Step 2: Delete the namespace + err = setup.Store.DeleteNamespace(setup.ctx, testNamespace) + require.NoError(t, err) + + // Step 3: Create namespace with same name but different dimension - should not crash + err = setup.Store.CreateNamespace(setup.ctx, testNamespace, 1024, properties) + require.NoError(t, err) + + // Add a document with 1024-dimensional embedding + embedding1024 := generateTestEmbedding(1024) + metadata1024 := map[string]interface{}{ + "type": "test_doc", + "test": "dimension_1024", + } + + err = setup.Store.Add(setup.ctx, testNamespace, "test-key-1024", embedding1024, metadata1024) + require.NoError(t, err) + + // Verify new document exists + result, err = setup.Store.GetChunk(setup.ctx, testNamespace, "test-key-1024") + require.NoError(t, err) + assert.Equal(t, "dimension_1024", result.Properties["test"]) + + // Verify vector search works with new dimension + vectorResults, err := setup.Store.GetNearest(setup.ctx, testNamespace, embedding1024, nil, []string{"type", "test"}, 0.8, 10) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(vectorResults), 1) + assert.NotNil(t, vectorResults[0].Score) + + // Cleanup + err = setup.Store.DeleteNamespace(setup.ctx, testNamespace) + if err != nil { + t.Logf("Warning: Failed to cleanup namespace: %v", err) + } + }) +} diff --git a/framework/vectorstore/store.go b/framework/vectorstore/store.go new file mode 100644 index 000000000..7fc8ba7cd --- /dev/null +++ b/framework/vectorstore/store.go @@ -0,0 +1,170 @@ +// Package vectorstore provides a generic interface for vector stores. +package vectorstore + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" +) + +type VectorStoreType string + +const ( + VectorStoreTypeWeaviate VectorStoreType = "weaviate" + VectorStoreTypeRedis VectorStoreType = "redis" +) + +// Query represents a query to the vector store. +type Query struct { + Field string + Operator QueryOperator + Value interface{} +} + +type QueryOperator string + +const ( + QueryOperatorEqual QueryOperator = "Equal" + QueryOperatorNotEqual QueryOperator = "NotEqual" + QueryOperatorGreaterThan QueryOperator = "GreaterThan" + QueryOperatorLessThan QueryOperator = "LessThan" + QueryOperatorGreaterThanOrEqual QueryOperator = "GreaterThanOrEqual" + QueryOperatorLessThanOrEqual QueryOperator = "LessThanOrEqual" + QueryOperatorLike QueryOperator = "Like" + QueryOperatorContainsAny QueryOperator = "ContainsAny" + QueryOperatorContainsAll QueryOperator = "ContainsAll" + QueryOperatorIsNull QueryOperator = "IsNull" + QueryOperatorIsNotNull QueryOperator = "IsNotNull" +) + +// SearchResult represents a search result with metadata. +type SearchResult struct { + ID string + Score *float64 + Properties map[string]interface{} +} + +// DeleteResult represents the result of a delete operation. +type DeleteResult struct { + ID string + Status DeleteStatus + Error string +} + +type DeleteStatus string + +const ( + DeleteStatusSuccess DeleteStatus = "success" + DeleteStatusError DeleteStatus = "error" +) + +type VectorStoreProperties struct { + DataType VectorStorePropertyType `json:"data_type"` + Description string `json:"description"` +} + +type VectorStorePropertyType string + +const ( + VectorStorePropertyTypeString VectorStorePropertyType = "string" + VectorStorePropertyTypeInteger VectorStorePropertyType = "integer" + VectorStorePropertyTypeBoolean VectorStorePropertyType = "boolean" + VectorStorePropertyTypeStringArray VectorStorePropertyType = "string[]" +) + +// VectorStore represents the interface for the vector store. +type VectorStore interface { + // Health check + Ping(ctx context.Context) error + CreateNamespace(ctx context.Context, namespace string, dimension int, properties map[string]VectorStoreProperties) error + DeleteNamespace(ctx context.Context, namespace string) error + GetChunk(ctx context.Context, namespace string, id string) (SearchResult, error) + GetChunks(ctx context.Context, namespace string, ids []string) ([]SearchResult, error) + GetAll(ctx context.Context, namespace string, queries []Query, selectFields []string, cursor *string, limit int64) ([]SearchResult, *string, error) + GetNearest(ctx context.Context, namespace string, vector []float32, queries []Query, selectFields []string, threshold float64, limit int64) ([]SearchResult, error) + Add(ctx context.Context, namespace string, id string, embedding []float32, metadata map[string]interface{}) error + Delete(ctx context.Context, namespace string, id string) error + DeleteAll(ctx context.Context, namespace string, queries []Query) ([]DeleteResult, error) + Close(ctx context.Context, namespace string) error +} + +// Config represents the configuration for the vector store. +type Config struct { + Enabled bool `json:"enabled"` + Type VectorStoreType `json:"type"` + Config any `json:"config"` +} + +// UnmarshalJSON unmarshals the config from JSON. +func (c *Config) UnmarshalJSON(data []byte) error { + // First, unmarshal into a temporary struct to get the basic fields + type TempConfig struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + Config json.RawMessage `json:"config"` // Keep as raw JSON + } + + var temp TempConfig + if err := json.Unmarshal(data, &temp); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Set basic fields + c.Enabled = temp.Enabled + c.Type = VectorStoreType(temp.Type) + + // Parse the config field based on type + switch c.Type { + case VectorStoreTypeWeaviate: + var weaviateConfig WeaviateConfig + if err := json.Unmarshal(temp.Config, &weaviateConfig); err != nil { + return fmt.Errorf("failed to unmarshal weaviate config: %w", err) + } + c.Config = weaviateConfig + case VectorStoreTypeRedis: + var redisConfig RedisConfig + if err := json.Unmarshal(temp.Config, &redisConfig); err != nil { + return fmt.Errorf("failed to unmarshal redis config: %w", err) + } + c.Config = redisConfig + default: + return fmt.Errorf("unknown vector store type: %s", temp.Type) + } + + return nil +} + +// NewVectorStore returns a new vector store based on the configuration. +func NewVectorStore(ctx context.Context, config *Config, logger schemas.Logger) (VectorStore, error) { + if config == nil { + return nil, fmt.Errorf("config cannot be nil") + } + + if !config.Enabled { + return nil, fmt.Errorf("vector store is disabled") + } + + switch config.Type { + case VectorStoreTypeWeaviate: + if config.Config == nil { + return nil, fmt.Errorf("weaviate config is required") + } + weaviateConfig, ok := config.Config.(WeaviateConfig) + if !ok { + return nil, fmt.Errorf("invalid weaviate config") + } + return newWeaviateStore(ctx, &weaviateConfig, logger) + case VectorStoreTypeRedis: + if config.Config == nil { + return nil, fmt.Errorf("redis config is required") + } + redisConfig, ok := config.Config.(RedisConfig) + if !ok { + return nil, fmt.Errorf("invalid redis config") + } + return newRedisStore(ctx, redisConfig, logger) + } + return nil, fmt.Errorf("invalid vector store type: %s", config.Type) +} diff --git a/framework/vectorstore/test_utils.go b/framework/vectorstore/test_utils.go new file mode 100644 index 000000000..54eaf9450 --- /dev/null +++ b/framework/vectorstore/test_utils.go @@ -0,0 +1,47 @@ +package vectorstore + +import ( + "math/rand" + "os" + "strconv" + + "github.com/google/uuid" +) + +// Helper functions +func getEnvWithDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvWithDefaultInt(key string, defaultValue int) (int, error) { + if value := os.Getenv(key); value != "" { + return strconv.Atoi(value) + } + return defaultValue, nil +} + +func generateUUID() string { + return uuid.New().String() +} + +func generateTestEmbedding(dim int) []float32 { + embedding := make([]float32, dim) + for i := range embedding { + embedding[i] = rand.Float32()*2 - 1 // Random values between -1 and 1 + } + return embedding +} + +func generateSimilarEmbedding(original []float32, similarity float32) []float32 { + similar := make([]float32, len(original)) + for i := range similar { + // Add small random noise to create similar but not identical embedding + noise := (rand.Float32()*2 - 1) * (1 - similarity) * 0.1 + similar[i] = original[i] + noise + } + return similar +} + diff --git a/framework/vectorstore/utils.go b/framework/vectorstore/utils.go new file mode 100644 index 000000000..82c8ddace --- /dev/null +++ b/framework/vectorstore/utils.go @@ -0,0 +1,15 @@ +package vectorstore + +import ( + "context" + "time" +) + +// withTimeout adds a timeout to the context if it is set. +func withTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout > 0 { + return context.WithTimeout(ctx, timeout) + } + // No-op cancel to simplify call sites. + return ctx, func() {} +} diff --git a/framework/vectorstore/weaviate.go b/framework/vectorstore/weaviate.go new file mode 100644 index 000000000..4c8d3ec01 --- /dev/null +++ b/framework/vectorstore/weaviate.go @@ -0,0 +1,618 @@ +package vectorstore + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/weaviate/weaviate-go-client/v5/weaviate" + "github.com/weaviate/weaviate-go-client/v5/weaviate/auth" + "github.com/weaviate/weaviate-go-client/v5/weaviate/filters" + "github.com/weaviate/weaviate-go-client/v5/weaviate/graphql" + "github.com/weaviate/weaviate-go-client/v5/weaviate/grpc" + "github.com/weaviate/weaviate/entities/models" +) + +// Default values for Weaviate vector index configuration +const ( + // Default class names (Weaviate prefers PascalCase) + DefaultClassName = "BifrostStore" +) + +// WeaviateConfig represents the configuration for the Weaviate vector store. +type WeaviateConfig struct { + // Connection settings + Scheme string `json:"scheme"` // "http" or "https" - REQUIRED + Host string `json:"host"` // "localhost:8080" - REQUIRED + GrpcConfig *WeaviateGrpcConfig `json:"grpc_config,omitempty"` // grpc config for weaviate (optional) + + // Authentication settings (optional) + APIKey string `json:"api_key,omitempty"` // API key for authentication + Headers map[string]string `json:"headers,omitempty"` // Additional headers + + // Connection settings + Timeout time.Duration `json:"timeout,omitempty"` // Request timeout (optional) +} + +type WeaviateGrpcConfig struct { + // Host is the host of the weaviate server (host:port). + // If host is without a port number then the 80 port for insecured and 443 port for secured connections will be used. + Host string `json:"host"` + // Secured is a boolean flag indicating if the connection is secured + Secured bool `json:"secured"` +} + +// WeaviateStore represents the Weaviate vector store. +type WeaviateStore struct { + client *weaviate.Client + config *WeaviateConfig + logger schemas.Logger +} + +// Ping checks if the Weaviate server is reachable. +func (s *WeaviateStore) Ping(ctx context.Context) error { + _, err := s.client.Misc().MetaGetter().Do(ctx) + return err +} + +// Add stores a new object (with or without embedding) +func (s *WeaviateStore) Add(ctx context.Context, className string, id string, embedding []float32, metadata map[string]interface{}) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("id is required") + } + + obj := &models.Object{ + Class: className, + Properties: metadata, + } + + var err error + if len(embedding) > 0 { + _, err = s.client.Data().Creator(). + WithClassName(className). + WithID(id). + WithProperties(obj.Properties). + WithVector(embedding). + Do(ctx) + } else { + _, err = s.client.Data().Creator(). + WithClassName(className). + WithID(id). + WithProperties(obj.Properties). + Do(ctx) + } + + return err +} + +// GetChunk returns the "metadata" for a single key +func (s *WeaviateStore) GetChunk(ctx context.Context, className string, id string) (SearchResult, error) { + obj, err := s.client.Data().ObjectsGetter(). + WithClassName(className). + WithID(id). + Do(ctx) + if err != nil { + return SearchResult{}, err + } + if len(obj) == 0 { + return SearchResult{}, fmt.Errorf("not found: %s", id) + } + + props, ok := obj[0].Properties.(map[string]interface{}) + if !ok { + return SearchResult{}, fmt.Errorf("invalid properties") + } + + return SearchResult{ + ID: id, + Score: nil, + Properties: props, + }, nil +} + +// GetChunks returns multiple objects by ID +func (s *WeaviateStore) GetChunks(ctx context.Context, className string, ids []string) ([]SearchResult, error) { + out := make([]SearchResult, 0, len(ids)) + for _, id := range ids { + obj, err := s.client.Data().ObjectsGetter(). + WithClassName(className). + WithID(id). + Do(ctx) + if err != nil { + return nil, err + } + if len(obj) > 0 { + props, ok := obj[0].Properties.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid properties") + } + out = append(out, SearchResult{ + ID: id, + Score: nil, + Properties: props, + }) + } + } + return out, nil +} + +// GetAll with filtering + pagination +func (s *WeaviateStore) GetAll(ctx context.Context, className string, queries []Query, selectFields []string, cursor *string, limit int64) ([]SearchResult, *string, error) { + where := buildWeaviateFilter(queries) + + fields := []graphql.Field{ + {Name: "_additional", Fields: []graphql.Field{ + {Name: "id"}, + }}, + } + for _, field := range selectFields { + fields = append(fields, graphql.Field{Name: field}) + } + + search := s.client.GraphQL().Get(). + WithClassName(className). + WithLimit(int(limit)). + WithFields(fields...) + + if where != nil { + search = search.WithWhere(where) + } + if cursor != nil { + search = search.WithAfter(*cursor) + } + + resp, err := search.Do(ctx) + if err != nil { + return nil, nil, err + } + + // Check for GraphQL errors + if len(resp.Errors) > 0 { + var errorMsgs []string + for _, err := range resp.Errors { + errorMsgs = append(errorMsgs, err.Message) + } + return nil, nil, fmt.Errorf("graphql errors: %v", errorMsgs) + } + + data, ok := resp.Data["Get"].(map[string]interface{}) + if !ok { + return nil, nil, fmt.Errorf("invalid graphql response: missing 'Get' key, got: %+v", resp.Data) + } + + objsRaw, exists := data[className] + if !exists { + // No results for this class - this is normal, not an error + s.logger.Debug(fmt.Sprintf("No results found for class '%s', available classes: %+v", className, data)) + return nil, nil, nil + } + + objs, ok := objsRaw.([]interface{}) + if !ok { + s.logger.Debug(fmt.Sprintf("Class '%s' exists but data is not an array: %+v", className, objsRaw)) + return nil, nil, nil + } + + results := make([]SearchResult, 0, len(objs)) + var nextCursor *string + for _, o := range objs { + obj, ok := o.(map[string]interface{}) + if !ok { + continue + } + + // Convert to SearchResult format for consistency + searchResult := SearchResult{ + Properties: obj, + } + + if additional, ok := obj["_additional"].(map[string]interface{}); ok { + if id, ok := additional["id"].(string); ok { + searchResult.ID = id + nextCursor = &id + } + } + + results = append(results, searchResult) + } + + return results, nextCursor, nil +} + +// GetNearest with explicit filters only +func (s *WeaviateStore) GetNearest( + ctx context.Context, + className string, + vector []float32, + queries []Query, + selectFields []string, + threshold float64, + limit int64, +) ([]SearchResult, error) { + where := buildWeaviateFilter(queries) + + fields := []graphql.Field{ + {Name: "_additional", Fields: []graphql.Field{ + {Name: "id"}, + {Name: "certainty"}, + }}, + } + + for _, field := range selectFields { + fields = append(fields, graphql.Field{Name: field}) + } + + nearVector := s.client.GraphQL().NearVectorArgBuilder(). + WithVector(vector). + WithCertainty(float32(threshold)) + + search := s.client.GraphQL().Get(). + WithClassName(className). + WithNearVector(nearVector). + WithLimit(int(limit)). + WithFields(fields...) + + if where != nil { + search = search.WithWhere(where) + } + + resp, err := search.Do(ctx) + if err != nil { + return nil, err + } + + // Check for GraphQL errors + if len(resp.Errors) > 0 { + var errorMsgs []string + for _, err := range resp.Errors { + errorMsgs = append(errorMsgs, err.Message) + } + return nil, fmt.Errorf("graphql errors: %v", errorMsgs) + } + + data, ok := resp.Data["Get"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid graphql response: missing 'Get' key, got: %+v", resp.Data) + } + + objsRaw, exists := data[className] + if !exists { + // No results for this class - this is normal, not an error + s.logger.Debug(fmt.Sprintf("No results found for class '%s', available classes: %+v", className, data)) + return nil, nil + } + + objs, ok := objsRaw.([]interface{}) + if !ok { + s.logger.Debug(fmt.Sprintf("Class '%s' exists but data is not an array: %+v", className, objsRaw)) + return nil, nil + } + + results := make([]SearchResult, 0, len(objs)) + for _, o := range objs { + obj, ok := o.(map[string]interface{}) + if !ok { + continue + } + + additional, ok := obj["_additional"].(map[string]interface{}) + if !ok { + continue + } + + // Safely extract ID + idRaw, exists := additional["id"] + if !exists || idRaw == nil { + continue + } + id, ok := idRaw.(string) + if !ok { + continue + } + + // Safely extract certainty/score with default value + var score float64 + if certaintyRaw, exists := additional["certainty"]; exists && certaintyRaw != nil { + switch v := certaintyRaw.(type) { + case float64: + score = v + case float32: + score = float64(v) + case int: + score = float64(v) + case int64: + score = float64(v) + default: + score = 0.0 // Default score if type conversion fails + } + } + + results = append(results, SearchResult{ + ID: id, + Score: &score, + Properties: obj, + }) + } + + return results, nil +} + +// Delete removes multiple objects by ID +func (s *WeaviateStore) Delete(ctx context.Context, className string, id string) error { + return s.client.Data().Deleter(). + WithClassName(className). + WithID(id). + Do(ctx) +} + +func (s *WeaviateStore) DeleteAll(ctx context.Context, className string, queries []Query) ([]DeleteResult, error) { + where := buildWeaviateFilter(queries) + + res, err := s.client.Batch().ObjectsBatchDeleter(). + WithClassName(className). + WithWhere(where). + Do(ctx) + if err != nil { + return nil, err + } + + // NOTE: Weaviate is returning an empty array for Results.Objects, even on successful deletes. + results := make([]DeleteResult, 0, len(res.Results.Objects)) + + for _, obj := range res.Results.Objects { + result := DeleteResult{ + ID: obj.ID.String(), + } + + if obj.Status != nil { + switch *obj.Status { + case "SUCCESS": + result.Status = DeleteStatusSuccess + case "FAILED": + result.Status = DeleteStatusError + + if obj.Errors != nil { + var errorMsgs []string + for _, err := range obj.Errors.Error { + errorMsgs = append(errorMsgs, err.Message) + } + + result.Error = strings.Join(errorMsgs, ", ") + } + } + } + + results = append(results, result) + } + + return results, nil +} + +func (s *WeaviateStore) Close(ctx context.Context, className string) error { + // nothing to close + return nil +} + +// newWeaviateStore creates a new Weaviate vector store. +func newWeaviateStore(ctx context.Context, config *WeaviateConfig, logger schemas.Logger) (*WeaviateStore, error) { + // Validate required config + if config.Scheme == "" || config.Host == "" { + return nil, fmt.Errorf("weaviate scheme and host are required") + } + + // Build client configuration + cfg := weaviate.Config{ + Scheme: config.Scheme, + Host: config.Host, + } + + // Add authentication if provided + if config.APIKey != "" { + cfg.AuthConfig = auth.ApiKey{Value: config.APIKey} + } + + // Add grpc config if provided + if config.GrpcConfig != nil { + cfg.GrpcConfig = &grpc.Config{ + Host: config.GrpcConfig.Host, + Secured: config.GrpcConfig.Secured, + } + } + + // Add custom headers if provided + if len(config.Headers) > 0 { + cfg.Headers = config.Headers + } + + // Create client + client, err := weaviate.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create weaviate client: %w", err) + } + + // Test connection with meta endpoint + testCtx := ctx + if config.Timeout > 0 { + var cancel context.CancelFunc + testCtx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + + _, err = client.Misc().MetaGetter().Do(testCtx) + if err != nil { + return nil, fmt.Errorf("failed to connect to weaviate: %w", err) + } + + store := &WeaviateStore{ + client: client, + config: config, + logger: logger, + } + + return store, nil +} + +func (s *WeaviateStore) CreateNamespace(ctx context.Context, className string, dimension int, properties map[string]VectorStoreProperties) error { + // Check if class exists + exists, err := s.client.Schema().ClassExistenceChecker(). + WithClassName(className). + Do(ctx) + if err != nil { + return fmt.Errorf("failed to check class existence: %w", err) + } + + if exists { + return nil // Schema already exists + } + + // Create properties + weaviateProperties := []*models.Property{} + for name, prop := range properties { + var dataType []string + switch prop.DataType { + case VectorStorePropertyTypeString: + dataType = []string{"string"} + case VectorStorePropertyTypeInteger: + dataType = []string{"int"} + case VectorStorePropertyTypeBoolean: + dataType = []string{"boolean"} + case VectorStorePropertyTypeStringArray: + dataType = []string{"string[]"} + } + + weaviateProperties = append(weaviateProperties, &models.Property{ + Name: name, + DataType: dataType, + Description: prop.Description, + }) + } + + // Create class schema with all fields we need + classSchema := &models.Class{ + Class: className, + Properties: weaviateProperties, + VectorIndexType: "hnsw", + Vectorizer: "none", // We provide our own vectors + } + + if dimension > 0 { + classSchema.VectorIndexConfig = map[string]interface{}{ + "vectorDimensions": dimension, + } + } + + err = s.client.Schema().ClassCreator(). + WithClass(classSchema). + Do(ctx) + if err != nil { + return fmt.Errorf("failed to create class schema: %w", err) + } + + return nil +} + +func (s *WeaviateStore) DeleteNamespace(ctx context.Context, className string) error { + exists, err := s.client.Schema().ClassExistenceChecker(). + WithClassName(className). + Do(ctx) + if err != nil { + return fmt.Errorf("failed to check class existence: %w", err) + } + if !exists { + return nil // Schema already does not exist + } else { + return s.client.Schema().ClassDeleter(). + WithClassName(className). + Do(ctx) + } +} + +// buildWeaviateFilter converts []Query β†’ Weaviate WhereFilter +func buildWeaviateFilter(queries []Query) *filters.WhereBuilder { + if len(queries) == 0 { + return nil + } + + var operands []*filters.WhereBuilder + for _, q := range queries { + // Convert string operator to filters operator + operator := convertOperator(q.Operator) + + fieldPath := strings.Split(q.Field, ".") + + whereClause := filters.Where(). + WithPath(fieldPath). + WithOperator(operator) + + // Special handling for IsNull and IsNotNull + switch q.Operator { + case QueryOperatorIsNull: + whereClause = whereClause.WithValueBoolean(true) + case QueryOperatorIsNotNull: + whereClause = whereClause.WithValueBoolean(false) + default: + // Set value based on type + switch v := q.Value.(type) { + case string: + whereClause = whereClause.WithValueString(v) + case int: + whereClause = whereClause.WithValueInt(int64(v)) + case int64: + whereClause = whereClause.WithValueInt(v) + case float32: + whereClause = whereClause.WithValueNumber(float64(v)) + case float64: + whereClause = whereClause.WithValueNumber(v) + case bool: + whereClause = whereClause.WithValueBoolean(v) + default: + // Fallback to string conversion + whereClause = whereClause.WithValueString(fmt.Sprintf("%v", v)) + } + } + + operands = append(operands, whereClause) + } + + if len(operands) == 1 { + return operands[0] + } + + // Create AND filter for multiple operands + return filters.Where(). + WithOperator(filters.And). + WithOperands(operands) +} + +// convertOperator converts string operator to filters operator +func convertOperator(op QueryOperator) filters.WhereOperator { + switch op { + case QueryOperatorEqual: + return filters.Equal + case QueryOperatorNotEqual: + return filters.NotEqual + case QueryOperatorLessThan: + return filters.LessThan + case QueryOperatorLessThanOrEqual: + return filters.LessThanEqual + case QueryOperatorGreaterThan: + return filters.GreaterThan + case QueryOperatorGreaterThanOrEqual: + return filters.GreaterThanEqual + case QueryOperatorLike: + return filters.Like + case QueryOperatorContainsAny: + return filters.ContainsAny + case QueryOperatorContainsAll: + return filters.ContainsAll + case QueryOperatorIsNull: + return filters.IsNull + case QueryOperatorIsNotNull: // IsNotNull is not supported by Weaviate, so we use IsNull and negate it. + return filters.IsNull + default: + // Default to Equal if unknown + return filters.Equal + } +} diff --git a/framework/vectorstore/weaviate_test.go b/framework/vectorstore/weaviate_test.go new file mode 100644 index 000000000..a8ed22a01 --- /dev/null +++ b/framework/vectorstore/weaviate_test.go @@ -0,0 +1,814 @@ +package vectorstore + +import ( + "context" + "os" + "testing" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/weaviate/weaviate-go-client/v5/weaviate/filters" + "github.com/weaviate/weaviate/entities/models" +) + +// Test constants +const ( + TestTimeout = 30 * time.Second + TestClassName = "TestWeaviate" + TestEmbeddingDim = 384 + DefaultTestScheme = "http" + DefaultTestHost = "localhost:9000" + DefaultTestTimeout = 10 * time.Second +) + +// TestSetup provides common test infrastructure +type TestSetup struct { + Store *WeaviateStore + Logger schemas.Logger + Config WeaviateConfig + ctx context.Context + cancel context.CancelFunc +} + +// NewTestSetup creates a test setup with environment-driven configuration +func NewTestSetup(t *testing.T) *TestSetup { + // Get configuration from environment variables + scheme := getEnvWithDefault("WEAVIATE_SCHEME", DefaultTestScheme) + host := getEnvWithDefault("WEAVIATE_HOST", DefaultTestHost) + apiKey := os.Getenv("WEAVIATE_API_KEY") + + timeoutStr := getEnvWithDefault("WEAVIATE_TIMEOUT", "10s") + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + timeout = DefaultTestTimeout + } + + config := WeaviateConfig{ + Scheme: scheme, + Host: host, + APIKey: apiKey, + Timeout: timeout, + } + + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + ctx, cancel := context.WithTimeout(context.Background(), TestTimeout) + + store, err := newWeaviateStore(ctx, &config, logger) + if err != nil { + cancel() + t.Fatalf("Failed to create Weaviate store: %v", err) + } + + setup := &TestSetup{ + Store: store, + Logger: logger, + Config: config, + ctx: ctx, + cancel: cancel, + } + + // Ensure class exists for integration tests + if !testing.Short() { + setup.ensureClassExists(t) + } + + return setup +} + +// Cleanup cleans up test resources +func (ts *TestSetup) Cleanup(t *testing.T) { + defer ts.cancel() + + if !testing.Short() { + // Clean up test data + ts.cleanupTestData(t) + } + + if err := ts.Store.Close(ts.ctx, TestClassName); err != nil { + t.Logf("Warning: Failed to close store: %v", err) + } +} + +// ensureClassExists creates the test class in Weaviate +func (ts *TestSetup) ensureClassExists(t *testing.T) { + // Try to get class schema first + exists, err := ts.Store.client.Schema().ClassGetter(). + WithClassName(TestClassName). + Do(ts.ctx) + + if err == nil && exists != nil { + t.Logf("Class %s already exists", TestClassName) + return + } + + // Create class with minimal schema - let Weaviate auto-create properties + class := &models.Class{ + Class: TestClassName, + Properties: []*models.Property{ + { + Name: "key", + DataType: []string{"text"}, + }, + { + Name: "test_type", + DataType: []string{"text"}, + }, + { + Name: "size", + DataType: []string{"int"}, + }, + { + Name: "public", + DataType: []string{"boolean"}, + }, + }, + VectorIndexConfig: map[string]interface{}{ + "distance": "cosine", + }, + } + + err = ts.Store.client.Schema().ClassCreator(). + WithClass(class). + Do(ts.ctx) + + if err != nil { + t.Logf("Warning: Failed to create test class %s: %v", TestClassName, err) + t.Logf("This might be due to auto-schema creation. Continuing...") + } else { + t.Logf("Created test class: %s", TestClassName) + } +} + +// cleanupTestData removes all test objects from the class +func (ts *TestSetup) cleanupTestData(t *testing.T) { + // Delete all objects in the test class + allTestKeys, _, err := ts.Store.GetAll(ts.ctx, TestClassName, []Query{}, []string{}, nil, 1000) + if err != nil { + t.Logf("Warning: Failed to get all test keys: %v", err) + return + } + + for _, key := range allTestKeys { + err := ts.Store.Delete(ts.ctx, TestClassName, key.ID) + if err != nil { + t.Logf("Warning: Failed to delete test key %s: %v", key.ID, err) + } + } + + t.Logf("Cleaned up test class: %s", TestClassName) +} + +// ============================================================================ +// UNIT TESTS +// ============================================================================ + +func TestWeaviateConfig_Validation(t *testing.T) { + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + ctx := context.Background() + + tests := []struct { + name string + config WeaviateConfig + expectError bool + errorMsg string + }{ + { + name: "valid config", + config: WeaviateConfig{ + Scheme: "http", + Host: "localhost:8080", + }, + expectError: false, + }, + { + name: "missing scheme", + config: WeaviateConfig{ + Host: "localhost:8080", + }, + expectError: true, + errorMsg: "scheme and host are required", + }, + { + name: "missing host", + config: WeaviateConfig{ + Scheme: "http", + }, + expectError: true, + errorMsg: "scheme and host are required", + }, + { + name: "with api key", + config: WeaviateConfig{ + Scheme: "https", + Host: "cluster.weaviate.network", + APIKey: "test-key", + }, + expectError: false, + }, + { + name: "with custom headers", + config: WeaviateConfig{ + Scheme: "http", + Host: "localhost:8080", + Headers: map[string]string{ + "Custom-Header": "value", + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, err := newWeaviateStore(ctx, &tt.config, logger) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, store) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + // Note: This will fail with connection error in unit tests + // but should pass config validation + assert.Nil(t, store) // Expected due to no real Weaviate instance + assert.Error(t, err) // Connection error expected + } + }) + } +} + +func TestDefaultClassName(t *testing.T) { + config := WeaviateConfig{ + Scheme: "http", + Host: "localhost:8080", + } + + // This will fail to connect but should set default class name + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + _, err := newWeaviateStore(context.Background(), &config, logger) + + // Should fail with connection error, but we can't test the default class name + // without mocking the client, which would be more complex + assert.Error(t, err) +} + +func TestBuildWeaviateFilter(t *testing.T) { + tests := []struct { + name string + queries []Query + expected *filters.WhereBuilder // We'll test the structure, not exact equality + isNil bool + }{ + { + name: "empty queries", + queries: []Query{}, + expected: nil, + isNil: true, + }, + { + name: "single string query", + queries: []Query{ + {Field: "category", Operator: QueryOperatorEqual, Value: "tech"}, + }, + isNil: false, + }, + { + name: "single numeric query", + queries: []Query{ + {Field: "size", Operator: QueryOperatorGreaterThan, Value: 1000}, + }, + isNil: false, + }, + { + name: "multiple queries (AND)", + queries: []Query{ + {Field: "category", Operator: QueryOperatorEqual, Value: "tech"}, + {Field: "public", Operator: QueryOperatorEqual, Value: true}, + }, + isNil: false, + }, + { + name: "mixed types", + queries: []Query{ + {Field: "name", Operator: QueryOperatorLike, Value: "test%"}, + {Field: "count", Operator: QueryOperatorLessThan, Value: int64(100)}, + {Field: "active", Operator: QueryOperatorEqual, Value: true}, + {Field: "score", Operator: QueryOperatorGreaterThanOrEqual, Value: 95.5}, + }, + isNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildWeaviateFilter(tt.queries) + + if tt.isNil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + // We can't easily test the internal structure without reflection + // or implementing String() methods, but we verify it's not nil + } + }) + } +} + +func TestConvertOperator(t *testing.T) { + tests := []struct { + input QueryOperator + expected filters.WhereOperator + }{ + {QueryOperatorEqual, filters.Equal}, + {QueryOperatorNotEqual, filters.NotEqual}, + {QueryOperatorLessThan, filters.LessThan}, + {QueryOperatorLessThanOrEqual, filters.LessThanEqual}, + {QueryOperatorGreaterThan, filters.GreaterThan}, + {QueryOperatorGreaterThanOrEqual, filters.GreaterThanEqual}, + {QueryOperatorLike, filters.Like}, + {QueryOperatorContainsAny, filters.ContainsAny}, + {QueryOperatorContainsAll, filters.ContainsAll}, + {QueryOperatorIsNull, filters.IsNull}, + {QueryOperatorIsNotNull, filters.IsNull}, + } + + for _, tt := range tests { + t.Run(string(tt.input), func(t *testing.T) { + result := convertOperator(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// ============================================================================ +// INTEGRATION TESTS (require real Weaviate instance) +// ============================================================================ + +func TestWeaviateStore_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewTestSetup(t) + defer setup.Cleanup(t) + + t.Run("Add and GetChunk", func(t *testing.T) { + testKey := generateUUID() + embedding := generateTestEmbedding(TestEmbeddingDim) + metadata := map[string]interface{}{ + "type": "document", + "size": 1024, + "public": true, + } + + // Add object + err := setup.Store.Add(setup.ctx, TestClassName, testKey, embedding, metadata) + require.NoError(t, err) + + // Small delay to ensure consistency + time.Sleep(100 * time.Millisecond) + + // Get single chunk + result, err := setup.Store.GetChunk(setup.ctx, TestClassName, testKey) + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Equal(t, "document", result.Properties["type"]) // Should contain metadata + }) + + t.Run("Add without embedding", func(t *testing.T) { + testKey := generateUUID() + metadata := map[string]interface{}{ + "type": "metadata-only", + } + + // Add object without embedding + err := setup.Store.Add(setup.ctx, TestClassName, testKey, nil, metadata) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Retrieve it + result, err := setup.Store.GetChunk(setup.ctx, TestClassName, testKey) + require.NoError(t, err) + assert.Equal(t, "metadata-only", result.Properties["type"]) + }) +} + +func TestWeaviateStore_FilteringScenarios(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewTestSetup(t) + defer setup.Cleanup(t) + + // Setup test data for filtering scenarios + testData := []struct { + key string + metadata map[string]interface{} + }{ + { + generateUUID(), + map[string]interface{}{ + "type": "pdf", + "size": 1024, + "public": true, + "author": "alice", + }, + }, + { + generateUUID(), + map[string]interface{}{ + "type": "docx", + "size": 2048, + "public": false, + "author": "bob", + }, + }, + { + generateUUID(), + map[string]interface{}{ + "type": "pdf", + "size": 512, + "public": true, + "author": "alice", + }, + }, + { + generateUUID(), + map[string]interface{}{ + "type": "txt", + "size": 256, + "public": true, + "author": "charlie", + }, + }, + } + + filterFields := []string{"type", "size", "public", "author"} + + // Add all test data + for _, item := range testData { + embedding := generateTestEmbedding(TestEmbeddingDim) + err := setup.Store.Add(setup.ctx, TestClassName, item.key, embedding, item.metadata) + require.NoError(t, err) + } + + time.Sleep(500 * time.Millisecond) // Wait for consistency + + t.Run("Filter by numeric comparison", func(t *testing.T) { + queries := []Query{ + {Field: "size", Operator: "GreaterThan", Value: 1000}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1 (1024) and doc2 (2048) + }) + + t.Run("Filter by boolean", func(t *testing.T) { + queries := []Query{ + {Field: "public", Operator: "Equal", Value: true}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 3) // doc1, doc3, doc4 + }) + + t.Run("Multiple filters (AND)", func(t *testing.T) { + queries := []Query{ + {Field: "type", Operator: "Equal", Value: "pdf"}, + {Field: "public", Operator: "Equal", Value: true}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1 and doc3 + }) + + t.Run("Complex multi-condition filter", func(t *testing.T) { + queries := []Query{ + {Field: "author", Operator: "Equal", Value: "alice"}, + {Field: "size", Operator: "LessThan", Value: 2000}, + {Field: "public", Operator: "Equal", Value: true}, + } + + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, queries, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1 and doc3 (both by alice, < 2000 size, public) + }) + + t.Run("Pagination test", func(t *testing.T) { + // Test with limit of 2 + results, cursor, err := setup.Store.GetAll(setup.ctx, TestClassName, nil, filterFields, nil, 2) + require.NoError(t, err) + assert.Len(t, results, 2) + + if cursor != nil { + // Get next page + nextResults, _, err := setup.Store.GetAll(setup.ctx, TestClassName, nil, filterFields, cursor, 2) + require.NoError(t, err) + assert.LessOrEqual(t, len(nextResults), 2) + t.Logf("First page: %d results, Next page: %d results", len(results), len(nextResults)) + } + }) +} + +func TestWeaviateStore_CompleteUseCases(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewTestSetup(t) + defer setup.Cleanup(t) + + t.Run("Document Storage & Retrieval Scenario", func(t *testing.T) { + // Add documents with different types + documents := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{"type": "pdf", "size": 1024, "public": true}, + }, + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{"type": "docx", "size": 2048, "public": false}, + }, + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{"type": "pdf", "size": 512, "public": true}, + }, + } + + filterFields := []string{"type", "size", "public", "author"} + + for _, doc := range documents { + err := setup.Store.Add(setup.ctx, TestClassName, doc.key, doc.embedding, doc.metadata) + require.NoError(t, err) + } + + time.Sleep(300 * time.Millisecond) + + // Test various retrieval patterns + + // Get PDF documents + pdfQuery := []Query{{Field: "type", Operator: "Equal", Value: "pdf"}} + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, pdfQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1, doc3 + + // Get large documents (size > 1000) + sizeQuery := []Query{{Field: "size", Operator: "GreaterThan", Value: 1000}} + results, _, err = setup.Store.GetAll(setup.ctx, TestClassName, sizeQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1, doc2 + + // Get public PDFs + combinedQuery := []Query{ + {Field: "public", Operator: "Equal", Value: true}, + {Field: "type", Operator: "Equal", Value: "pdf"}, + } + results, _, err = setup.Store.GetAll(setup.ctx, TestClassName, combinedQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // doc1, doc3 + + // Vector similarity search + queryEmbedding := documents[0].embedding // Similar to doc1 + vectorResults, err := setup.Store.GetNearest(setup.ctx, TestClassName, queryEmbedding, nil, filterFields, 0.8, 10) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(vectorResults), 1) + }) + + t.Run("User Content Management Scenario", func(t *testing.T) { + // Add user content with metadata + userContent := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{"user": "alice", "lang": "en", "category": "tech"}, + }, + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{"user": "bob", "lang": "es", "category": "tech"}, + }, + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{"user": "alice", "lang": "en", "category": "sports"}, + }, + } + + filterFields := []string{"user", "lang", "category"} + + for _, content := range userContent { + err := setup.Store.Add(setup.ctx, TestClassName, content.key, content.embedding, content.metadata) + require.NoError(t, err) + } + + time.Sleep(300 * time.Millisecond) + + // Test user-specific filtering + aliceQuery := []Query{{Field: "user", Operator: "Equal", Value: "alice"}} + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, aliceQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 2) // Alice's content + + // English tech content + techEnQuery := []Query{ + {Field: "lang", Operator: "Equal", Value: "en"}, + {Field: "category", Operator: "Equal", Value: "tech"}, + } + results, _, err = setup.Store.GetAll(setup.ctx, TestClassName, techEnQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 1) // user1_content + + // Alice's similar content (semantic search with user filter) + aliceFilter := []Query{{Field: "user", Operator: "Equal", Value: "alice"}} + queryEmbedding := userContent[0].embedding + vectorResults, err := setup.Store.GetNearest(setup.ctx, TestClassName, queryEmbedding, aliceFilter, filterFields, 0.1, 10) + require.NoError(t, err) + assert.Len(t, vectorResults, 2) // Both of Alice's content + }) + + t.Run("Semantic Cache-like Workflow", func(t *testing.T) { + // Add request-response pairs with parameters + cacheEntries := []struct { + key string + embedding []float32 + metadata map[string]interface{} + }{ + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{ + "request_hash": "abc123", + "user": "u1", + "lang": "en", + "response": "answer1", + }, + }, + { + generateUUID(), + generateTestEmbedding(TestEmbeddingDim), + map[string]interface{}{ + "request_hash": "def456", + "user": "u1", + "lang": "es", + "response": "answer2", + }, + }, + } + + filterFields := []string{"request_hash", "user", "lang", "response"} + + for _, entry := range cacheEntries { + err := setup.Store.Add(setup.ctx, TestClassName, entry.key, entry.embedding, entry.metadata) + require.NoError(t, err) + } + + time.Sleep(300 * time.Millisecond) + + // Test hash-based direct retrieval (exact match) + hashQuery := []Query{{Field: "request_hash", Operator: "Equal", Value: "abc123"}} + results, _, err := setup.Store.GetAll(setup.ctx, TestClassName, hashQuery, filterFields, nil, 10) + require.NoError(t, err) + assert.Len(t, results, 1) + + // Test semantic search with user and language filters + userLangFilter := []Query{ + {Field: "user", Operator: "Equal", Value: "u1"}, + {Field: "lang", Operator: "Equal", Value: "en"}, + } + similarEmbedding := generateSimilarEmbedding(cacheEntries[0].embedding, 0.9) + vectorResults, err := setup.Store.GetNearest(setup.ctx, TestClassName, similarEmbedding, userLangFilter, filterFields, 0.7, 10) + require.NoError(t, err) + assert.Len(t, vectorResults, 1) // Should find English content for u1 + }) +} + +// ============================================================================ +// INTERFACE COMPLIANCE TESTS +// ============================================================================ + +func TestWeaviateStore_InterfaceCompliance(t *testing.T) { + // Verify that WeaviateStore implements VectorStore interface + var _ VectorStore = (*WeaviateStore)(nil) +} + +func TestVectorStoreFactory_Weaviate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + logger := bifrost.NewDefaultLogger(schemas.LogLevelInfo) + config := &Config{ + Enabled: true, + Type: VectorStoreTypeWeaviate, + Config: WeaviateConfig{ + Scheme: getEnvWithDefault("WEAVIATE_SCHEME", DefaultTestScheme), + Host: getEnvWithDefault("WEAVIATE_HOST", DefaultTestHost), + APIKey: os.Getenv("WEAVIATE_API_KEY"), + }, + } + + store, err := NewVectorStore(context.Background(), config, logger) + if err != nil { + t.Skipf("Could not create Weaviate store: %v", err) + } + defer store.Close(context.Background(), TestClassName) + + // Verify it's actually a WeaviateStore + weaviateStore, ok := store.(*WeaviateStore) + assert.True(t, ok) + assert.NotNil(t, weaviateStore) +} + +func TestWeaviateStore_NamespaceDimensionHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + setup := NewTestSetup(t) + defer setup.Cleanup(t) + + testClassName := "TestDimensionHandling" + + t.Run("Recreate class with different dimension should not crash", func(t *testing.T) { + properties := map[string]VectorStoreProperties{ + "type": {DataType: VectorStorePropertyTypeString}, + "test": {DataType: VectorStorePropertyTypeString}, + } + + // Step 1: Create class with dimension 512 + err := setup.Store.CreateNamespace(setup.ctx, testClassName, 512, properties) + require.NoError(t, err) + + // Add a document with 512-dimensional embedding + testKey512 := generateUUID() + embedding512 := generateTestEmbedding(512) + metadata := map[string]interface{}{ + "type": "test_doc", + "test": "dimension_512", + } + + err = setup.Store.Add(setup.ctx, testClassName, testKey512, embedding512, metadata) + require.NoError(t, err) + + // Verify it was added + result, err := setup.Store.GetChunk(setup.ctx, testClassName, testKey512) + require.NoError(t, err) + assert.Equal(t, "dimension_512", result.Properties["test"]) + + // Step 2: Delete the class/namespace + err = setup.Store.DeleteNamespace(setup.ctx, testClassName) + require.NoError(t, err) + + // Step 3: Create class with same name but different dimension - should not crash + err = setup.Store.CreateNamespace(setup.ctx, testClassName, 1024, properties) + require.NoError(t, err) + + // Add a document with 1024-dimensional embedding + testKey1024 := generateUUID() + embedding1024 := generateTestEmbedding(1024) + metadata1024 := map[string]interface{}{ + "type": "test_doc", + "test": "dimension_1024", + } + + err = setup.Store.Add(setup.ctx, testClassName, testKey1024, embedding1024, metadata1024) + require.NoError(t, err) + + // Verify new document exists + result, err = setup.Store.GetChunk(setup.ctx, testClassName, testKey1024) + require.NoError(t, err) + assert.Equal(t, "dimension_1024", result.Properties["test"]) + + // Verify vector search works with new dimension + vectorResults, err := setup.Store.GetNearest(setup.ctx, testClassName, embedding1024, nil, []string{"type", "test"}, 0.8, 10) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(vectorResults), 1) + assert.NotNil(t, vectorResults[0].Score) + + // Cleanup + err = setup.Store.DeleteNamespace(setup.ctx, testClassName) + if err != nil { + t.Logf("Warning: Failed to cleanup class: %v", err) + } + }) +} diff --git a/framework/version b/framework/version new file mode 100644 index 000000000..93325ddee --- /dev/null +++ b/framework/version @@ -0,0 +1 @@ +1.1.27 diff --git a/helm-charts/.gitignore b/helm-charts/.gitignore new file mode 100644 index 000000000..0b5a3a079 --- /dev/null +++ b/helm-charts/.gitignore @@ -0,0 +1,17 @@ +# Helm chart gitignore + +# Generated values files +my-values.yaml +**/my-values.yaml + +# Helm dependencies +charts/ +*.tgz + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store + diff --git a/helm-charts/README.md b/helm-charts/README.md new file mode 100644 index 000000000..1f648e8d4 --- /dev/null +++ b/helm-charts/README.md @@ -0,0 +1,172 @@ +# Bifrost Helm Charts + +Official Helm charts for deploying [Bifrost](https://www.getbifrost.ai) on Kubernetes. + +## Available Charts + +- **bifrost**: Main application chart with support for multiple storage backends + +## Quick Start + +### Install with Default Configuration (SQLite) + +```bash +helm install bifrost ./bifrost +``` + +### Install with PostgreSQL + +```bash +helm install bifrost ./bifrost -f bifrost/values-examples/postgres-only.yaml +``` + +### Install with PostgreSQL + Weaviate + +```bash +helm install bifrost ./bifrost -f bifrost/values-examples/postgres-weaviate.yaml +``` + +## Available Configurations + +We provide several pre-configured examples in `bifrost/values-examples/`: + +1. **postgres-only.yaml** - PostgreSQL for config and logs +2. **postgres-weaviate.yaml** - PostgreSQL + Weaviate vector store +3. **postgres-redis.yaml** - PostgreSQL + Redis vector store +4. **sqlite-only.yaml** - SQLite for config and logs +5. **sqlite-weaviate.yaml** - SQLite + Weaviate vector store +6. **sqlite-redis.yaml** - SQLite + Redis vector store +7. **external-postgres.yaml** - Use external PostgreSQL instance +8. **production-ha.yaml** - Production HA setup with auto-scaling + +## Documentation + +For detailed documentation, see the [Bifrost chart README](./bifrost/README.md). + +## Repository Structure + +```bash +helm-charts/ +β”œβ”€β”€ README.md # This file +└── bifrost/ + β”œβ”€β”€ Chart.yaml # Chart metadata + β”œβ”€β”€ values.yaml # Default values + β”œβ”€β”€ README.md # Detailed documentation + β”œβ”€β”€ templates/ # Kubernetes manifests + β”‚ β”œβ”€β”€ deployment.yaml + β”‚ β”œβ”€β”€ service.yaml + β”‚ β”œβ”€β”€ ingress.yaml + β”‚ β”œβ”€β”€ configmap.yaml + β”‚ β”œβ”€β”€ postgresql-*.yaml # PostgreSQL resources + β”‚ β”œβ”€β”€ weaviate-*.yaml # Weaviate resources + β”‚ └── redis-*.yaml # Redis resources + └── values-examples/ # Example configurations + β”œβ”€β”€ postgres-only.yaml + β”œβ”€β”€ postgres-weaviate.yaml + β”œβ”€β”€ postgres-redis.yaml + β”œβ”€β”€ sqlite-only.yaml + β”œβ”€β”€ sqlite-weaviate.yaml + β”œβ”€β”€ sqlite-redis.yaml + β”œβ”€β”€ external-postgres.yaml + β”œβ”€β”€ production-ha.yaml + └── semantic-cache-secret-example.yaml # Secret example for API keys +``` + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.2.0+ +- PV provisioner support (for persistent storage) + +## Installation Examples + +### Development Setup + +```bash +# Simple SQLite setup for local development +helm install bifrost ./bifrost \ + --set bifrost.providers.openai.keys[0].value="sk-..." \ + --set bifrost.providers.openai.keys[0].weight=1 +``` + +### Production Setup + +```bash +# High-availability setup with PostgreSQL and monitoring +helm install bifrost ./bifrost \ + -f bifrost/values-examples/production-ha.yaml \ + --set bifrost.encryptionKey="your-secure-key" \ + --set postgresql.auth.password="secure-db-password" \ + --set ingress.hosts[0].host="bifrost.yourdomain.com" +``` + +### Semantic Caching Setup + +For semantic caching, create a Kubernetes Secret for your OpenAI API key: + +```bash +# Create secret for semantic cache API key +kubectl create secret generic bifrost-semantic-cache \ + --from-literal=openai-key=sk-YOUR_OPENAI_API_KEY \ + -n default + +# Install with semantic caching enabled +helm install bifrost ./bifrost \ + -f bifrost/values-examples/postgres-weaviate.yaml +``` + +The values examples now use `secretRef` to reference the secret instead of inline keys for better security. + +## Customization + +Create your own values file: + +```yaml +# my-values.yaml +storage: + mode: postgres + +postgresql: + enabled: true + +bifrost: + encryptionKey: "my-encryption-key" + providers: + openai: + keys: + - value: "sk-..." + weight: 1 + anthropic: + keys: + - value: "sk-ant-..." + weight: 1 +``` + +Then install: + +```bash +helm install bifrost ./bifrost -f my-values.yaml +``` + +## Upgrade + +```bash +helm upgrade bifrost ./bifrost -f your-values.yaml +``` + +## Uninstall + +```bash +helm uninstall bifrost +``` + +## Support + +- Documentation: [https://www.getbifrost.ai/docs](https://www.getbifrost.ai/docs) +- GitHub: [https://github.com/maxim-ai/bifrost](https://github.com/maxim-ai/bifrost) +- Issues: [https://github.com/maxim-ai/bifrost/issues](https://github.com/maxim-ai/bifrost/issues) + +## License + +Apache 2.0 - See [LICENSE](../LICENSE) for more information. + diff --git a/helm-charts/bifrost/.helmignore b/helm-charts/bifrost/.helmignore new file mode 100644 index 000000000..898df4886 --- /dev/null +++ b/helm-charts/bifrost/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ + diff --git a/helm-charts/bifrost/Chart.yaml b/helm-charts/bifrost/Chart.yaml new file mode 100644 index 000000000..730be6c95 --- /dev/null +++ b/helm-charts/bifrost/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +name: bifrost +description: A Helm chart for deploying Bifrost - AI Gateway with unified interface for multiple providers +type: application +version: 1.3.5 +appVersion: "1.3.5" +keywords: + - ai + - gateway + - llm + - openai + - anthropic +home: https://www.getbifrost.ai +sources: + - https://github.com/maximhq/bifrost +maintainers: + - name: Bifrost Team + email: support@getbifrost.ai +icon: https://www.getbifrost.ai/favicon.png + diff --git a/helm-charts/bifrost/README.md b/helm-charts/bifrost/README.md new file mode 100644 index 000000000..249a075ce --- /dev/null +++ b/helm-charts/bifrost/README.md @@ -0,0 +1,399 @@ +# Bifrost Helm Chart + +This Helm chart deploys [Bifrost](https://www.getbifrost.ai) - an AI Gateway with unified interface for multiple LLM providers. + +## Features + +- πŸš€ Support for multiple storage backends (SQLite, PostgreSQL) +- πŸ” Optional vector store integration (Weaviate, Redis) +- πŸ“Š Built-in observability and metrics +- πŸ” Encryption support for sensitive data +- 🎯 Semantic caching capabilities +- πŸ“ˆ Horizontal Pod Autoscaling +- 🌐 Ingress support with TLS +- πŸ”„ Multiple deployment configurations + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.2.0+ +- PV provisioner support in the underlying infrastructure (if using persistence) + +## Installation + +### Quick Start (SQLite) + +```bash +helm install bifrost ./bifrost +``` + +This will deploy Bifrost with SQLite as the storage backend. + +### PostgreSQL Backend + +```bash +helm install bifrost ./bifrost -f values-examples/postgres-only.yaml +``` + +### PostgreSQL + Weaviate + +```bash +helm install bifrost ./bifrost -f values-examples/postgres-weaviate.yaml +``` + +### PostgreSQL + Redis + +```bash +helm install bifrost ./bifrost -f values-examples/postgres-redis.yaml +``` + +### SQLite + Weaviate + +```bash +helm install bifrost ./bifrost -f values-examples/sqlite-weaviate.yaml +``` + +### SQLite + Redis + +```bash +helm install bifrost ./bifrost -f values-examples/sqlite-redis.yaml +``` + +### External PostgreSQL + +```bash +# Edit values-examples/external-postgres.yaml with your database details +helm install bifrost ./bifrost -f values-examples/external-postgres.yaml +``` + +### Production HA Setup + +```bash +# Edit values-examples/production-ha.yaml with your configuration +helm install bifrost ./bifrost -f values-examples/production-ha.yaml +``` + +## Configuration + +### Storage Modes + +The chart supports two storage modes controlled by `storage.mode`: + +- **sqlite** (default): Uses SQLite databases stored in persistent volumes +- **postgres**: Uses PostgreSQL for config and logs storage + +### Vector Store Options + +Configure semantic caching with vector stores: + +- **none** (default): No vector store +- **weaviate**: Use Weaviate for vector storage +- **redis**: Use Redis for vector storage + +### Key Configuration Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `storage.mode` | Storage backend (sqlite/postgres) | `sqlite` | +| `storage.persistence.enabled` | Enable persistent storage for SQLite | `true` | +| `storage.persistence.size` | Size of persistent volume | `10Gi` | +| `postgresql.enabled` | Deploy PostgreSQL | `false` | +| `postgresql.external.enabled` | Use external PostgreSQL | `false` | +| `vectorStore.enabled` | Enable vector store | `false` | +| `vectorStore.type` | Vector store type (none/weaviate/redis) | `none` | +| `bifrost.encryptionKey` | Encryption key for sensitive data | `""` | +| `bifrost.client.enableLogging` | Enable request/response logging | `true` | +| `bifrost.providers` | LLM provider configurations | `{}` | +| `ingress.enabled` | Enable ingress | `false` | +| `autoscaling.enabled` | Enable HPA | `false` | + +### Adding Provider Keys + +Edit your values file or use `--set`: + +```yaml +bifrost: + providers: + openai: + keys: + - value: "sk-..." + weight: 1 + anthropic: + keys: + - value: "sk-ant-..." + weight: 1 +``` + +Or via command line: + +```bash +helm install bifrost ./bifrost \ + --set bifrost.providers.openai.keys[0].value="sk-..." \ + --set bifrost.providers.openai.keys[0].weight=1 +``` + +### Enabling Plugins + +```yaml +bifrost: + plugins: + telemetry: + enabled: true + config: {} + + logging: + enabled: true + config: {} + + governance: + enabled: true + config: + isVkMandatory: false + + semanticCache: + enabled: true + config: + provider: "openai" + keys: + - "sk-..." + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.8 + ttl: "5m" +``` + +## Architecture Patterns + +### Pattern 1: Simple Development Setup +- **Storage**: SQLite +- **Scale**: Single replica +- **Use Case**: Local development, testing + +```bash +helm install bifrost ./bifrost +``` + +### Pattern 2: Production with PostgreSQL +- **Storage**: PostgreSQL +- **Scale**: Multiple replicas with HPA +- **Features**: Logging, telemetry, governance +- **Use Case**: Production deployments + +```bash +helm install bifrost ./bifrost -f values-examples/production-ha.yaml +``` + +### Pattern 3: ML/AI Workloads +- **Storage**: PostgreSQL +- **Vector Store**: Weaviate +- **Features**: Semantic caching, embeddings +- **Use Case**: High-volume AI inference with caching + +```bash +helm install bifrost ./bifrost -f values-examples/postgres-weaviate.yaml +``` + +## Upgrade + +```bash +helm upgrade bifrost ./bifrost -f your-values.yaml +``` + +## Uninstall + +```bash +helm uninstall bifrost +``` + +To delete PVCs: + +```bash +kubectl delete pvc -l app.kubernetes.io/instance=bifrost +``` + +## Accessing Bifrost + +### Port Forward (ClusterIP) + +```bash +export POD_NAME=$(kubectl get pods -l "app.kubernetes.io/name=bifrost,app.kubernetes.io/instance=bifrost" -o jsonpath="{.items[0].metadata.name}") +kubectl port-forward $POD_NAME 8080:8080 +``` + +Then access at http://localhost:8080 + +### LoadBalancer + +```bash +export SERVICE_IP=$(kubectl get svc bifrost --template "{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}") +echo http://$SERVICE_IP:8080 +``` + +### Ingress + +Configure `ingress.enabled=true` and access via your domain. + +## Monitoring + +Bifrost exposes Prometheus metrics at `/metrics` endpoint. + +Enable telemetry plugin: + +```yaml +bifrost: + plugins: + telemetry: + enabled: true +``` + +## Security Considerations + +1. **Encryption Key**: Always set a strong encryption key for production: + ```yaml + bifrost: + encryptionKey: "your-secure-32-byte-key-here" + ``` + +2. **Database Passwords**: Use strong passwords for PostgreSQL/Redis: + ```yaml + postgresql: + auth: + password: "use-a-strong-password" + ``` + +3. **Secrets Management**: Consider using external secret management: + ```yaml + envFrom: + - secretRef: + name: bifrost-secrets + ``` + +4. **Network Policies**: Implement Kubernetes network policies to restrict traffic. + +5. **RBAC**: Use appropriate service account permissions. + +## Troubleshooting + +### Check Pod Status + +```bash +kubectl get pods -l app.kubernetes.io/name=bifrost +kubectl logs -l app.kubernetes.io/name=bifrost +``` + +### Check Configuration + +```bash +kubectl get configmap bifrost-config -o yaml +``` + +### Database Connection Issues + +For PostgreSQL: +```bash +kubectl exec -it deployment/bifrost-postgresql -- psql -U bifrost -d bifrost +``` + +For SQLite: +```bash +kubectl exec -it deployment/bifrost -- ls -la /app/data/ +``` + +### Vector Store Issues + +Check Weaviate: +```bash +kubectl logs -l app.kubernetes.io/component=vectorstore +kubectl port-forward svc/bifrost-weaviate 8080:8080 +``` + +Check Redis: +```bash +kubectl logs -l app.kubernetes.io/component=redis +kubectl exec -it deployment/bifrost-redis-master -- redis-cli ping +``` + +## Examples + +### Example 1: Deploy with OpenAI Provider + +```bash +cat < +``` + +**Option 2: Using a manifest file** +```bash +# Edit semantic-cache-secret-example.yaml with your API key +kubectl apply -f values-examples/semantic-cache-secret-example.yaml -n +``` + +### Deploying with Secrets + +```bash +# 1. Create the secret first +kubectl create secret generic bifrost-semantic-cache \ + --from-literal=openai-key=sk-YOUR_OPENAI_API_KEY \ + -n default + +# 2. Deploy Bifrost with the values file +helm install bifrost . \ + -f values-examples/production-ha.yaml \ + -n default +``` + +## Backward Compatibility + +The changes maintain full backward compatibility: + +- **With secretRef**: Keys are injected via environment variable +- **Without secretRef**: Keys can still be provided directly in `config.keys` (not recommended for production) +- **Existing deployments**: Continue to work without changes + +## Security Best Practices + +### βœ… DO: +- Use Kubernetes Secrets for all API keys +- Create secrets in the same namespace as the deployment +- Use RBAC to restrict secret access +- Rotate API keys regularly +- Use tools like Sealed Secrets or External Secrets Operator for GitOps + +### ❌ DON'T: +- Hardcode API keys in values files +- Commit secrets to version control +- Share secrets across namespaces unnecessarily +- Use plaintext keys in production environments + +## Migration Guide + +If you have existing deployments with hardcoded keys: + +### Step 1: Create the Secret +```bash +# Extract your current key from values +kubectl create secret generic bifrost-semantic-cache \ + --from-literal=openai-key=YOUR_CURRENT_KEY \ + -n +``` + +### Step 2: Update Your Values File +```yaml +# Remove the keys array +plugins: + semanticCache: + enabled: true + # Add secretRef + secretRef: + name: "bifrost-semantic-cache" + key: "openai-key" + config: + provider: "openai" + # Remove: keys: ["sk-..."] + embeddingModel: "text-embedding-3-small" +``` + +### Step 3: Upgrade the Deployment +```bash +helm upgrade bifrost . \ + -f your-updated-values.yaml \ + -n +``` + +## Environment Variable + +The semantic cache plugin now supports reading keys from the environment variable: +- **Variable Name**: `SEMANTIC_CACHE_API_KEY` +- **Source**: Kubernetes Secret referenced by `secretRef` +- **Format**: Single API key or comma-separated keys for multiple keys + +## Troubleshooting + +### Secret Not Found +``` +Error: secrets "bifrost-semantic-cache" not found +``` +**Solution**: Create the secret before deploying: +```bash +kubectl create secret generic bifrost-semantic-cache \ + --from-literal=openai-key=sk-YOUR_KEY \ + -n +``` + +### Invalid API Key +``` +Error: API key authentication failed +``` +**Solution**: Verify the secret contains the correct key: +```bash +kubectl get secret bifrost-semantic-cache -n -o jsonpath='{.data.openai-key}' | base64 -d +``` + +### Plugin Not Using Secret +**Solution**: Verify the values file includes the `secretRef` section and the pod has the environment variable: +```bash +kubectl exec -n -- env | grep SEMANTIC_CACHE_API_KEY +``` + +## References + +- [Kubernetes Secrets Documentation](https://kubernetes.io/docs/concepts/configuration/secret/) +- [Helm Secrets Management](https://helm.sh/docs/chart_best_practices/secrets/) +- [Bifrost Documentation](https://www.getbifrost.ai/docs) + diff --git a/helm-charts/bifrost/scripts/generate-values.sh b/helm-charts/bifrost/scripts/generate-values.sh new file mode 100755 index 000000000..bb6dde26a --- /dev/null +++ b/helm-charts/bifrost/scripts/generate-values.sh @@ -0,0 +1,339 @@ +#!/bin/bash +# Bifrost Values File Generator +# This interactive script helps you generate a custom values.yaml file + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_info() { echo -e "${BLUE}β„Ή ${NC}$1"; } +print_success() { echo -e "${GREEN}βœ“ ${NC}$1"; } +print_warning() { echo -e "${YELLOW}⚠ ${NC}$1"; } +print_error() { echo -e "${RED}βœ— ${NC}$1"; } + +print_banner() { + echo "" + echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}" + echo -e "${BLUE}β•‘ β•‘${NC}" + echo -e "${BLUE}β•‘ Bifrost Values Generator β•‘${NC}" + echo -e "${BLUE}β•‘ β•‘${NC}" + echo -e "${BLUE}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" + echo "" +} + +print_banner + +OUTPUT_FILE="my-values.yaml" + +# Storage mode +echo "1. Select storage mode:" +echo " 1) SQLite (simple, single node)" +echo " 2) PostgreSQL (production, scalable)" +read -p "Choice [1-2]: " storage_choice + +case $storage_choice in + 1) STORAGE_MODE="sqlite" ;; + 2) STORAGE_MODE="postgres" ;; + *) print_error "Invalid choice"; exit 1 ;; +esac + +# Vector store +echo "" +echo "2. Do you need vector store for semantic caching?" +read -p "Enable vector store? (y/n): " vector_choice + +if [[ "$vector_choice" =~ ^[Yy]$ ]]; then + echo " 1) Weaviate" + echo " 2) Redis" + read -p "Choice [1-2]: " vector_type_choice + case $vector_type_choice in + 1) VECTOR_TYPE="weaviate" ;; + 2) VECTOR_TYPE="redis" ;; + *) print_error "Invalid choice"; exit 1 ;; + esac + VECTOR_ENABLED="true" +else + VECTOR_ENABLED="false" + VECTOR_TYPE="none" +fi + +# Deployment type +echo "" +echo "3. Deployment type:" +echo " 1) Development (1 replica, minimal resources)" +echo " 2) Production (3+ replicas, auto-scaling)" +read -p "Choice [1-2]: " deploy_choice + +case $deploy_choice in + 1) + REPLICAS="1" + AUTOSCALING="false" + CPU_REQUEST="250m" + MEM_REQUEST="256Mi" + CPU_LIMIT="1000m" + MEM_LIMIT="1Gi" + ;; + 2) + REPLICAS="3" + AUTOSCALING="true" + CPU_REQUEST="1000m" + MEM_REQUEST="1Gi" + CPU_LIMIT="4000m" + MEM_LIMIT="4Gi" + ;; + *) print_error "Invalid choice"; exit 1 ;; +esac + +# Ingress +echo "" +read -p "4. Do you want to enable Ingress? (y/n): " ingress_choice +if [[ "$ingress_choice" =~ ^[Yy]$ ]]; then + INGRESS_ENABLED="true" + read -p " Enter your domain (e.g., bifrost.yourdomain.com): " DOMAIN +else + INGRESS_ENABLED="false" + DOMAIN="bifrost.local" +fi + +# Encryption key +echo "" +read -p "5. Enter encryption key (leave empty to skip): " ENCRYPTION_KEY + +# Check if output file already exists +if [[ -f "$OUTPUT_FILE" ]]; then + echo "" + print_warning "File '$OUTPUT_FILE' already exists." + read -p "Do you want to overwrite it? (y/n): " overwrite_choice + if [[ ! "$overwrite_choice" =~ ^[Yy]$ ]]; then + print_info "Generation aborted. No files were modified." + exit 0 + fi +fi + +# Generate the file +print_info "Generating values file..." + +cat > "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" < /dev/null; then + print_error "Helm is not installed. Please install Helm 3.2.0 or later." + exit 1 + fi + + if ! command -v kubectl &> /dev/null; then + print_error "kubectl is not installed. Please install kubectl." + exit 1 + fi + + # Check kubectl connection + if ! kubectl cluster-info &> /dev/null; then + print_error "Cannot connect to Kubernetes cluster. Please check your kubeconfig." + exit 1 + fi + + print_success "All prerequisites met" +} + +# Show menu +show_menu() { + echo "" + echo "Select a deployment configuration:" + echo "" + echo " 1) SQLite only (simple, local development)" + echo " 2) PostgreSQL only (production-ready database)" + echo " 3) PostgreSQL + Weaviate (semantic caching with Weaviate)" + echo " 4) PostgreSQL + Redis (semantic caching with Redis)" + echo " 5) SQLite + Weaviate (local dev with semantic caching)" + echo " 6) SQLite + Redis (local dev with Redis caching)" + echo " 7) External PostgreSQL (use your own database)" + echo " 8) Production HA (high-availability setup)" + echo " 9) Custom (use your own values file)" + echo "" + echo " 0) Exit" + echo "" +} + +# Get user input +get_input() { + read -p "Enter your choice [0-9]: " choice + case $choice in + 1) CONFIG="sqlite-only" ;; + 2) CONFIG="postgres-only" ;; + 3) CONFIG="postgres-weaviate" ;; + 4) CONFIG="postgres-redis" ;; + 5) CONFIG="sqlite-weaviate" ;; + 6) CONFIG="sqlite-redis" ;; + 7) CONFIG="external-postgres" ;; + 8) CONFIG="production-ha" ;; + 9) CONFIG="custom" ;; + 0) exit 0 ;; + *) + print_error "Invalid choice. Please try again." + return 1 + ;; + esac + return 0 +} + +# Get release name +get_release_name() { + read -p "Enter release name (default: bifrost): " RELEASE_NAME + RELEASE_NAME=${RELEASE_NAME:-bifrost} +} + +# Get namespace +get_namespace() { + read -p "Enter namespace (default: default): " NAMESPACE + NAMESPACE=${NAMESPACE:-default} + + # Check if namespace exists + if ! kubectl get namespace "$NAMESPACE" &> /dev/null; then + read -p "Namespace '$NAMESPACE' does not exist. Create it? (y/n): " CREATE_NS + if [[ "$CREATE_NS" =~ ^[Yy]$ ]]; then + kubectl create namespace "$NAMESPACE" + print_success "Namespace '$NAMESPACE' created" + else + print_error "Installation aborted" + exit 1 + fi + fi +} + +# Get custom values file +get_custom_values() { + read -p "Enter path to custom values file: " CUSTOM_VALUES + if [[ ! -f "$CUSTOM_VALUES" ]]; then + print_error "File not found: $CUSTOM_VALUES" + exit 1 + fi +} + +# Install chart +install_chart() { + local values_file="" + + if [[ "$CONFIG" == "custom" ]]; then + # Validate that CUSTOM_VALUES is non-empty + if [[ -z "$CUSTOM_VALUES" ]]; then + print_error "Custom values file path is empty" + exit 1 + fi + values_file="$CUSTOM_VALUES" + # Validate that the custom values file exists and is a regular file + if [[ ! -f "$values_file" ]]; then + print_error "Custom values file does not exist or is not a regular file: $values_file" + exit 1 + fi + else + values_file="${CHART_DIR}/values-examples/${CONFIG}.yaml" + # Validate that the predefined values file exists + if [[ ! -f "$values_file" ]]; then + print_error "Values file does not exist: $values_file" + exit 1 + fi + fi + + print_info "Installing Bifrost..." + print_info "Release: $RELEASE_NAME" + print_info "Namespace: $NAMESPACE" + print_info "Configuration: $CONFIG" + echo "" + + # Ask for confirmation + read -p "Proceed with installation? (y/n): " CONFIRM + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + print_warning "Installation cancelled" + exit 0 + fi + + # Run helm install with explicit chart directory + if helm install "$RELEASE_NAME" "$CHART_DIR" \ + --namespace "$NAMESPACE" \ + -f "$values_file" \ + --create-namespace; then + + print_success "Bifrost installed successfully!" + echo "" + print_info "To check the status:" + echo " helm status $RELEASE_NAME -n $NAMESPACE" + echo "" + print_info "To get the application URL:" + echo " kubectl --namespace $NAMESPACE port-forward svc/$RELEASE_NAME 8080:8080" + echo " Then visit: http://localhost:8080" + echo "" + print_info "To view logs:" + echo " kubectl logs -l app.kubernetes.io/name=bifrost -n $NAMESPACE -f" + echo "" + else + print_error "Installation failed" + exit 1 + fi +} + +# Main function +main() { + print_banner + check_prerequisites + + while true; do + show_menu + if get_input; then + break + fi + done + + get_release_name + get_namespace + + if [[ "$CONFIG" == "custom" ]]; then + get_custom_values + fi + + # Set explicit chart directory (parent of scripts directory) + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + CHART_DIR="$SCRIPT_DIR/.." + + install_chart +} + +# Run main function +main + diff --git a/helm-charts/bifrost/scripts/validate.sh b/helm-charts/bifrost/scripts/validate.sh new file mode 100755 index 000000000..cc89e90a4 --- /dev/null +++ b/helm-charts/bifrost/scripts/validate.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Bifrost Helm Chart Validation Script +# This script validates the Helm chart before installation + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_info() { + echo -e "${BLUE}β„Ή ${NC}$1" +} + +print_success() { + echo -e "${GREEN}βœ“ ${NC}$1" +} + +print_error() { + echo -e "${RED}βœ— ${NC}$1" +} + +print_banner() { + echo "" + echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}" + echo -e "${BLUE}β•‘ β•‘${NC}" + echo -e "${BLUE}β•‘ Bifrost Chart Validator β•‘${NC}" + echo -e "${BLUE}β•‘ β•‘${NC}" + echo -e "${BLUE}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" + echo "" +} + +# Set explicit chart directory (parent of scripts directory) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHART_DIR="$SCRIPT_DIR/.." + +print_banner + +# Check if Helm is installed +print_info "Checking Helm installation..." +if ! command -v helm &> /dev/null; then + print_error "Helm is not installed" + exit 1 +fi +print_success "Helm is installed" + +# Lint the chart +print_info "Linting Helm chart..." +if helm lint "$CHART_DIR"; then + print_success "Chart linting passed" +else + print_error "Chart linting failed" + exit 1 +fi + +# Template the chart with default values +print_info "Templating chart with default values..." +if helm template test-release "$CHART_DIR" > /dev/null; then + print_success "Default values template successful" +else + print_error "Default values template failed" + exit 1 +fi + +# Test all example configurations +print_info "Testing example configurations..." +for config in "$CHART_DIR"/values-examples/*.yaml; do + config_name=$(basename "$config") + print_info " Testing $config_name..." + if helm template test-release "$CHART_DIR" -f "$config" > /dev/null; then + print_success " $config_name: OK" + else + print_error " $config_name: FAILED" + exit 1 + fi +done + +# Dry run install +print_info "Performing dry-run installation..." +if helm install test-release "$CHART_DIR" --dry-run --debug > /dev/null 2>&1; then + print_success "Dry-run installation successful" +else + print_error "Dry-run installation failed" + exit 1 +fi + +echo "" +print_success "All validation checks passed!" +echo "" +print_info "Chart is ready for installation" + diff --git a/helm-charts/bifrost/values-examples/external-postgres.yaml b/helm-charts/bifrost/values-examples/external-postgres.yaml new file mode 100644 index 000000000..1bca7b88e --- /dev/null +++ b/helm-charts/bifrost/values-examples/external-postgres.yaml @@ -0,0 +1,36 @@ +# Configuration: External PostgreSQL (not deployed by Helm) +# Usage: helm install bifrost ./bifrost -f values-examples/external-postgres.yaml + +# Storage configuration +storage: + mode: postgres + configStore: + enabled: true + logsStore: + enabled: true + +# Use external PostgreSQL +postgresql: + enabled: false + external: + enabled: true + host: "your-postgres-host.example.com" + port: 5432 + user: bifrost + password: "your-secure-password" + database: bifrost + sslMode: require + +# No vector store +vectorStore: + enabled: false + type: none + +# Bifrost configuration +bifrost: + encryptionKey: "your-encryption-key-here" + client: + enableLogging: true + providers: {} + # Add your provider keys here + diff --git a/helm-charts/bifrost/values-examples/postgres-only.yaml b/helm-charts/bifrost/values-examples/postgres-only.yaml new file mode 100644 index 000000000..577b2934c --- /dev/null +++ b/helm-charts/bifrost/values-examples/postgres-only.yaml @@ -0,0 +1,46 @@ +# Configuration: PostgreSQL for config and logs store +# Usage: helm install bifrost ./bifrost -f values-examples/postgres-only.yaml + +# Storage configuration +storage: + mode: postgres + configStore: + enabled: true + logsStore: + enabled: true + +# Deploy PostgreSQL +postgresql: + enabled: true + auth: + username: bifrost + password: bifrost_password + database: bifrost + primary: + persistence: + enabled: true + size: 10Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi + +# No vector store +vectorStore: + enabled: false + type: none + +# Bifrost configuration +bifrost: + client: + enableLogging: true + providers: {} + # Add your provider keys here + # openai: + # keys: + # - value: "sk-..." + # weight: 1 + diff --git a/helm-charts/bifrost/values-examples/postgres-redis.yaml b/helm-charts/bifrost/values-examples/postgres-redis.yaml new file mode 100644 index 000000000..0d6e17520 --- /dev/null +++ b/helm-charts/bifrost/values-examples/postgres-redis.yaml @@ -0,0 +1,75 @@ +# Configuration: PostgreSQL for config/logs + Redis for vector store +# Usage: helm install bifrost ./bifrost -f values-examples/postgres-redis.yaml + +# Storage configuration +storage: + mode: postgres + configStore: + enabled: true + logsStore: + enabled: true + +# Deploy PostgreSQL +postgresql: + enabled: true + auth: + username: bifrost + password: bifrost_password + database: bifrost + primary: + persistence: + enabled: true + size: 10Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi + +# Deploy Redis for vector store +vectorStore: + enabled: true + type: redis + redis: + enabled: true + auth: + enabled: true + password: "redis_password" + master: + persistence: + enabled: true + size: 8Gi + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + +# Bifrost configuration +bifrost: + client: + enableLogging: true + providers: {} + # Add your provider keys here + + # Enable semantic cache plugin to use Redis vector store + plugins: + semanticCache: + enabled: true + # Reference to external Kubernetes Secret for OpenAI API key + # Create the secret with: kubectl create secret generic bifrost-semantic-cache --from-literal=openai-key=sk-YOUR_OPENAI_KEY + secretRef: + name: "bifrost-semantic-cache" + key: "openai-key" + config: + provider: "openai" + # keys are injected from the secret via environment variable + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.8 + ttl: "5m" + diff --git a/helm-charts/bifrost/values-examples/postgres-weaviate.yaml b/helm-charts/bifrost/values-examples/postgres-weaviate.yaml new file mode 100644 index 000000000..e01714094 --- /dev/null +++ b/helm-charts/bifrost/values-examples/postgres-weaviate.yaml @@ -0,0 +1,72 @@ +# Configuration: PostgreSQL for config/logs + Weaviate for vector store +# Usage: helm install bifrost ./bifrost -f values-examples/postgres-weaviate.yaml + +# Storage configuration +storage: + mode: postgres + configStore: + enabled: true + logsStore: + enabled: true + +# Deploy PostgreSQL +postgresql: + enabled: true + auth: + username: bifrost + password: bifrost_password + database: bifrost + primary: + persistence: + enabled: true + size: 10Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi + +# Deploy Weaviate for vector store +vectorStore: + enabled: true + type: weaviate + weaviate: + enabled: true + replicas: 1 + persistence: + enabled: true + size: 10Gi + resources: + limits: + cpu: 1000m + memory: 2Gi + requests: + cpu: 500m + memory: 1Gi + +# Bifrost configuration +bifrost: + client: + enableLogging: true + providers: {} + # Add your provider keys here + + # Enable semantic cache plugin to use vector store + plugins: + semanticCache: + enabled: true + # Reference to external Kubernetes Secret for OpenAI API key + # Create the secret with: kubectl create secret generic bifrost-semantic-cache --from-literal=openai-key=sk-YOUR_OPENAI_KEY + secretRef: + name: "bifrost-semantic-cache" + key: "openai-key" + config: + provider: "openai" + # keys are injected from the secret via environment variable + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.8 + ttl: "5m" + diff --git a/helm-charts/bifrost/values-examples/production-ha.yaml b/helm-charts/bifrost/values-examples/production-ha.yaml new file mode 100644 index 000000000..0a1c0d63c --- /dev/null +++ b/helm-charts/bifrost/values-examples/production-ha.yaml @@ -0,0 +1,146 @@ +# Configuration: Production High-Availability Setup +# PostgreSQL + Weaviate + Auto-scaling + Ingress +# Usage: helm install bifrost ./bifrost -f values-examples/production-ha.yaml + +# Multiple replicas for HA +replicaCount: 3 + +# Auto-scaling configuration +autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + +# Ingress configuration +ingress: + enabled: true + className: "nginx" + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + hosts: + - host: bifrost.yourdomain.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: bifrost-tls + hosts: + - bifrost.yourdomain.com + +# Resource limits for production +resources: + limits: + cpu: 4000m + memory: 4Gi + requests: + cpu: 1000m + memory: 1Gi + +# Storage configuration +storage: + mode: postgres + configStore: + enabled: true + logsStore: + enabled: true + +# PostgreSQL with higher resources +postgresql: + enabled: true + auth: + username: bifrost + password: "CHANGE_ME_SECURE_PASSWORD" + database: bifrost + primary: + persistence: + enabled: true + size: 50Gi + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 1000m + memory: 2Gi + +# Weaviate for semantic caching +vectorStore: + enabled: true + type: weaviate + weaviate: + enabled: true + replicas: 2 + persistence: + enabled: true + size: 50Gi + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 1000m + memory: 2Gi + +# Bifrost production configuration +bifrost: + # Reference to external Kubernetes Secret for encryption key + # Create the secret with: kubectl create secret generic bifrost-encryption --from-literal=key=YOUR_ENCRYPTION_KEY + encryptionKeySecret: + name: "bifrost-encryption" + key: "key" + + client: + initialPoolSize: 1000 + allowedOrigins: + - "https://yourdomain.com" + - "https://app.yourdomain.com" + enableLogging: true + enableGovernance: true + maxRequestBodySizeMb: 100 + + providers: {} + # Add your production provider keys here + + plugins: + telemetry: + enabled: true + config: {} + + logging: + enabled: true + config: {} + + semanticCache: + enabled: true + # Reference to external Kubernetes Secret for OpenAI API key + # Create the secret with: kubectl create secret generic bifrost-semantic-cache --from-literal=openai-key=sk-YOUR_OPENAI_KEY + secretRef: + name: "bifrost-semantic-cache" + key: "openai-key" + config: + provider: "openai" + # keys are injected from the secret via environment variable + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.85 + ttl: "1h" + conversationHistoryThreshold: 5 + +# Pod affinity for better distribution +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: + - bifrost + topologyKey: kubernetes.io/hostname + diff --git a/helm-charts/bifrost/values-examples/semantic-cache-secret-example.yaml b/helm-charts/bifrost/values-examples/semantic-cache-secret-example.yaml new file mode 100644 index 000000000..fb1a2dd56 --- /dev/null +++ b/helm-charts/bifrost/values-examples/semantic-cache-secret-example.yaml @@ -0,0 +1,28 @@ +# Example Kubernetes Secret for Semantic Cache API Key +# This secret is referenced by production-ha.yaml +# +# IMPORTANT: Do not commit this file with real API keys to version control! +# +# Usage: +# 1. Replace 'YOUR_OPENAI_API_KEY' with your actual OpenAI API key +# 2. Apply the secret: kubectl apply -f semantic-cache-secret-example.yaml -n +# 3. Deploy Bifrost with: helm install bifrost . -f values-examples/production-ha.yaml -n +# +# Alternative: Create the secret using kubectl command: +# kubectl create secret generic bifrost-semantic-cache \ +# --from-literal=openai-key=sk-YOUR_OPENAI_API_KEY \ +# -n + +apiVersion: v1 +kind: Secret +metadata: + name: bifrost-semantic-cache + namespace: default # Change this to your target namespace + labels: + app.kubernetes.io/name: bifrost + app.kubernetes.io/component: semantic-cache +type: Opaque +stringData: + # Replace with your actual OpenAI API key + openai-key: "sk-YOUR_OPENAI_API_KEY" + diff --git a/helm-charts/bifrost/values-examples/sqlite-only.yaml b/helm-charts/bifrost/values-examples/sqlite-only.yaml new file mode 100644 index 000000000..860f8a945 --- /dev/null +++ b/helm-charts/bifrost/values-examples/sqlite-only.yaml @@ -0,0 +1,34 @@ +# Configuration: SQLite for config and logs store +# Usage: helm install bifrost ./bifrost -f values-examples/sqlite-only.yaml + +# Storage configuration +storage: + mode: sqlite + persistence: + enabled: true + size: 10Gi + configStore: + enabled: true + logsStore: + enabled: true + +# No PostgreSQL +postgresql: + enabled: false + +# No vector store +vectorStore: + enabled: false + type: none + +# Bifrost configuration +bifrost: + client: + enableLogging: true + providers: {} + # Add your provider keys here + # openai: + # keys: + # - value: "sk-..." + # weight: 1 + diff --git a/helm-charts/bifrost/values-examples/sqlite-redis.yaml b/helm-charts/bifrost/values-examples/sqlite-redis.yaml new file mode 100644 index 000000000..b630a9ef4 --- /dev/null +++ b/helm-charts/bifrost/values-examples/sqlite-redis.yaml @@ -0,0 +1,76 @@ +# Configuration: SQLite for config/logs + Redis for vector store +# Usage: helm install bifrost ./bifrost -f values-examples/sqlite-redis.yaml +# +# SECURITY NOTE: This example contains placeholder values that MUST be replaced +# before deployment. Specifically: +# - Redis password must be set to a strong, randomly generated value +# - Provider API keys must be replaced with real keys +# See inline comments for specific requirements. + +# Storage configuration +storage: + mode: sqlite + persistence: + enabled: true + size: 10Gi + configStore: + enabled: true + logsStore: + enabled: true + +# No PostgreSQL +postgresql: + enabled: false + +# Deploy Redis for vector store +vectorStore: + enabled: true + type: redis + redis: + enabled: true + auth: + enabled: true + # REQUIRED: Replace with a strong, randomly generated password + # Example: Use `openssl rand -base64 32` to generate a secure password + # Or set via Helm: --set vectorStore.redis.auth.password="$(openssl rand -base64 32)" + # Or use a Kubernetes secret: --set vectorStore.redis.auth.existingSecret=redis-secret + password: "REPLACE_ME_WITH_STRONG_PASSWORD" + master: + persistence: + enabled: true + size: 8Gi + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + +# Bifrost configuration +bifrost: + client: + enableLogging: true + providers: {} + # Add your provider keys here + + # Enable semantic cache plugin to use Redis vector store + plugins: + semanticCache: + enabled: true + # OPTION 1 (Recommended): Reference to external Kubernetes Secret for OpenAI API key + # Create the secret with: kubectl create secret generic bifrost-semantic-cache --from-literal=openai-key=sk-YOUR_OPENAI_KEY + secretRef: + name: "bifrost-semantic-cache" + key: "openai-key" + # OPTION 2 (Not recommended): Or uncomment to provide keys directly (not secure) + # Remove secretRef above and uncomment the keys below: + config: + provider: "openai" + # keys: + # - "REPLACE_WITH_OPENAI_API_KEY" # Not recommended: use secretRef instead + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.8 + ttl: "5m" + diff --git a/helm-charts/bifrost/values-examples/sqlite-weaviate.yaml b/helm-charts/bifrost/values-examples/sqlite-weaviate.yaml new file mode 100644 index 000000000..a4bbbfdad --- /dev/null +++ b/helm-charts/bifrost/values-examples/sqlite-weaviate.yaml @@ -0,0 +1,60 @@ +# Configuration: SQLite for config/logs + Weaviate for vector store +# Usage: helm install bifrost ./bifrost -f values-examples/sqlite-weaviate.yaml + +# Storage configuration +storage: + mode: sqlite + persistence: + enabled: true + size: 10Gi + configStore: + enabled: true + logsStore: + enabled: true + +# No PostgreSQL +postgresql: + enabled: false + +# Deploy Weaviate for vector store +vectorStore: + enabled: true + type: weaviate + weaviate: + enabled: true + replicas: 1 + persistence: + enabled: true + size: 10Gi + resources: + limits: + cpu: 1000m + memory: 2Gi + requests: + cpu: 500m + memory: 1Gi + +# Bifrost configuration +bifrost: + client: + enableLogging: true + providers: {} + # Add your provider keys here + + # Enable semantic cache plugin to use vector store + plugins: + semanticCache: + enabled: true + # Reference to external Kubernetes Secret for OpenAI API key + # Create the secret with: kubectl create secret generic bifrost-semantic-cache --from-literal=openai-key=sk-YOUR_OPENAI_KEY + secretRef: + name: "bifrost-semantic-cache" + key: "openai-key" + config: + provider: "openai" + # keys are injected from the secret via environment variable + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.8 + ttl: "5m" + diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml new file mode 100644 index 000000000..a27401d00 --- /dev/null +++ b/helm-charts/bifrost/values.yaml @@ -0,0 +1,357 @@ +# Default values for Bifrost +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# Bifrost application configuration +replicaCount: 1 + +image: + repository: ghcr.io/maxim-ai/bifrost + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 1000 + runAsUser: 1000 + runAsNonRoot: true + +securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + +service: + type: ClusterIP + port: 8080 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: bifrost.local + paths: + - path: / + pathType: Prefix + tls: [] + +resources: + limits: + cpu: 2000m + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi + +livenessProbe: + httpGet: + path: /metrics + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /metrics + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +# Bifrost specific configuration +# You can find entire schema at https://getbifrost.ai/schema +bifrost: + # Application settings + appDir: /app/data + port: 8080 + host: 0.0.0.0 + logLevel: info + logStyle: json + + # Encryption key for sensitive data + # Can be set as a secret or environment variable + encryptionKey: "" + + # Client configuration + client: + dropExcessRequests: false + initialPoolSize: 300 + allowedOrigins: + - "*" + enableLogging: true + enableGovernance: false + enforceGovernanceHeader: false + allowDirectKeys: true + maxRequestBodySizeMb: 100 + enableLitellmFallbacks: false + prometheusLabels: [] + + # Provider configurations (add your provider keys here) + providers: {} + # openai: + # keys: + # - value: "sk-..." + # weight: 1 + # anthropic: + # keys: + # - value: "sk-ant-..." + # weight: 1 + + # MCP (Model Context Protocol) configuration + mcp: + enabled: false + clientConfigs: [] + # - name: "example-mcp" + # connectionType: "stdio" + # stdioConfig: + # command: "/path/to/mcp/server" + # args: [] + # envs: [] + + # Plugins configuration + plugins: + telemetry: + enabled: false + config: {} + + logging: + enabled: false + config: {} + + governance: + enabled: false + config: + isVkMandatory: false + + maxim: + enabled: false + config: + apiKey: "" + logRepoId: "" + + semanticCache: + enabled: false + config: + provider: "openai" + keys: [] + embeddingModel: "text-embedding-3-small" + dimension: 1536 + threshold: 0.8 + ttl: "5m" + conversationHistoryThreshold: 3 + cacheByModel: true + cacheByProvider: true + excludeSystemPrompt: false + + otel: + enabled: false + config: + collectorUrl: "" + traceType: "otel" + protocol: "grpc" + +# Storage configuration +storage: + # Storage mode: sqlite or postgres + # This determines what config_store and logs_store use + mode: sqlite # Options: sqlite, postgres + + # Persistent volume for SQLite databases (when mode is sqlite) + persistence: + enabled: true + # storageClass: "-" # Use default storage class + accessMode: ReadWriteOnce + size: 10Gi + # existingClaim: "" # Use an existing PVC + + # Configuration store settings + configStore: + enabled: true + # type is derived from storage.mode, but can be overridden + # type: sqlite # Options: sqlite, postgres + + # Logs store settings + logsStore: + enabled: true + # type is derived from storage.mode, but can be overridden + # type: sqlite # Options: sqlite, postgres + +# PostgreSQL configuration (when storage.mode is postgres) +postgresql: + # Deploy PostgreSQL as part of this chart + enabled: false + + # Use external PostgreSQL instance + external: + enabled: false + host: "" + port: 5432 + user: bifrost + password: "" + database: bifrost + sslMode: disable + # existingSecret: "" # Use existing secret for password + # passwordKey: "password" # Key in the secret + + # PostgreSQL subchart configuration (when postgresql.enabled is true) + auth: + username: bifrost + password: bifrost_password + database: bifrost + + primary: + persistence: + enabled: true + size: 8Gi + + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi + + metrics: + enabled: false + +# Vector store configuration +vectorStore: + # Enable vector store for semantic caching + enabled: false + type: none # Options: none, weaviate, redis + + # Weaviate configuration + weaviate: + # Deploy Weaviate as part of this chart + enabled: false + + # Use external Weaviate instance + external: + enabled: false + scheme: http + host: "" + apiKey: "" + grpcHost: "" + grpcSecured: false + + # Weaviate subchart configuration (when weaviate.enabled is true) + replicas: 1 + + image: + repository: semitechnologies/weaviate + tag: "1.24.1" + + persistence: + enabled: true + size: 10Gi + + resources: + limits: + cpu: 1000m + memory: 2Gi + requests: + cpu: 500m + memory: 1Gi + + env: + QUERY_DEFAULTS_LIMIT: "25" + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true" + PERSISTENCE_DATA_PATH: "/var/lib/weaviate" + DEFAULT_VECTORIZER_MODULE: "none" + ENABLE_MODULES: "" + CLUSTER_HOSTNAME: "node1" + + # Redis configuration + redis: + # Deploy Redis as part of this chart + enabled: false + + # Use external Redis instance + external: + enabled: false + host: "" + port: 6379 + password: "" + database: 0 + # existingSecret: "" + # passwordKey: "password" + + # Redis subchart configuration (when redis.enabled is true) + auth: + enabled: true + password: "redis_password" + + master: + persistence: + enabled: true + size: 8Gi + + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + + metrics: + enabled: false + +# Environment variables +env: [] + # - name: CUSTOM_ENV_VAR + # value: "value" + +# Environment variables from secrets/configmaps +envFrom: [] + # - secretRef: + # name: my-secret + # - configMapRef: + # name: my-configmap + diff --git a/npx/bin.js b/npx/bin.js new file mode 100644 index 000000000..1e2ee9456 --- /dev/null +++ b/npx/bin.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +import { execFileSync } from "child_process"; +import { chmodSync, createWriteStream, existsSync, fsyncSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { Readable } from "stream"; + +const BASE_URL = "https://downloads.getmaxim.ai"; + +// Parse transport version from command line arguments +function parseTransportVersion() { + const args = process.argv.slice(2); + let transportVersion = "latest"; // Default to latest + + // Find --transport-version argument + const versionArgIndex = args.findIndex(arg => arg.startsWith("--transport-version")); + + if (versionArgIndex !== -1) { + const versionArg = args[versionArgIndex]; + + if (versionArg.includes("=")) { + // Format: --transport-version=v1.2.3 + transportVersion = versionArg.split("=")[1]; + } else if (versionArgIndex + 1 < args.length) { + // Format: --transport-version v1.2.3 + transportVersion = args[versionArgIndex + 1]; + } + + // Remove the transport-version arguments from args array so they don't get passed to the binary + if (versionArg.includes("=")) { + args.splice(versionArgIndex, 1); + } else { + args.splice(versionArgIndex, 2); + } + } + + return { version: validateTransportVersion(transportVersion), remainingArgs: args }; +} + +// Validate transport version format +function validateTransportVersion(version) { + if (version === "latest") { + return version; + } + + // Check if version matches v{x.x.x} format + const versionRegex = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + if (versionRegex.test(version)) { + return version; + } + + console.error(`Invalid transport version format: ${version}`); + console.error(`Transport version must be either "latest", "v1.2.3", or "v1.2.3-prerelease1"`); + process.exit(1); +} + +const { version: VERSION, remainingArgs } = parseTransportVersion(); + +async function getPlatformArchAndBinary() { + const platform = process.platform; + const arch = process.arch; + + let platformDir; + let archDir; + let binaryName; + + if (platform === "darwin") { + platformDir = "darwin"; + if (arch === "arm64") archDir = "arm64"; + else archDir = "amd64"; + binaryName = "bifrost-http"; + } else if (platform === "linux") { + platformDir = "linux"; + if (arch === "x64") archDir = "amd64"; + else if (arch === "ia32") archDir = "386"; + else archDir = arch; // fallback + binaryName = "bifrost-http"; + } else if (platform === "win32") { + platformDir = "windows"; + if (arch === "x64") archDir = "amd64"; + else if (arch === "ia32") archDir = "386"; + else archDir = arch; // fallback + binaryName = "bifrost-http.exe"; + } else { + console.error(`Unsupported platform/arch: ${platform}/${arch}`); + process.exit(1); + } + + return { platformDir, archDir, binaryName }; +} + +async function downloadBinary(url, dest) { + // console.log(`πŸ”„ Downloading binary from ${url}...`); + + const res = await fetch(url); + + if (!res.ok) { + console.error(`❌ Download failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const contentLength = res.headers.get('content-length'); + const totalSize = contentLength ? parseInt(contentLength, 10) : null; + let downloadedSize = 0; + + const fileStream = createWriteStream(dest, { flags: "w" }); + await new Promise((resolve, reject) => { + try { + // Convert the fetch response body to a Node.js readable stream + const nodeStream = Readable.fromWeb(res.body); + + // Add progress tracking + nodeStream.on('data', (chunk) => { + downloadedSize += chunk.length; + if (totalSize) { + const progress = ((downloadedSize / totalSize) * 100).toFixed(1); + process.stdout.write(`\r⏱️ Downloading Binary: ${progress}% (${formatBytes(downloadedSize)}/${formatBytes(totalSize)})`); + } else { + process.stdout.write(`\r⏱️ Downloaded: ${formatBytes(downloadedSize)}`); + } + }); + + nodeStream.pipe(fileStream); + fileStream.on("finish", () => { + process.stdout.write('\n'); + + // Ensure file is fully written to disk + try { + fsyncSync(fileStream.fd); + } catch (syncError) { + // fsync might fail on some systems, ignore + } + + resolve(); + }); + fileStream.on("error", reject); + nodeStream.on("error", reject); + } catch (error) { + reject(error); + } + }); + + chmodSync(dest, 0o755); +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +(async () => { + const platformInfo = await getPlatformArchAndBinary(); + const { platformDir, archDir, binaryName } = platformInfo; + + // For future use when we want to add multiple fallback binaries + const downloadUrls = []; + + downloadUrls.push(`${BASE_URL}/bifrost/${VERSION}/${platformDir}/${archDir}/${binaryName}`); + + let lastError = null; + let binaryWorking = false; + + for (let i = 0; i < downloadUrls.length; i++) { + const downloadUrl = downloadUrls[i]; + // Use unique file path for each attempt to avoid ETXTBSY + const binaryPath = join(tmpdir(), `${binaryName}-${i}`); + + try { + await downloadBinary(downloadUrl, binaryPath); + + // Verify the binary is executable before trying to run it + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at: ${binaryPath}`); + } + + // Add a small delay to ensure file is fully written and not busy + await new Promise(resolve => setTimeout(resolve, 100)); + + // Test if the binary can execute + try { + execFileSync(binaryPath, remainingArgs, { stdio: "inherit" }); + binaryWorking = true; + break; + } catch (execError) { + // If execution fails (ENOENT, ETXTBSY, etc.), try next binary + lastError = execError; + continue; + } + } catch (downloadError) { + lastError = downloadError; + // Continue to next URL silently + } + } + + if (!binaryWorking) { + console.error(`❌ Failed to start Bifrost. Error:`, lastError.message); + + // Show critical error details for troubleshooting + if (lastError.code) { + console.error(`Error code: ${lastError.code}`); + } + if (lastError.errno) { + console.error(`System error: ${lastError.errno}`); + } + if (lastError.signal) { + console.error(`Signal: ${lastError.signal}`); + } + + // For specific Linux issues, show diagnostic info + if (process.platform === 'linux' && (lastError.code === 'ENOENT' || lastError.code === 'ETXTBSY')) { + console.error(`\nπŸ’‘ This appears to be a Linux compatibility issue.`); + console.error(` The binary may be incompatible with your Linux distribution.`); + } + + process.exit(lastError.status || 1); + } +})(); diff --git a/npx/package-lock.json b/npx/package-lock.json new file mode 100644 index 000000000..0dfb91807 --- /dev/null +++ b/npx/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "@maximhq/bifrost", + "version": "1.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@maximhq/bifrost", + "version": "1.0.4", + "license": "Apache-2.0", + "bin": { + "bifrost": "bin.js" + }, + "engines": { + "node": ">=18.0.0" + } + } + } +} diff --git a/npx/package.json b/npx/package.json new file mode 100644 index 000000000..8c8a7c558 --- /dev/null +++ b/npx/package.json @@ -0,0 +1,24 @@ +{ + "name": "@maximhq/bifrost", + "version": "1.0.5", + "description": "High-performance AI gateway CLI - connect to 12+ providers through a single API", + "keywords": ["ai", "gateway", "openai", "anthropic", "cli", "bifrost"], + "homepage": "https://github.com/maximhq/bifrost", + "repository": { + "type": "git", + "url": "https://github.com/maximhq/bifrost.git" + }, + "license": "Apache-2.0", + "author": "Maxim HQ", + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + }, + "bin": { + "bifrost": "bin.js" + }, + "type": "module", + "dependencies": {} +} \ No newline at end of file diff --git a/plugins/go.mod b/plugins/go.mod deleted file mode 100644 index 82e50b301..000000000 --- a/plugins/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -module github.com/maximhq/bifrost/plugins - -go 1.24.1 - -require ( - github.com/maximhq/bifrost/core v1.0.1 - github.com/maximhq/maxim-go v0.1.1 -) diff --git a/plugins/go.sum b/plugins/go.sum deleted file mode 100644 index b8cb7b66e..000000000 --- a/plugins/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -github.com/maximhq/bifrost/core v1.0.1 h1:B0u6o13faUexA+V0EUU0bsLW2dHg9+R2TZKQzPzCxlY= -github.com/maximhq/bifrost/core v1.0.1/go.mod h1:4+Ept2EnX1EEjH/mBuSwK7eE56znI/BCoCbIrx25/x8= -github.com/maximhq/maxim-go v0.1.1 h1:69uUQjjDPmUGcKg/M4/3AO0fbD+70Agt66pH/UCsI5M= -github.com/maximhq/maxim-go v0.1.1/go.mod h1:0+UTWM7UZwNNE5VnljLtr/vpRGtYP8r/2q9WDwlLWFw= diff --git a/plugins/governance/changelog.md b/plugins/governance/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/governance/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/governance/go.mod b/plugins/governance/go.mod new file mode 100644 index 000000000..793d4a864 --- /dev/null +++ b/plugins/governance/go.mod @@ -0,0 +1,110 @@ +module github.com/maximhq/bifrost/plugins/governance + +go 1.24.1 + +toolchain go1.24.3 + +require gorm.io/gorm v1.31.1 + +require ( + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect +) diff --git a/plugins/governance/go.sum b/plugins/governance/go.sum new file mode 100644 index 000000000..a01d845b4 --- /dev/null +++ b/plugins/governance/go.sum @@ -0,0 +1,255 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/plugins/governance/main.go b/plugins/governance/main.go new file mode 100644 index 000000000..6e2b8c5ae --- /dev/null +++ b/plugins/governance/main.go @@ -0,0 +1,535 @@ +// Package governance provides comprehensive governance plugin for Bifrost +package governance + +import ( + "context" + "fmt" + "math/rand/v2" + "slices" + "sort" + "strings" + "sync" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/modelcatalog" +) + +// PluginName is the name of the governance plugin +const PluginName = "governance" + +const ( + governanceRejectedContextKey schemas.BifrostContextKey = "bf-governance-rejected" + governanceIsCacheReadContextKey schemas.BifrostContextKey = "bf-governance-is-cache-read" + governanceIsBatchContextKey schemas.BifrostContextKey = "bf-governance-is-batch" + + VirtualKeyPrefix = "sk-bf-" +) + +// Config is the configuration for the governance plugin +type Config struct { + IsVkMandatory *bool `json:"is_vk_mandatory"` +} + +type InMemoryStore interface { + GetConfiguredProviders() map[schemas.ModelProvider]configstore.ProviderConfig +} + +// GovernancePlugin implements the main governance plugin with hierarchical budget system +type GovernancePlugin struct { + ctx context.Context + cancelFunc context.CancelFunc + wg sync.WaitGroup // Track active goroutines + + // Core components with clear separation of concerns + store *GovernanceStore // Pure data access layer + resolver *BudgetResolver // Pure decision engine for hierarchical governance + tracker *UsageTracker // Business logic owner (updates, resets, persistence) + + // Dependencies + configStore configstore.ConfigStore + modelCatalog *modelcatalog.ModelCatalog + logger schemas.Logger + + // Transport dependencies + inMemoryStore InMemoryStore + + isVkMandatory *bool +} + +// Init initializes and returns a governance plugin instance. +// +// It wires the core components (store, resolver, tracker), performs a best-effort +// startup reset of expired limits when a persistent `configstore.ConfigStore` is +// provided, and establishes a cancellable plugin context used by background work. +// +// Behavior and defaults: +// - Enables all governance features with optimized defaults. +// - If `store` is nil, the plugin runs in-memory only (no persistence). +// - If `modelCatalog` is nil, cost calculation is skipped. +// - `config.IsVkMandatory` controls whether `x-bf-vk` is required in PreHook. +// - `inMemoryStore` is used by TransportInterceptor to validate configured providers +// and build provider-prefixed models; it may be nil. When nil, transport-level +// provider validation/routing is skipped and existing model strings are left +// unchanged. This is safe and recommended when using the plugin directly from +// the Go SDK without the HTTP transport. +// +// Parameters: +// - ctx: base context for the plugin; a child context with cancel is created. +// - config: plugin flags; may be nil. +// - logger: logger used by all subcomponents. +// - store: configuration store used for persistence; may be nil. +// - governanceConfig: initial/seed governance configuration for the store. +// - modelCatalog: optional model catalog to compute request cost. +// - inMemoryStore: provider registry used for routing/validation in transports. +// +// Returns: +// - *GovernancePlugin on success. +// - error if the governance store fails to initialize. +// +// Side effects: +// - Logs warnings when optional dependencies are missing. +// - May perform startup resets via the usage tracker when `store` is non-nil. +func Init( + ctx context.Context, + config *Config, + logger schemas.Logger, + store configstore.ConfigStore, + governanceConfig *configstore.GovernanceConfig, + modelCatalog *modelcatalog.ModelCatalog, + inMemoryStore InMemoryStore, +) (*GovernancePlugin, error) { + if store == nil { + logger.Warn("governance plugin requires config store to persist data, running in memory only mode") + } + if modelCatalog == nil { + logger.Warn("governance plugin requires model catalog to calculate cost, all cost calculations will be skipped.") + } + + // Handle nil config - use safe default for IsVkMandatory + var isVkMandatory *bool + if config != nil { + isVkMandatory = config.IsVkMandatory + } + + governanceStore, err := NewGovernanceStore(ctx, logger, store, governanceConfig) + if err != nil { + return nil, fmt.Errorf("failed to initialize governance store: %w", err) + } + // Initialize components in dependency order with fixed, optimal settings + // Resolver (pure decision engine for hierarchical governance, depends only on store) + resolver := NewBudgetResolver(governanceStore, logger) + + // 3. Tracker (business logic owner, depends on store and resolver) + tracker := NewUsageTracker(ctx, governanceStore, resolver, store, logger) + + // 4. Perform startup reset check for any expired limits from downtime + if store != nil { + if err := tracker.PerformStartupResets(ctx); err != nil { + logger.Warn("startup reset failed: %v", err) + // Continue initialization even if startup reset fails (non-critical) + } + } + ctx, cancelFunc := context.WithCancel(ctx) + plugin := &GovernancePlugin{ + ctx: ctx, + cancelFunc: cancelFunc, + store: governanceStore, + resolver: resolver, + tracker: tracker, + configStore: store, + modelCatalog: modelCatalog, + logger: logger, + isVkMandatory: isVkMandatory, + inMemoryStore: inMemoryStore, + } + return plugin, nil +} + +// GetName returns the name of the plugin +func (p *GovernancePlugin) GetName() string { + return PluginName +} + +// TransportInterceptor intercepts requests before they are processed (governance decision point) +func (p *GovernancePlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + var virtualKeyValue string + var err error + + for header, value := range headers { + if strings.ToLower(string(header)) == string(schemas.BifrostContextKeyVirtualKey) { + virtualKeyValue = string(value) + break + } + } + if virtualKeyValue == "" { + return headers, body, nil + } + + virtualKey, ok := p.store.GetVirtualKey(virtualKeyValue) + if !ok || virtualKey == nil || !virtualKey.IsActive { + return headers, body, nil + } + + body, err = p.loadBalanceProvider(body, virtualKey) + if err != nil { + return headers, body, err + } + + headers, err = p.addMCPIncludeTools(headers, virtualKey) + if err != nil { + return headers, body, err + } + + return headers, body, nil +} + +func (p *GovernancePlugin) loadBalanceProvider(body map[string]any, virtualKey *configstoreTables.TableVirtualKey) (map[string]any, error) { + // Check if the request has a model field + modelValue, hasModel := body["model"] + if !hasModel { + return body, nil + } + modelStr, ok := modelValue.(string) + if !ok || modelStr == "" { + return body, nil + } + + // Check if model already has provider prefix (contains "/") + if strings.Contains(modelStr, "/") { + provider, _ := schemas.ParseModelString(modelStr, "") + // Checking valid provider when store is available; if store is nil, + // assume the prefixed model should be left unchanged. + if p.inMemoryStore != nil { + if _, ok := p.inMemoryStore.GetConfiguredProviders()[provider]; ok { + return body, nil + } + } else { + return body, nil + } + } + + // Get provider configs for this virtual key + providerConfigs := virtualKey.ProviderConfigs + if len(providerConfigs) == 0 { + // No provider configs, continue without modification + return body, nil + } + allowedProviderConfigs := make([]configstoreTables.TableVirtualKeyProviderConfig, 0) + for _, config := range providerConfigs { + if len(config.AllowedModels) == 0 || slices.Contains(config.AllowedModels, modelStr) { + // Check if the provider's budget or rate limits are violated using resolver helper methods + if p.resolver.isProviderBudgetViolated(config) || p.resolver.isProviderRateLimitViolated(config) { + // Provider config violated budget or rate limits, skip this provider + continue + } + + allowedProviderConfigs = append(allowedProviderConfigs, config) + } + } + if len(allowedProviderConfigs) == 0 { + // No allowed provider configs, continue without modification + return body, nil + } + // Weighted random selection from allowed providers for the main model + totalWeight := 0.0 + for _, config := range allowedProviderConfigs { + totalWeight += config.Weight + } + // Generate random number between 0 and totalWeight + randomValue := rand.Float64() * totalWeight + // Select provider based on weighted random selection + var selectedProvider schemas.ModelProvider + currentWeight := 0.0 + for _, config := range allowedProviderConfigs { + currentWeight += config.Weight + if randomValue <= currentWeight { + selectedProvider = schemas.ModelProvider(config.Provider) + break + } + } + // Fallback: if no provider was selected (shouldn't happen but guard against FP issues) + if selectedProvider == "" && len(allowedProviderConfigs) > 0 { + selectedProvider = schemas.ModelProvider(allowedProviderConfigs[0].Provider) + } + // Update the model field in the request body + body["model"] = string(selectedProvider) + "/" + modelStr + + // Check if fallbacks field is already present + _, hasFallbacks := body["fallbacks"] + if !hasFallbacks && len(allowedProviderConfigs) > 1 { + // Sort allowed provider configs by weight (descending) + sort.Slice(allowedProviderConfigs, func(i, j int) bool { + return allowedProviderConfigs[i].Weight > allowedProviderConfigs[j].Weight + }) + + // Filter out the selected provider and create fallbacks array + fallbacks := make([]string, 0, len(allowedProviderConfigs)-1) + for _, config := range allowedProviderConfigs { + if config.Provider != string(selectedProvider) { + fallbacks = append(fallbacks, string(schemas.ModelProvider(config.Provider))+"/"+modelStr) + } + } + + // Add fallbacks to request body + body["fallbacks"] = fallbacks + } + + return body, nil +} + +func (p *GovernancePlugin) addMCPIncludeTools(headers map[string]string, virtualKey *configstoreTables.TableVirtualKey) (map[string]string, error) { + if len(virtualKey.MCPConfigs) > 0 { + if headers == nil { + headers = make(map[string]string) + } + executeOnlyTools := make([]string, 0) + for _, vkMcpConfig := range virtualKey.MCPConfigs { + if len(vkMcpConfig.ToolsToExecute) == 0 { + // No tools specified in virtual key config - skip this client entirely + continue + } + + // Handle wildcard in virtual key config - allow all tools from this client + if slices.Contains(vkMcpConfig.ToolsToExecute, "*") { + // Virtual key uses wildcard - use client-specific wildcard + executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s/*", vkMcpConfig.MCPClient.Name)) + continue + } + + for _, tool := range vkMcpConfig.ToolsToExecute { + if tool != "" { + // Add the tool - client config filtering will be handled by mcp.go + executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s/%s", vkMcpConfig.MCPClient.Name, tool)) + } + } + } + + // Set even when empty to exclude tools when no tools are present in the virtual key config + headers["x-bf-mcp-include-tools"] = strings.Join(executeOnlyTools, ",") + } + + return headers, nil +} + +// PreHook intercepts requests before they are processed (governance decision point) +func (p *GovernancePlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + // Extract governance headers and virtual key using utility functions + headers := extractHeadersFromContext(*ctx) + virtualKeyValue := getStringFromContext(*ctx, schemas.BifrostContextKeyVirtualKey) + requestID := getStringFromContext(*ctx, schemas.BifrostContextKeyRequestID) + if virtualKeyValue == "" { + if p.isVkMandatory != nil && *p.isVkMandatory { + return req, &schemas.PluginShortCircuit{ + Error: &schemas.BifrostError{ + Type: bifrost.Ptr("virtual_key_required"), + StatusCode: bifrost.Ptr(400), + Error: &schemas.ErrorField{ + Message: "x-bf-vk header is missing", + }, + }, + }, nil + } else { + return req, nil, nil + } + } + + provider, model, _ := req.GetRequestFields() + + // Create request context for evaluation + evaluationRequest := &EvaluationRequest{ + VirtualKey: virtualKeyValue, + Provider: provider, + Model: model, + Headers: headers, + RequestID: requestID, + } + + // Use resolver to make governance decision (pure decision engine) + result := p.resolver.EvaluateRequest(ctx, evaluationRequest) + + if result.Decision != DecisionAllow { + if ctx != nil { + if _, ok := (*ctx).Value(governanceRejectedContextKey).(bool); !ok { + *ctx = context.WithValue(*ctx, governanceRejectedContextKey, true) + } + } + } + + // Handle decision + switch result.Decision { + case DecisionAllow: + return req, nil, nil + + case DecisionVirtualKeyNotFound, DecisionVirtualKeyBlocked, DecisionModelBlocked, DecisionProviderBlocked: + return req, &schemas.PluginShortCircuit{ + Error: &schemas.BifrostError{ + Type: bifrost.Ptr(string(result.Decision)), + StatusCode: bifrost.Ptr(403), + Error: &schemas.ErrorField{ + Message: result.Reason, + }, + }, + }, nil + + case DecisionRateLimited, DecisionTokenLimited, DecisionRequestLimited: + return req, &schemas.PluginShortCircuit{ + Error: &schemas.BifrostError{ + Type: bifrost.Ptr(string(result.Decision)), + StatusCode: bifrost.Ptr(429), + Error: &schemas.ErrorField{ + Message: result.Reason, + }, + }, + }, nil + + case DecisionBudgetExceeded: + return req, &schemas.PluginShortCircuit{ + Error: &schemas.BifrostError{ + Type: bifrost.Ptr(string(result.Decision)), + StatusCode: bifrost.Ptr(402), + Error: &schemas.ErrorField{ + Message: result.Reason, + }, + }, + }, nil + + default: + // Fallback to deny for unknown decisions + return req, &schemas.PluginShortCircuit{ + Error: &schemas.BifrostError{ + Type: bifrost.Ptr(string(result.Decision)), + Error: &schemas.ErrorField{ + Message: "Governance decision error", + }, + }, + }, nil + } +} + +// PostHook processes the response and updates usage tracking (business logic execution) +func (p *GovernancePlugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + if _, ok := (*ctx).Value(governanceRejectedContextKey).(bool); ok { + return result, err, nil + } + + // Extract governance information + headers := extractHeadersFromContext(*ctx) + virtualKey := getStringFromContext(*ctx, schemas.BifrostContextKeyVirtualKey) + requestID := getStringFromContext(*ctx, schemas.BifrostContextKeyRequestID) + + // Skip if no virtual key + if virtualKey == "" { + return result, err, nil + } + + // Extract request type, provider, and model + requestType, provider, model := bifrost.GetResponseFields(result, err) + + // Extract cache and batch flags from context + isCacheRead := false + isBatch := false + if val := (*ctx).Value(governanceIsCacheReadContextKey); val != nil { + if b, ok := val.(bool); ok { + isCacheRead = b + } + } + if val := (*ctx).Value(governanceIsBatchContextKey); val != nil { + if b, ok := val.(bool); ok { + isBatch = b + } + } + + p.wg.Add(1) + go func() { + defer p.wg.Done() + p.postHookWorker(result, provider, model, requestType, virtualKey, requestID, headers, isCacheRead, isBatch, bifrost.IsFinalChunk(ctx)) + }() + + return result, err, nil +} + +// Cleanup shuts down all components gracefully +func (p *GovernancePlugin) Cleanup() error { + p.wg.Wait() // Wait for all background workers to complete + if p.cancelFunc != nil { + p.cancelFunc() + } + if err := p.tracker.Cleanup(); err != nil { + return err + } + + return nil +} + +func (p *GovernancePlugin) postHookWorker(result *schemas.BifrostResponse, provider schemas.ModelProvider, model string, requestType schemas.RequestType, virtualKey, requestID string, headers map[string]string, isCacheRead, isBatch bool, isFinalChunk bool) { + // Determine if request was successful + success := (result != nil) + + // Extract team/customer info for audit trail + var teamID, customerID *string + if teamIDValue := headers["x-bf-team"]; teamIDValue != "" { + teamID = &teamIDValue + } + if customerIDValue := headers["x-bf-customer"]; customerIDValue != "" { + customerID = &customerIDValue + } + + // Streaming detection + isStreaming := bifrost.IsStreamRequestType(requestType) + + if !isStreaming || (isStreaming && isFinalChunk) { + var cost float64 + if p.modelCatalog != nil && result != nil { + cost = p.modelCatalog.CalculateCostWithCacheDebug(result) + } + tokensUsed := 0 + if result != nil { + switch { + case result.TextCompletionResponse != nil && result.TextCompletionResponse.Usage != nil: + tokensUsed = result.TextCompletionResponse.Usage.TotalTokens + case result.ChatResponse != nil && result.ChatResponse.Usage != nil: + tokensUsed = result.ChatResponse.Usage.TotalTokens + case result.ResponsesResponse != nil && result.ResponsesResponse.Usage != nil: + tokensUsed = result.ResponsesResponse.Usage.TotalTokens + case result.ResponsesStreamResponse != nil && result.ResponsesStreamResponse.Response != nil && result.ResponsesStreamResponse.Response.Usage != nil: + tokensUsed = result.ResponsesStreamResponse.Response.Usage.TotalTokens + case result.EmbeddingResponse != nil && result.EmbeddingResponse.Usage != nil: + tokensUsed = result.EmbeddingResponse.Usage.TotalTokens + case result.SpeechResponse != nil && result.SpeechResponse.Usage != nil: + tokensUsed = result.SpeechResponse.Usage.TotalTokens + case result.SpeechStreamResponse != nil && result.SpeechStreamResponse.Usage != nil: + tokensUsed = result.SpeechStreamResponse.Usage.TotalTokens + case result.TranscriptionResponse != nil && result.TranscriptionResponse.Usage != nil && result.TranscriptionResponse.Usage.TotalTokens != nil: + tokensUsed = *result.TranscriptionResponse.Usage.TotalTokens + case result.TranscriptionStreamResponse != nil && result.TranscriptionStreamResponse.Usage != nil && result.TranscriptionStreamResponse.Usage.TotalTokens != nil: + tokensUsed = *result.TranscriptionStreamResponse.Usage.TotalTokens + } + } + // Create usage update for tracker (business logic) + usageUpdate := &UsageUpdate{ + VirtualKey: virtualKey, + Provider: provider, + Model: model, + Success: success, + TokensUsed: int64(tokensUsed), + Cost: cost, + RequestID: requestID, + TeamID: teamID, + CustomerID: customerID, + IsStreaming: isStreaming, + IsFinalChunk: isFinalChunk, + HasUsageData: tokensUsed > 0, + } + + // Queue usage update asynchronously using tracker + p.tracker.UpdateUsage(p.ctx, usageUpdate) + } +} + +// GetGovernanceStore returns the governance store +func (p *GovernancePlugin) GetGovernanceStore() *GovernanceStore { + return p.store +} diff --git a/plugins/governance/resolver.go b/plugins/governance/resolver.go new file mode 100644 index 000000000..41db2398a --- /dev/null +++ b/plugins/governance/resolver.go @@ -0,0 +1,347 @@ +// Package governance provides the budget evaluation and decision engine +package governance + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" +) + +// Decision represents the result of governance evaluation +type Decision string + +const ( + DecisionAllow Decision = "allow" + DecisionVirtualKeyNotFound Decision = "virtual_key_not_found" + DecisionVirtualKeyBlocked Decision = "virtual_key_blocked" + DecisionRateLimited Decision = "rate_limited" + DecisionBudgetExceeded Decision = "budget_exceeded" + DecisionTokenLimited Decision = "token_limited" + DecisionRequestLimited Decision = "request_limited" + DecisionModelBlocked Decision = "model_blocked" + DecisionProviderBlocked Decision = "provider_blocked" +) + +// EvaluationRequest contains the context for evaluating a request +type EvaluationRequest struct { + VirtualKey string `json:"virtual_key"` // Virtual key value + Provider schemas.ModelProvider `json:"provider"` + Model string `json:"model"` + Headers map[string]string `json:"headers"` + RequestID string `json:"request_id"` +} + +// EvaluationResult contains the complete result of governance evaluation +type EvaluationResult struct { + Decision Decision `json:"decision"` + Reason string `json:"reason"` + VirtualKey *configstoreTables.TableVirtualKey `json:"virtual_key,omitempty"` + RateLimitInfo *configstoreTables.TableRateLimit `json:"rate_limit_info,omitempty"` + BudgetInfo []*configstoreTables.TableBudget `json:"budget_info,omitempty"` // All budgets in hierarchy + UsageInfo *UsageInfo `json:"usage_info,omitempty"` +} + +// UsageInfo represents current usage levels for rate limits and budgets +type UsageInfo struct { + // Rate limit usage + TokensUsedMinute int64 `json:"tokens_used_minute"` + TokensUsedHour int64 `json:"tokens_used_hour"` + TokensUsedDay int64 `json:"tokens_used_day"` + RequestsUsedMinute int64 `json:"requests_used_minute"` + RequestsUsedHour int64 `json:"requests_used_hour"` + RequestsUsedDay int64 `json:"requests_used_day"` + + // Budget usage + VKBudgetUsage int64 `json:"vk_budget_usage"` + TeamBudgetUsage int64 `json:"team_budget_usage"` + CustomerBudgetUsage int64 `json:"customer_budget_usage"` +} + +// BudgetResolver provides decision logic for the new hierarchical governance system +type BudgetResolver struct { + store *GovernanceStore + logger schemas.Logger +} + +// NewBudgetResolver creates a new budget-based governance resolver +func NewBudgetResolver(store *GovernanceStore, logger schemas.Logger) *BudgetResolver { + return &BudgetResolver{ + store: store, + logger: logger, + } +} + +// EvaluateRequest evaluates a request against the new hierarchical governance system +func (r *BudgetResolver) EvaluateRequest(ctx *context.Context, evaluationRequest *EvaluationRequest) *EvaluationResult { + // 1. Validate virtual key exists and is active + vk, exists := r.store.GetVirtualKey(evaluationRequest.VirtualKey) + if !exists { + return &EvaluationResult{ + Decision: DecisionVirtualKeyNotFound, + Reason: "Virtual key not found", + } + } + + // Set virtual key id and name in context + *ctx = context.WithValue(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-id"), vk.ID) + *ctx = context.WithValue(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-name"), vk.Name) + + if !vk.IsActive { + return &EvaluationResult{ + Decision: DecisionVirtualKeyBlocked, + Reason: "Virtual key is inactive", + } + } + + // 2. Check provider filtering + if !r.isProviderAllowed(vk, evaluationRequest.Provider) { + return &EvaluationResult{ + Decision: DecisionProviderBlocked, + Reason: fmt.Sprintf("Provider '%s' is not allowed for this virtual key", evaluationRequest.Provider), + VirtualKey: vk, + } + } + + // 3. Check model filtering + if !r.isModelAllowed(vk, evaluationRequest.Provider, evaluationRequest.Model) { + return &EvaluationResult{ + Decision: DecisionModelBlocked, + Reason: fmt.Sprintf("Model '%s' is not allowed for this virtual key", evaluationRequest.Model), + VirtualKey: vk, + } + } + + // 4. Check rate limits (Provider level first, then VK level) + if rateLimitResult := r.checkRateLimits(vk, string(evaluationRequest.Provider)); rateLimitResult != nil { + return rateLimitResult + } + + // 5. Check budget hierarchy (VK β†’ Team β†’ Customer) + if budgetResult := r.checkBudgetHierarchy(*ctx, vk); budgetResult != nil { + return budgetResult + } + + if vk.Keys != nil { + includeOnlyKeys := make([]string, 0, len(vk.Keys)) + for _, dbKey := range vk.Keys { + includeOnlyKeys = append(includeOnlyKeys, dbKey.KeyID) + } + + if len(includeOnlyKeys) > 0 { + *ctx = context.WithValue(*ctx, schemas.BifrostContextKey("bf-governance-include-only-keys"), includeOnlyKeys) + } + } + + // All checks passed + return &EvaluationResult{ + Decision: DecisionAllow, + Reason: "Request allowed by governance policy", + VirtualKey: vk, + } +} + +// isModelAllowed checks if the requested model is allowed for this VK +func (r *BudgetResolver) isModelAllowed(vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, model string) bool { + // Empty AllowedModels means all models are allowed + if len(vk.ProviderConfigs) == 0 { + return true + } + + for _, pc := range vk.ProviderConfigs { + if pc.Provider == string(provider) { + if len(pc.AllowedModels) == 0 { + return true + } + return slices.Contains(pc.AllowedModels, model) + } + } + + return false +} + +// isProviderAllowed checks if the requested provider is allowed for this VK +func (r *BudgetResolver) isProviderAllowed(vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider) bool { + // Empty AllowedProviders means all providers are allowed + if len(vk.ProviderConfigs) == 0 { + return true + } + + for _, pc := range vk.ProviderConfigs { + if pc.Provider == string(provider) { + return true + } + } + + return false +} + +// checkRateLimits checks provider-level rate limits first, then VK rate limits using flexible approach +func (r *BudgetResolver) checkRateLimits(vk *configstoreTables.TableVirtualKey, provider string) *EvaluationResult { + // First check provider-level rate limits + if providerRateLimitResult := r.checkProviderRateLimits(vk, provider); providerRateLimitResult != nil { + return providerRateLimitResult + } + + // Then check VK-level rate limits + if vk.RateLimit == nil { + return nil // No VK rate limits defined + } + + return r.checkSingleRateLimit(vk.RateLimit, "virtual key", vk) +} + +// checkProviderRateLimits checks rate limits for a specific provider config +func (r *BudgetResolver) checkProviderRateLimits(vk *configstoreTables.TableVirtualKey, provider string) *EvaluationResult { + if vk.ProviderConfigs == nil { + return nil // No provider configs defined + } + + // Find the specific provider config + for _, pc := range vk.ProviderConfigs { + if pc.Provider == provider && pc.RateLimit != nil { + return r.checkSingleRateLimit(pc.RateLimit, fmt.Sprintf("provider '%s'", provider), vk) + } + } + + return nil // No rate limits for this provider +} + +// checkSingleRateLimit checks a single rate limit and returns evaluation result if violated +func (r *BudgetResolver) checkSingleRateLimit(rateLimit *configstoreTables.TableRateLimit, rateLimitName string, vk *configstoreTables.TableVirtualKey) *EvaluationResult { + var violations []string + + // Token limits + if rateLimit.TokenMaxLimit != nil && rateLimit.TokenCurrentUsage >= *rateLimit.TokenMaxLimit { + duration := "unknown" + if rateLimit.TokenResetDuration != nil { + duration = *rateLimit.TokenResetDuration + } + violations = append(violations, fmt.Sprintf("token limit exceeded (%d/%d, resets every %s)", + rateLimit.TokenCurrentUsage, *rateLimit.TokenMaxLimit, duration)) + } + + // Request limits + if rateLimit.RequestMaxLimit != nil && rateLimit.RequestCurrentUsage >= *rateLimit.RequestMaxLimit { + duration := "unknown" + if rateLimit.RequestResetDuration != nil { + duration = *rateLimit.RequestResetDuration + } + violations = append(violations, fmt.Sprintf("request limit exceeded (%d/%d, resets every %s)", + rateLimit.RequestCurrentUsage, *rateLimit.RequestMaxLimit, duration)) + } + + if len(violations) > 0 { + // Determine specific violation type + decision := DecisionRateLimited + if len(violations) == 1 { + if strings.Contains(violations[0], "token") { + decision = DecisionTokenLimited + } else if strings.Contains(violations[0], "request") { + decision = DecisionRequestLimited + } + } + + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("%s rate limits exceeded: %v", rateLimitName, violations), + VirtualKey: vk, + RateLimitInfo: rateLimit, + } + } + + return nil // No rate limit violations +} + +// checkBudgetHierarchy checks the budget hierarchy atomically (VK β†’ Team β†’ Customer) +func (r *BudgetResolver) checkBudgetHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey) *EvaluationResult { + // Use atomic budget checking to prevent race conditions + if err := r.store.CheckBudget(ctx, vk); err != nil { + r.logger.Debug(fmt.Sprintf("Atomic budget check failed for VK %s: %s", vk.ID, err.Error())) + + return &EvaluationResult{ + Decision: DecisionBudgetExceeded, + Reason: fmt.Sprintf("Budget check failed: %s", err.Error()), + VirtualKey: vk, + } + } + + return nil // No budget violations +} + +// Helper methods for provider config validation (used by TransportInterceptor) + +// isProviderBudgetViolated checks if a provider config's budget is violated +func (r *BudgetResolver) isProviderBudgetViolated(config configstoreTables.TableVirtualKeyProviderConfig) bool { + if config.Budget == nil { + return false + } + + // Check if budget needs reset + if config.Budget.ResetDuration != "" { + if duration, err := configstoreTables.ParseDuration(config.Budget.ResetDuration); err == nil { + if time.Since(config.Budget.LastReset).Round(time.Millisecond) >= duration { + // Budget expired but hasn't been reset yet - not violated + return false + } + } + } + + // Check if current usage exceeds budget limit + return config.Budget.CurrentUsage > config.Budget.MaxLimit +} + +// isProviderRateLimitViolated checks if a provider config's rate limit is violated +func (r *BudgetResolver) isProviderRateLimitViolated(config configstoreTables.TableVirtualKeyProviderConfig) bool { + if config.RateLimit == nil { + return false + } + + // Check token limits + if config.RateLimit.TokenMaxLimit != nil && config.RateLimit.TokenCurrentUsage >= *config.RateLimit.TokenMaxLimit { + // Check if token limit needs reset + if config.RateLimit.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*config.RateLimit.TokenResetDuration); err == nil { + if time.Since(config.RateLimit.TokenLastReset).Round(time.Millisecond) >= duration { + // Token limit expired but hasn't been reset yet - not violated + } else { + // Token limit exceeded and not expired + return true + } + } else { + // Parse error - assume violated + return true + } + } else { + // No reset duration - violated + return true + } + } + + // Check request limits + if config.RateLimit.RequestMaxLimit != nil && config.RateLimit.RequestCurrentUsage >= *config.RateLimit.RequestMaxLimit { + // Check if request limit needs reset + if config.RateLimit.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*config.RateLimit.RequestResetDuration); err == nil { + if time.Since(config.RateLimit.RequestLastReset).Round(time.Millisecond) >= duration { + // Request limit expired but hasn't been reset yet - not violated + } else { + // Request limit exceeded and not expired + return true + } + } else { + // Parse error - assume violated + return true + } + } else { + // No reset duration - violated + return true + } + } + + return false // No violations +} diff --git a/plugins/governance/store.go b/plugins/governance/store.go new file mode 100644 index 000000000..9f91a5e7b --- /dev/null +++ b/plugins/governance/store.go @@ -0,0 +1,728 @@ +// Package governance provides the in-memory cache store for fast governance data access +package governance + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GovernanceStore provides in-memory cache for governance data with fast, non-blocking access +type GovernanceStore struct { + // Core data maps using sync.Map for lock-free reads + virtualKeys sync.Map // string -> *VirtualKey (VK value -> VirtualKey with preloaded relationships) + teams sync.Map // string -> *Team (Team ID -> Team) + customers sync.Map // string -> *Customer (Customer ID -> Customer) + budgets sync.Map // string -> *Budget (Budget ID -> Budget) + + // Config store for refresh operations + configStore configstore.ConfigStore + + // Logger + logger schemas.Logger +} + +// NewGovernanceStore creates a new in-memory governance store +func NewGovernanceStore(ctx context.Context, logger schemas.Logger, configStore configstore.ConfigStore, governanceConfig *configstore.GovernanceConfig) (*GovernanceStore, error) { + store := &GovernanceStore{ + configStore: configStore, + logger: logger, + } + + if configStore != nil { + // Load initial data from database + if err := store.loadFromDatabase(ctx); err != nil { + return nil, fmt.Errorf("failed to load initial data: %w", err) + } + } else { + if err := store.loadFromConfigMemory(ctx, governanceConfig); err != nil { + return nil, fmt.Errorf("failed to load governance data from config memory: %w", err) + } + } + + store.logger.Info("governance store initialized successfully") + return store, nil +} + +// GetVirtualKey retrieves a virtual key by its value (lock-free) with all relationships preloaded +func (gs *GovernanceStore) GetVirtualKey(vkValue string) (*configstoreTables.TableVirtualKey, bool) { + value, exists := gs.virtualKeys.Load(vkValue) + if !exists || value == nil { + return nil, false + } + + vk, ok := value.(*configstoreTables.TableVirtualKey) + if !ok || vk == nil { + return nil, false + } + return vk, true +} + +// GetAllBudgets returns all budgets (for background reset operations) +func (gs *GovernanceStore) GetAllBudgets() map[string]*configstoreTables.TableBudget { + result := make(map[string]*configstoreTables.TableBudget) + gs.budgets.Range(func(key, value interface{}) bool { + // Type-safe conversion + keyStr, keyOk := key.(string) + budget, budgetOk := value.(*configstoreTables.TableBudget) + + if keyOk && budgetOk && budget != nil { + result[keyStr] = budget + } + return true // continue iteration + }) + return result +} + +// CheckBudget performs budget checking using in-memory store data (lock-free for high performance) +func (gs *GovernanceStore) CheckBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey) error { + if vk == nil { + return fmt.Errorf("virtual key cannot be nil") + } + + // Use helper to collect budgets and their names (lock-free) + budgetsToCheck, budgetNames := gs.collectBudgetsFromHierarchy(ctx, vk) + + // Check each budget in hierarchy order using in-memory data + for i, budget := range budgetsToCheck { + // Check if budget needs reset (in-memory check) + if budget.ResetDuration != "" { + if duration, err := configstoreTables.ParseDuration(budget.ResetDuration); err == nil { + if time.Since(budget.LastReset).Round(time.Millisecond) >= duration { + // Budget expired but hasn't been reset yet - treat as reset + // Note: actual reset will happen in post-hook via AtomicBudgetUpdate + continue // Skip budget check for expired budgets + } + } + } + + // Check if current usage exceeds budget limit + if budget.CurrentUsage > budget.MaxLimit { + return fmt.Errorf("%s budget exceeded: %.4f > %.4f dollars", + budgetNames[i], budget.CurrentUsage, budget.MaxLimit) + } + } + + return nil +} + +// UpdateBudget performs atomic budget updates across the hierarchy (both in memory and in database) +func (gs *GovernanceStore) UpdateBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey, cost float64) error { + if vk == nil { + return fmt.Errorf("virtual key cannot be nil") + } + + // Collect budget IDs using fast in-memory lookup instead of DB queries + budgetIDs := gs.collectBudgetIDsFromMemory(ctx, vk) + + if gs.configStore == nil { + for _, budgetID := range budgetIDs { + // Update in-memory cache for next read (lock-free) + if cachedBudgetValue, exists := gs.budgets.Load(budgetID); exists && cachedBudgetValue != nil { + if cachedBudget, ok := cachedBudgetValue.(*configstoreTables.TableBudget); ok && cachedBudget != nil { + clone := *cachedBudget + clone.CurrentUsage += cost + gs.budgets.Store(budgetID, &clone) + } + } + } + + return nil + } + + return gs.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // budgetIDs already collected from in-memory data - no need to duplicate + + // Update each budget atomically + for _, budgetID := range budgetIDs { + var budget configstoreTables.TableBudget + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&budget, "id = ?", budgetID).Error; err != nil { + return fmt.Errorf("failed to lock budget %s: %w", budgetID, err) + } + + // Check if budget needs reset + if err := gs.resetBudgetIfNeeded(ctx, tx, &budget); err != nil { + return fmt.Errorf("failed to reset budget: %w", err) + } + + // Update usage + budget.CurrentUsage += cost + if err := gs.configStore.UpdateBudget(ctx, &budget, tx); err != nil { + return fmt.Errorf("failed to save budget %s: %w", budgetID, err) + } + + // Update in-memory cache for next read (lock-free) + if cachedBudgetValue, exists := gs.budgets.Load(budgetID); exists && cachedBudgetValue != nil { + if cachedBudget, ok := cachedBudgetValue.(*configstoreTables.TableBudget); ok && cachedBudget != nil { + clone := *cachedBudget + clone.CurrentUsage += cost + clone.LastReset = budget.LastReset + gs.budgets.Store(budgetID, &clone) + } + } + } + + return nil + }) +} + +// UpdateRateLimitUsage updates rate limit counters for both provider-level and VK-level rate limits (lock-free) +func (gs *GovernanceStore) UpdateRateLimitUsage(ctx context.Context, vkValue string, provider string, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error { + if vkValue == "" { + return fmt.Errorf("virtual key value cannot be empty") + } + + vkValue_, exists := gs.virtualKeys.Load(vkValue) + if !exists || vkValue_ == nil { + return fmt.Errorf("virtual key not found: %s", vkValue) + } + + vk, ok := vkValue_.(*configstoreTables.TableVirtualKey) + if !ok || vk == nil { + return fmt.Errorf("invalid virtual key type for: %s", vkValue) + } + + var rateLimitsToUpdate []*configstoreTables.TableRateLimit + + // First, update provider-level rate limits if they exist + if provider != "" && vk.ProviderConfigs != nil { + for _, pc := range vk.ProviderConfigs { + if pc.Provider == provider && pc.RateLimit != nil { + if gs.updateSingleRateLimit(pc.RateLimit, tokensUsed, shouldUpdateTokens, shouldUpdateRequests) { + rateLimitsToUpdate = append(rateLimitsToUpdate, pc.RateLimit) + } + break + } + } + } + + // Then, update VK-level rate limits if they exist + if vk.RateLimit != nil { + if gs.updateSingleRateLimit(vk.RateLimit, tokensUsed, shouldUpdateTokens, shouldUpdateRequests) { + rateLimitsToUpdate = append(rateLimitsToUpdate, vk.RateLimit) + } + } + + // Save all updated rate limits to database + if len(rateLimitsToUpdate) > 0 && gs.configStore != nil { + if err := gs.configStore.UpdateRateLimits(ctx, rateLimitsToUpdate); err != nil { + return fmt.Errorf("failed to update rate limit usage: %w", err) + } + } + + return nil +} + +// updateSingleRateLimit updates a single rate limit's counters and returns true if any changes were made +func (gs *GovernanceStore) updateSingleRateLimit(rateLimit *configstoreTables.TableRateLimit, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) bool { + now := time.Now() + updated := false + + // Check and reset token counter if needed + if rateLimit.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err == nil { + if now.Sub(rateLimit.TokenLastReset) >= duration { + rateLimit.TokenCurrentUsage = 0 + rateLimit.TokenLastReset = now + updated = true + } + } + } + + // Check and reset request counter if needed + if rateLimit.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err == nil { + if now.Sub(rateLimit.RequestLastReset) >= duration { + rateLimit.RequestCurrentUsage = 0 + rateLimit.RequestLastReset = now + updated = true + } + } + } + + // Update usage counters based on flags + if shouldUpdateTokens && tokensUsed > 0 { + rateLimit.TokenCurrentUsage += tokensUsed + updated = true + } + + if shouldUpdateRequests { + rateLimit.RequestCurrentUsage += 1 + updated = true + } + + return updated +} + +// checkAndResetSingleRateLimit checks and resets a single rate limit's counters if expired +func (gs *GovernanceStore) checkAndResetSingleRateLimit(ctx context.Context, rateLimit *configstoreTables.TableRateLimit, now time.Time) bool { + updated := false + + // Check and reset token counter if needed + if rateLimit.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err == nil { + if now.Sub(rateLimit.TokenLastReset).Round(time.Millisecond) >= duration { + rateLimit.TokenCurrentUsage = 0 + rateLimit.TokenLastReset = now + updated = true + } + } + } + + // Check and reset request counter if needed + if rateLimit.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err == nil { + if now.Sub(rateLimit.RequestLastReset).Round(time.Millisecond) >= duration { + rateLimit.RequestCurrentUsage = 0 + rateLimit.RequestLastReset = now + updated = true + } + } + } + + return updated +} + +// ResetExpiredRateLimits performs background reset of expired rate limits for both provider-level and VK-level (lock-free) +func (gs *GovernanceStore) ResetExpiredRateLimits(ctx context.Context) error { + now := time.Now() + var resetRateLimits []*configstoreTables.TableRateLimit + + gs.virtualKeys.Range(func(key, value interface{}) bool { + // Type-safe conversion + vk, ok := value.(*configstoreTables.TableVirtualKey) + if !ok || vk == nil { + return true // continue + } + + // Check provider-level rate limits + if vk.ProviderConfigs != nil { + for _, pc := range vk.ProviderConfigs { + if pc.RateLimit != nil { + if gs.checkAndResetSingleRateLimit(ctx, pc.RateLimit, now) { + resetRateLimits = append(resetRateLimits, pc.RateLimit) + } + } + } + } + + // Check VK-level rate limits + if vk.RateLimit != nil { + if gs.checkAndResetSingleRateLimit(ctx, vk.RateLimit, now) { + resetRateLimits = append(resetRateLimits, vk.RateLimit) + } + } + + return true // continue + }) + + // Persist reset rate limits to database + if len(resetRateLimits) > 0 && gs.configStore != nil { + if err := gs.configStore.UpdateRateLimits(ctx, resetRateLimits); err != nil { + return fmt.Errorf("failed to persist rate limit resets to database: %w", err) + } + } + + return nil +} + +// ResetExpiredBudgets checks and resets budgets that have exceeded their reset duration (lock-free) +func (gs *GovernanceStore) ResetExpiredBudgets(ctx context.Context) error { + now := time.Now() + var resetBudgets []*configstoreTables.TableBudget + + gs.budgets.Range(func(key, value interface{}) bool { + // Type-safe conversion + budget, ok := value.(*configstoreTables.TableBudget) + if !ok || budget == nil { + return true // continue + } + + duration, err := configstoreTables.ParseDuration(budget.ResetDuration) + if err != nil { + gs.logger.Error("invalid budget reset duration %s: %w", budget.ResetDuration, err) + return true // continue + } + + if now.Sub(budget.LastReset) >= duration { + oldUsage := budget.CurrentUsage + budget.CurrentUsage = 0 + budget.LastReset = now + resetBudgets = append(resetBudgets, budget) + + gs.logger.Debug(fmt.Sprintf("Reset budget %s (was %.2f, reset to 0)", + budget.ID, oldUsage)) + } + return true // continue + }) + + // Persist to database if any resets occurred + if len(resetBudgets) > 0 && gs.configStore != nil { + if err := gs.configStore.UpdateBudgets(ctx, resetBudgets); err != nil { + return fmt.Errorf("failed to persist budget resets to database: %w", err) + } + } + + return nil +} + +// DATABASE METHODS + +// loadFromDatabase loads all governance data from the database into memory +func (gs *GovernanceStore) loadFromDatabase(ctx context.Context) error { + // Load customers with their budgets + customers, err := gs.configStore.GetCustomers(ctx) + if err != nil { + return fmt.Errorf("failed to load customers: %w", err) + } + + // Load teams with their budgets + teams, err := gs.configStore.GetTeams(ctx, "") + if err != nil { + return fmt.Errorf("failed to load teams: %w", err) + } + + // Load virtual keys with all relationships + virtualKeys, err := gs.configStore.GetVirtualKeys(ctx) + if err != nil { + return fmt.Errorf("failed to load virtual keys: %w", err) + } + + // Load budgets + budgets, err := gs.configStore.GetBudgets(ctx) + if err != nil { + return fmt.Errorf("failed to load budgets: %w", err) + } + + // Rebuild in-memory structures (lock-free) + gs.rebuildInMemoryStructures(ctx, customers, teams, virtualKeys, budgets) + + return nil +} + +// loadFromConfigMemory loads all governance data from the config's memory into store's memory +func (gs *GovernanceStore) loadFromConfigMemory(ctx context.Context, config *configstore.GovernanceConfig) error { + if config == nil { + return fmt.Errorf("governance config is nil") + } + + // Load customers with their budgets + customers := config.Customers + + // Load teams with their budgets + teams := config.Teams + + // Load budgets + budgets := config.Budgets + + // Load virtual keys with all relationships + virtualKeys := config.VirtualKeys + + // Load rate limits + rateLimits := config.RateLimits + + // Populate virtual keys with their relationships + for i := range virtualKeys { + vk := &virtualKeys[i] + + for i := range teams { + if vk.TeamID != nil && teams[i].ID == *vk.TeamID { + vk.Team = &teams[i] + } + } + + for i := range customers { + if vk.CustomerID != nil && customers[i].ID == *vk.CustomerID { + vk.Customer = &customers[i] + } + } + + for i := range budgets { + if vk.BudgetID != nil && budgets[i].ID == *vk.BudgetID { + vk.Budget = &budgets[i] + } + } + + for i := range rateLimits { + if vk.RateLimitID != nil && rateLimits[i].ID == *vk.RateLimitID { + vk.RateLimit = &rateLimits[i] + } + } + + virtualKeys[i] = *vk + } + + // Rebuild in-memory structures (lock-free) + gs.rebuildInMemoryStructures(ctx, customers, teams, virtualKeys, budgets) + + return nil +} + +// rebuildInMemoryStructures rebuilds all in-memory data structures (lock-free) +func (gs *GovernanceStore) rebuildInMemoryStructures(ctx context.Context, customers []configstoreTables.TableCustomer, teams []configstoreTables.TableTeam, virtualKeys []configstoreTables.TableVirtualKey, budgets []configstoreTables.TableBudget) { + // Clear existing data by creating new sync.Maps + gs.virtualKeys = sync.Map{} + gs.teams = sync.Map{} + gs.customers = sync.Map{} + gs.budgets = sync.Map{} + + // Build customers map + for i := range customers { + customer := &customers[i] + gs.customers.Store(customer.ID, customer) + } + + // Build teams map + for i := range teams { + team := &teams[i] + gs.teams.Store(team.ID, team) + } + + // Build budgets map + for i := range budgets { + budget := &budgets[i] + gs.budgets.Store(budget.ID, budget) + } + + // Build virtual keys map and track active VKs + for i := range virtualKeys { + vk := &virtualKeys[i] + gs.virtualKeys.Store(vk.Value, vk) + } +} + +// UTILITY FUNCTIONS + +// collectBudgetsFromHierarchy collects budgets and their metadata from the hierarchy (Provider Configs β†’ VK β†’ Team β†’ Customer) +func (gs *GovernanceStore) collectBudgetsFromHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey) ([]*configstoreTables.TableBudget, []string) { + if vk == nil { + return nil, nil + } + + var budgets []*configstoreTables.TableBudget + var budgetNames []string + + // Collect all budgets in hierarchy order using lock-free sync.Map access (Provider Configs β†’ VK β†’ Team β†’ Customer) + for _, pc := range vk.ProviderConfigs { + if pc.BudgetID != nil { + if budgetValue, exists := gs.budgets.Load(*pc.BudgetID); exists && budgetValue != nil { + if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { + budgets = append(budgets, budget) + budgetNames = append(budgetNames, pc.Provider) + } + } + } + } + + if vk.BudgetID != nil { + if budgetValue, exists := gs.budgets.Load(*vk.BudgetID); exists && budgetValue != nil { + if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { + budgets = append(budgets, budget) + budgetNames = append(budgetNames, "VK") + } + } + } + + if vk.TeamID != nil { + if teamValue, exists := gs.teams.Load(*vk.TeamID); exists && teamValue != nil { + if team, ok := teamValue.(*configstoreTables.TableTeam); ok && team != nil { + if team.BudgetID != nil { + if budgetValue, exists := gs.budgets.Load(*team.BudgetID); exists && budgetValue != nil { + if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { + budgets = append(budgets, budget) + budgetNames = append(budgetNames, "Team") + } + } + } + + // Check if team belongs to a customer + if team.CustomerID != nil { + if customerValue, exists := gs.customers.Load(*team.CustomerID); exists && customerValue != nil { + if customer, ok := customerValue.(*configstoreTables.TableCustomer); ok && customer != nil { + if customer.BudgetID != nil { + if budgetValue, exists := gs.budgets.Load(*customer.BudgetID); exists && budgetValue != nil { + if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { + budgets = append(budgets, budget) + budgetNames = append(budgetNames, "Customer") + } + } + } + } + } + } + } + } + } + + if vk.CustomerID != nil { + if customerValue, exists := gs.customers.Load(*vk.CustomerID); exists && customerValue != nil { + if customer, ok := customerValue.(*configstoreTables.TableCustomer); ok && customer != nil { + if customer.BudgetID != nil { + if budgetValue, exists := gs.budgets.Load(*customer.BudgetID); exists && budgetValue != nil { + if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { + budgets = append(budgets, budget) + budgetNames = append(budgetNames, "Customer") + } + } + } + } + } + } + + return budgets, budgetNames +} + +// collectBudgetIDsFromMemory collects budget IDs from in-memory store data (lock-free) +func (gs *GovernanceStore) collectBudgetIDsFromMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey) []string { + budgets, _ := gs.collectBudgetsFromHierarchy(ctx, vk) + + budgetIDs := make([]string, len(budgets)) + for i, budget := range budgets { + budgetIDs[i] = budget.ID + } + + return budgetIDs +} + +// resetBudgetIfNeeded checks and resets budget within a transaction +func (gs *GovernanceStore) resetBudgetIfNeeded(ctx context.Context, tx *gorm.DB, budget *configstoreTables.TableBudget) error { + duration, err := configstoreTables.ParseDuration(budget.ResetDuration) + if err != nil { + return fmt.Errorf("invalid reset duration %s: %w", budget.ResetDuration, err) + } + + now := time.Now() + if now.Sub(budget.LastReset) >= duration { + budget.CurrentUsage = 0 + budget.LastReset = now + + if gs.configStore != nil { + // Save reset to database + if err := gs.configStore.UpdateBudget(ctx, budget, tx); err != nil { + return fmt.Errorf("failed to save budget reset: %w", err) + } + } + } + + return nil +} + +// PUBLIC API METHODS + +// CreateVirtualKeyInMemory adds a new virtual key to the in-memory store (lock-free) +func (gs *GovernanceStore) CreateVirtualKeyInMemory(vk *configstoreTables.TableVirtualKey) { // with rateLimit preloaded + if vk == nil { + return // Nothing to create + } + gs.virtualKeys.Store(vk.Value, vk) +} + +// UpdateVirtualKeyInMemory updates an existing virtual key in the in-memory store (lock-free) +func (gs *GovernanceStore) UpdateVirtualKeyInMemory(vk *configstoreTables.TableVirtualKey) { // with rateLimit preloaded + if vk == nil { + return // Nothing to update + } + gs.virtualKeys.Store(vk.Value, vk) +} + +// DeleteVirtualKeyInMemory removes a virtual key from the in-memory store +func (gs *GovernanceStore) DeleteVirtualKeyInMemory(vkID string) { + if vkID == "" { + return // Nothing to delete + } + + // Find and delete the VK by ID (lock-free) + gs.virtualKeys.Range(func(key, value interface{}) bool { + // Type-safe conversion + vk, ok := value.(*configstoreTables.TableVirtualKey) + if !ok || vk == nil { + return true // continue iteration + } + + if vk.ID == vkID { + gs.virtualKeys.Delete(key) + return false // stop iteration + } + return true // continue iteration + }) +} + +// CreateTeamInMemory adds a new team to the in-memory store (lock-free) +func (gs *GovernanceStore) CreateTeamInMemory(team *configstoreTables.TableTeam) { + if team == nil { + return // Nothing to create + } + gs.teams.Store(team.ID, team) +} + +// UpdateTeamInMemory updates an existing team in the in-memory store (lock-free) +func (gs *GovernanceStore) UpdateTeamInMemory(team *configstoreTables.TableTeam) { + if team == nil { + return // Nothing to update + } + gs.teams.Store(team.ID, team) +} + +// DeleteTeamInMemory removes a team from the in-memory store (lock-free) +func (gs *GovernanceStore) DeleteTeamInMemory(teamID string) { + if teamID == "" { + return // Nothing to delete + } + gs.teams.Delete(teamID) +} + +// CreateCustomerInMemory adds a new customer to the in-memory store (lock-free) +func (gs *GovernanceStore) CreateCustomerInMemory(customer *configstoreTables.TableCustomer) { + if customer == nil { + return // Nothing to create + } + gs.customers.Store(customer.ID, customer) +} + +// UpdateCustomerInMemory updates an existing customer in the in-memory store (lock-free) +func (gs *GovernanceStore) UpdateCustomerInMemory(customer *configstoreTables.TableCustomer) { + if customer == nil { + return // Nothing to update + } + gs.customers.Store(customer.ID, customer) +} + +// DeleteCustomerInMemory removes a customer from the in-memory store (lock-free) +func (gs *GovernanceStore) DeleteCustomerInMemory(customerID string) { + if customerID == "" { + return // Nothing to delete + } + gs.customers.Delete(customerID) +} + +// CreateBudgetInMemory adds a new budget to the in-memory store (lock-free) +func (gs *GovernanceStore) CreateBudgetInMemory(budget *configstoreTables.TableBudget) { + if budget == nil { + return // Nothing to create + } + gs.budgets.Store(budget.ID, budget) +} + +// UpdateBudgetInMemory updates a specific budget in the in-memory cache (lock-free) +func (gs *GovernanceStore) UpdateBudgetInMemory(budget *configstoreTables.TableBudget) error { + if budget == nil { + return fmt.Errorf("budget cannot be nil") + } + gs.budgets.Store(budget.ID, budget) + return nil +} + +// DeleteBudgetInMemory removes a budget from the in-memory store (lock-free) +func (gs *GovernanceStore) DeleteBudgetInMemory(budgetID string) { + if budgetID == "" { + return // Nothing to delete + } + gs.budgets.Delete(budgetID) +} diff --git a/plugins/governance/tracker.go b/plugins/governance/tracker.go new file mode 100644 index 000000000..432df1cab --- /dev/null +++ b/plugins/governance/tracker.go @@ -0,0 +1,250 @@ +// Package governance provides simplified usage tracking for the new hierarchical system +package governance + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" +) + +// UsageUpdate contains data for VK-level usage tracking +type UsageUpdate struct { + VirtualKey string `json:"virtual_key"` + Provider schemas.ModelProvider `json:"provider"` + Model string `json:"model"` + Success bool `json:"success"` + TokensUsed int64 `json:"tokens_used"` + Cost float64 `json:"cost"` // Cost in dollars + RequestID string `json:"request_id"` + TeamID *string `json:"team_id,omitempty"` // For audit trail + CustomerID *string `json:"customer_id,omitempty"` // For audit trail + + // Streaming optimization fields + IsStreaming bool `json:"is_streaming"` // Whether this is a streaming response + IsFinalChunk bool `json:"is_final_chunk"` // Whether this is the final chunk + HasUsageData bool `json:"has_usage_data"` // Whether this chunk contains usage data +} + +// UsageTracker manages VK-level usage tracking and budget management +type UsageTracker struct { + store *GovernanceStore + resolver *BudgetResolver + configStore configstore.ConfigStore + logger schemas.Logger + + // Background workers + trackerCtx context.Context + trackerCancel context.CancelFunc + resetTicker *time.Ticker + done chan struct{} + wg sync.WaitGroup +} + +// NewUsageTracker creates a new usage tracker for the hierarchical budget system +func NewUsageTracker(ctx context.Context, store *GovernanceStore, resolver *BudgetResolver, configStore configstore.ConfigStore, logger schemas.Logger) *UsageTracker { + tracker := &UsageTracker{ + store: store, + resolver: resolver, + configStore: configStore, + logger: logger, + done: make(chan struct{}), + } + + // Start background workers for business logic + tracker.trackerCtx, tracker.trackerCancel = context.WithCancel(context.Background()) + tracker.startWorkers(tracker.trackerCtx) + + tracker.logger.Info("usage tracker initialized for hierarchical budget system") + return tracker +} + +// UpdateUsage queues a usage update for async processing (main business entry point) +func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { + // Get virtual key + vk, exists := t.store.GetVirtualKey(update.VirtualKey) + if !exists { + t.logger.Debug(fmt.Sprintf("Virtual key not found: %s", update.VirtualKey)) + return + } + + // Only process successful requests for usage tracking + if !update.Success { + t.logger.Debug(fmt.Sprintf("Request was not successful, skipping usage update for VK: %s", vk.ID)) + return + } + + // Streaming optimization: only process certain updates based on streaming status + shouldUpdateTokens := !update.IsStreaming || (update.IsStreaming && update.HasUsageData) + shouldUpdateRequests := !update.IsStreaming || (update.IsStreaming && update.IsFinalChunk) + shouldUpdateBudget := !update.IsStreaming || (update.IsStreaming && update.HasUsageData) + + // Update rate limit usage (both provider-level and VK-level) if applicable + if vk.RateLimit != nil || len(vk.ProviderConfigs) > 0 { + if err := t.store.UpdateRateLimitUsage(ctx, update.VirtualKey, string(update.Provider), update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { + t.logger.Error("failed to update rate limit usage for VK %s: %v", vk.ID, err) + } + } + + // Update budget usage in hierarchy (VK β†’ Team β†’ Customer) only if we have usage data + if shouldUpdateBudget && update.Cost > 0 { + t.updateBudgetHierarchy(ctx, vk, update) + } +} + +// updateBudgetHierarchy updates budget usage atomically in the VK β†’ Team β†’ Customer hierarchy +func (t *UsageTracker) updateBudgetHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey, update *UsageUpdate) { + // Use atomic budget update to prevent race conditions and ensure consistency + if err := t.store.UpdateBudget(ctx, vk, update.Cost); err != nil { + t.logger.Error("failed to update budget hierarchy atomically for VK %s: %v", vk.ID, err) + } +} + +// startWorkers starts all background workers for business logic +func (t *UsageTracker) startWorkers(ctx context.Context) { + // Counter reset manager (business logic) + t.resetTicker = time.NewTicker(1 * time.Minute) + t.wg.Add(1) + go t.resetWorker(ctx) +} + +// resetWorker manages periodic resets of rate limit and usage counters +func (t *UsageTracker) resetWorker(ctx context.Context) { + defer t.wg.Done() + + for { + select { + case <-t.resetTicker.C: + t.resetExpiredCounters(ctx) + + case <-t.done: + return + } + } +} + +// resetExpiredCounters manages periodic resets of usage counters AND budgets using flexible durations +func (t *UsageTracker) resetExpiredCounters(ctx context.Context) { + // ==== PART 1: Reset Rate Limits ==== + if err := t.store.ResetExpiredRateLimits(ctx); err != nil { + t.logger.Error("failed to reset expired rate limits: %v", err) + } + + // ==== PART 2: Reset Budgets ==== + if err := t.store.ResetExpiredBudgets(ctx); err != nil { + t.logger.Error("failed to reset expired budgets: %v", err) + } +} + +// Public methods for monitoring and admin operations + +// PerformStartupResets checks and resets any expired rate limits and budgets on startup +func (t *UsageTracker) PerformStartupResets(ctx context.Context) error { + if t.configStore == nil { + t.logger.Warn("config store is not available, skipping initialization of usage tracker") + return nil + } + + t.logger.Info("performing startup reset check for expired rate limits and budgets") + now := time.Now() + + var resetRateLimits []*configstoreTables.TableRateLimit + var errs []string + var vksWithRateLimits int + var vksWithoutRateLimits int + + // ==== RESET EXPIRED RATE LIMITS ==== + // Check ALL virtual keys (both active and inactive) for expired rate limits + allVKs, err := t.configStore.GetVirtualKeys(ctx) + if err != nil { + errs = append(errs, fmt.Sprintf("failed to load virtual keys for reset: %s", err.Error())) + } else { + t.logger.Debug(fmt.Sprintf("startup reset: checking %d virtual keys (active + inactive) for expired rate limits", len(allVKs))) + } + + for i := range allVKs { + vk := &allVKs[i] // Get pointer to VK for modifications + if vk.RateLimit == nil { + vksWithoutRateLimits++ + continue + } + + vksWithRateLimits++ + + rateLimit := vk.RateLimit + rateLimitUpdated := false + + // Check token limits + if rateLimit.TokenResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err == nil { + timeSinceReset := now.Sub(rateLimit.TokenLastReset) + if timeSinceReset >= duration { + rateLimit.TokenCurrentUsage = 0 + rateLimit.TokenLastReset = now + rateLimitUpdated = true + } + } else { + errs = append(errs, fmt.Sprintf("invalid token reset duration for VK %s: %s", vk.ID, *rateLimit.TokenResetDuration)) + } + } + + // Check request limits + if rateLimit.RequestResetDuration != nil { + if duration, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err == nil { + timeSinceReset := now.Sub(rateLimit.RequestLastReset) + if timeSinceReset >= duration { + rateLimit.RequestCurrentUsage = 0 + rateLimit.RequestLastReset = now + rateLimitUpdated = true + } + } else { + errs = append(errs, fmt.Sprintf("invalid request reset duration for VK %s: %s", vk.ID, *rateLimit.RequestResetDuration)) + } + } + + if rateLimitUpdated { + resetRateLimits = append(resetRateLimits, rateLimit) + } + } + + // DB reset is also handled by this function + if err := t.store.ResetExpiredBudgets(ctx); err != nil { + errs = append(errs, fmt.Sprintf("failed to reset expired budgets: %s", err.Error())) + } + + // ==== PERSIST RESETS TO DATABASE ==== + if t.configStore != nil { + if len(resetRateLimits) > 0 { + if err := t.configStore.UpdateRateLimits(ctx, resetRateLimits); err != nil { + errs = append(errs, fmt.Sprintf("failed to persist rate limit resets: %s", err.Error())) + } + } + } + if len(errs) > 0 { + t.logger.Error("startup reset encountered %d errors: %v", len(errs), errs) + return fmt.Errorf("startup reset completed with %d errors", len(errs)) + } + + return nil +} + +// Cleanup stops all background workers and flushes pending operations +func (t *UsageTracker) Cleanup() error { + // Stop background workers + if t.trackerCancel != nil { + t.trackerCancel() + } + close(t.done) + if t.resetTicker != nil { + t.resetTicker.Stop() + } + // Wait for workers to finish + t.wg.Wait() + + t.logger.Debug("usage tracker cleanup completed") + return nil +} diff --git a/plugins/governance/utils.go b/plugins/governance/utils.go new file mode 100644 index 000000000..017ae2d0c --- /dev/null +++ b/plugins/governance/utils.go @@ -0,0 +1,36 @@ +// Package governance provides utility functions for the governance plugin +package governance + +import ( + "context" + + "github.com/maximhq/bifrost/core/schemas" +) + +// extractHeadersFromContext extracts governance headers from context (standalone version) +func extractHeadersFromContext(ctx context.Context) map[string]string { + headers := make(map[string]string) + + // Extract governance headers using schemas.BifrostContextKey + if teamID := getStringFromContext(ctx, schemas.BifrostContextKey("x-bf-team")); teamID != "" { + headers["x-bf-team"] = teamID + } + if userID := getStringFromContext(ctx, schemas.BifrostContextKey("x-bf-user")); userID != "" { + headers["x-bf-user"] = userID + } + if customerID := getStringFromContext(ctx, schemas.BifrostContextKey("x-bf-customer")); customerID != "" { + headers["x-bf-customer"] = customerID + } + + return headers +} + +// getStringFromContext safely extracts a string value from context +func getStringFromContext(ctx context.Context, key any) string { + if value := ctx.Value(key); value != nil { + if str, ok := value.(string); ok { + return str + } + } + return "" +} diff --git a/plugins/governance/version b/plugins/governance/version new file mode 100644 index 000000000..5574de9b7 --- /dev/null +++ b/plugins/governance/version @@ -0,0 +1 @@ +1.3.28 diff --git a/plugins/jsonparser/changelog.md b/plugins/jsonparser/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/jsonparser/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/jsonparser/go.mod b/plugins/jsonparser/go.mod new file mode 100644 index 000000000..a939ecbc2 --- /dev/null +++ b/plugins/jsonparser/go.mod @@ -0,0 +1,53 @@ +module github.com/maximhq/bifrost/plugins/jsonparser + +go 1.24.0 + +toolchain go1.24.3 + +require github.com/maximhq/bifrost/core v1.2.22 + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/jsonparser/go.sum b/plugins/jsonparser/go.sum new file mode 100644 index 000000000..11cf9a0b8 --- /dev/null +++ b/plugins/jsonparser/go.sum @@ -0,0 +1,129 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/jsonparser/main.go b/plugins/jsonparser/main.go new file mode 100644 index 000000000..0672a5ca3 --- /dev/null +++ b/plugins/jsonparser/main.go @@ -0,0 +1,236 @@ +package jsonparser + +import ( + "context" + "strings" + "sync" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +const ( + PluginName = "streaming-json-parser" +) + +type Usage string + +const ( + AllRequests Usage = "all_requests" + PerRequest Usage = "per_request" +) + +// AccumulatedContent holds both the content and timestamp for a request +type AccumulatedContent struct { + Content *strings.Builder + Timestamp time.Time +} + +// JsonParserPlugin provides JSON parsing capabilities for streaming responses +// It handles partial JSON chunks by accumulating them and making the accumulated content valid JSON +type JsonParserPlugin struct { + usage Usage + // State management for accumulating chunks + accumulatedContent map[string]*AccumulatedContent // requestID -> accumulated content with timestamp + mutex sync.RWMutex + // Cleanup configuration + cleanupInterval time.Duration + maxAge time.Duration + stopCleanup chan struct{} + stopOnce sync.Once +} + +// PluginConfig holds configuration options for the JSON parser plugin +type PluginConfig struct { + Usage Usage + CleanupInterval time.Duration + MaxAge time.Duration +} + +const ( + EnableStreamingJSONParser schemas.BifrostContextKey = "enable-streaming-json-parser" +) + +// Init creates a new JSON parser plugin instance with custom configuration +func Init(config PluginConfig) (*JsonParserPlugin, error) { + // Set defaults if not provided + if config.CleanupInterval <= 0 { + config.CleanupInterval = 5 * time.Minute + } + if config.MaxAge <= 0 { + config.MaxAge = 30 * time.Minute + } + if config.Usage == "" { + config.Usage = PerRequest + } + + plugin := &JsonParserPlugin{ + usage: config.Usage, + accumulatedContent: make(map[string]*AccumulatedContent), + cleanupInterval: config.CleanupInterval, + maxAge: config.MaxAge, + stopCleanup: make(chan struct{}), + } + + // Start the cleanup goroutine + go plugin.startCleanupGoroutine() + + return plugin, nil +} + +// GetName returns the plugin name +func (p *JsonParserPlugin) GetName() string { + return PluginName +} + +// TransportInterceptor is not used for this plugin +func (p *JsonParserPlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// PreHook is not used for this plugin as we only process responses +func (p *JsonParserPlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + return req, nil, nil +} + +// PostHook processes streaming responses by accumulating chunks and making accumulated content valid JSON +func (p *JsonParserPlugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + // If there's an error, don't process + if err != nil { + return result, err, nil + } + + extraFields := result.GetExtraFields() + + // Check if plugin should run based on usage type + if !p.shouldRun(ctx, extraFields.RequestType) { + return result, err, nil + } + + // If no chat response, return as is + if result == nil || result.ChatResponse == nil { + return result, err, nil + } + + // Get request ID for state management, if it's not set, return as is + requestID := p.getRequestID(ctx, result) + if requestID == "" { + return result, err, nil + } + + // Create a deep copy of the result to avoid modifying the original pointer + // This ensures other plugins using the same pointer don't get corrupted data + resultCopy := p.deepCopyBifrostResponse(result) + if resultCopy == nil || resultCopy.ChatResponse == nil { + return result, err, nil + } + + // Process only streaming choices to accumulate and fix partial JSON + if len(resultCopy.ChatResponse.Choices) > 0 { + for i := range resultCopy.ChatResponse.Choices { + choice := &resultCopy.ChatResponse.Choices[i] + + // Handle only streaming response + if choice.ChatStreamResponseChoice != nil { + if choice.ChatStreamResponseChoice.Delta.Content != nil { + content := *choice.ChatStreamResponseChoice.Delta.Content + if content != "" { + // Accumulate the content + accumulated := p.accumulateContent(requestID, content) + + // Process the accumulated content to make it valid JSON + fixedContent := p.parsePartialJSON(accumulated) + + if !p.isValidJSON(fixedContent) { + err = &schemas.BifrostError{ + Error: &schemas.ErrorField{ + Message: "Invalid JSON in streaming response", + }, + StreamControl: &schemas.StreamControl{ + SkipStream: bifrost.Ptr(true), + }, + } + + return nil, err, nil + } + + // Replace the delta content with the complete valid JSON + choice.ChatStreamResponseChoice.Delta.Content = &fixedContent + } + } + } + } + } + + // If this is the final chunk, cleanup the accumulated content for this request + if streamEndIndicatorValue := (*ctx).Value(schemas.BifrostContextKeyStreamEndIndicator); streamEndIndicatorValue != nil { + isFinalChunk, ok := streamEndIndicatorValue.(bool) + if ok && isFinalChunk { + p.ClearRequestState(requestID) + } + } + + // Return the modified copy instead of the original + return resultCopy, err, nil +} + +// Cleanup performs plugin cleanup and clears accumulated content +func (p *JsonParserPlugin) Cleanup() error { + // Stop the cleanup goroutine + p.StopCleanup() + + p.mutex.Lock() + defer p.mutex.Unlock() + + // Clear accumulated content + p.accumulatedContent = make(map[string]*AccumulatedContent) + return nil +} + +// ClearRequestState clears the accumulated content for a specific request +func (p *JsonParserPlugin) ClearRequestState(requestID string) { + p.mutex.Lock() + defer p.mutex.Unlock() + + delete(p.accumulatedContent, requestID) +} + +// CLEANUP METHODS + +// startCleanupGoroutine starts a goroutine that periodically cleans up old accumulated content +func (p *JsonParserPlugin) startCleanupGoroutine() { + ticker := time.NewTicker(p.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + p.cleanupOldEntries() + case <-p.stopCleanup: + return + } + } +} + +// cleanupOldEntries removes accumulated content entries that are older than maxAge +func (p *JsonParserPlugin) cleanupOldEntries() { + p.mutex.Lock() + defer p.mutex.Unlock() + + now := time.Now() + cutoff := now.Add(-p.maxAge) + + for requestID, content := range p.accumulatedContent { + if content.Timestamp.Before(cutoff) { + delete(p.accumulatedContent, requestID) + } + } +} + +// StopCleanup stops the cleanup goroutine +func (p *JsonParserPlugin) StopCleanup() { + p.stopOnce.Do(func() { + close(p.stopCleanup) + }) +} diff --git a/plugins/jsonparser/plugin_test.go b/plugins/jsonparser/plugin_test.go new file mode 100644 index 000000000..9fdc0bfee --- /dev/null +++ b/plugins/jsonparser/plugin_test.go @@ -0,0 +1,323 @@ +package jsonparser + +import ( + "context" + "os" + "testing" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// BaseAccount implements the schemas.Account interface for testing purposes. +// It provides mock implementations of the required methods to test the JSON parser plugin +// with a basic OpenAI configuration. +type BaseAccount struct{} + +// GetConfiguredProviders returns a list of supported providers for testing. +// Currently only supports OpenAI for simplicity in testing. +func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{schemas.OpenAI}, nil +} + +// GetKeysForProvider returns a mock API key configuration for testing. +// Uses the OPENAI_API_KEY environment variable for authentication. +func (baseAccount *BaseAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{"gpt-4o-mini", "gpt-4-turbo"}, + Weight: 1.0, + }, + }, nil +} + +// GetConfigForProvider returns default provider configuration for testing. +// Uses standard network and concurrency settings. +func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil +} + +// TestJsonParserPluginEndToEnd tests the integration of the JSON parser plugin with Bifrost. +// It performs the following steps: +// 1. Initializes the JSON parser plugin with AllRequests usage +// 2. Sets up a test Bifrost instance with the plugin +// 3. Makes a test chat completion request with streaming enabled +// 4. Verifies that the plugin processes the streaming response correctly +// +// Required environment variables: +// - OPENAI_API_KEY: Your OpenAI API key for the test request +func TestJsonParserPluginEndToEnd(t *testing.T) { + ctx := context.Background() + // Check if OpenAI API key is set + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("OPENAI_API_KEY is not set, skipping end-to-end test") + } + + // Initialize the JSON parser plugin for all requests + plugin, err := Init(PluginConfig{ + Usage: AllRequests, + CleanupInterval: 5 * time.Minute, + MaxAge: 30 * time.Minute, + }) + if err != nil { + t.Fatalf("Error initializing JSON parser plugin: %v", err) + } + + account := BaseAccount{} + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + // Make a test responses request with streaming enabled + // Request JSON output to test the parser + var responseFormat interface{} = map[string]interface{}{ + "type": "json_object", + } + + request := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Return a JSON object with name, age, and city fields. Example: {\"name\": \"John\", \"age\": 30, \"city\": \"New York\"}"), + }, + }, + }, + Params: &schemas.ChatParameters{ + ResponseFormat: &responseFormat, + }, + } + // Make the streaming request + responseChan, bifrostErr := client.ChatCompletionStreamRequest(ctx, request) + + if bifrostErr != nil { + t.Fatalf("Error in Bifrost request: %v", bifrostErr) + } + + // Process streaming responses + if responseChan != nil { + t.Logf("Streaming response channel received") + + // Read from the channel to see the streaming responses + responseCount := 0 + + for streamResponse := range responseChan { + responseCount++ + + if streamResponse.BifrostError != nil { + t.Logf("Streaming response error: %v", streamResponse.BifrostError) + } + + if streamResponse.BifrostChatResponse != nil { + if streamResponse.BifrostChatResponse.Choices != nil { + for _, outputMsg := range streamResponse.BifrostChatResponse.Choices { + if outputMsg.ChatStreamResponseChoice != nil && outputMsg.ChatStreamResponseChoice.Delta.Content != nil { + content := *outputMsg.ChatStreamResponseChoice.Delta.Content + if content != "" { + t.Logf("Chunk %d: %s", responseCount, content) + } + } + } + } + } + } + + t.Logf("Stream completed after %d responses", responseCount) + } else { + t.Logf("No streaming response channel received") + } + + t.Log("End-to-end test completed - check logs for JSON parsing behavior") +} + +// TestJsonParserPluginPerRequest tests the per-request configuration of the JSON parser plugin. +// It tests how the plugin behaves when enabled via context for specific requests. +// +// Required environment variables: +// - OPENAI_API_KEY: Your OpenAI API key for the test request +func TestJsonParserPluginPerRequest(t *testing.T) { + ctx := context.Background() + // Check if OpenAI API key is set + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("OPENAI_API_KEY is not set, skipping per-request test") + } + + // Initialize the JSON parser plugin for per-request usage + plugin, err := Init(PluginConfig{ + Usage: PerRequest, + CleanupInterval: 5 * time.Minute, + MaxAge: 30 * time.Minute, + }) + if err != nil { + t.Fatalf("Error initializing JSON parser plugin: %v", err) + } + + account := BaseAccount{} + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + // Test request with plugin enabled via context + var responseFormat interface{} = map[string]interface{}{ + "type": "json_object", + } + + request := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Return a JSON object with name and age fields."), + }, + }, + }, + Params: &schemas.ChatParameters{ + ResponseFormat: &responseFormat, + }, + } + + // Create context with plugin enabled + newContext := context.WithValue(ctx, EnableStreamingJSONParser, true) + + // Make the streaming request + responseChan, bifrostErr := client.ChatCompletionStreamRequest(newContext, request) + + if bifrostErr != nil { + t.Logf("Error in Bifrost request: %v", bifrostErr) + } + + // Process streaming responses + if responseChan != nil { + t.Logf("Streaming response channel received for per-request test") + + // Read from the channel to see the streaming responses + responseCount := 0 + + for streamResponse := range responseChan { + responseCount++ + + if streamResponse.BifrostError != nil { + t.Logf("Streaming response error: %v", streamResponse.BifrostError) + } + + if streamResponse.BifrostChatResponse != nil { + for _, choice := range streamResponse.BifrostChatResponse.Choices { + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta.Content != nil { + content := *choice.ChatStreamResponseChoice.Delta.Content + if content != "" { + t.Logf("Per-request chunk %d: %s", responseCount, content) + } + } + } + } + } + + t.Logf("Per-request stream completed after %d responses", responseCount) + } else { + t.Logf("No streaming response channel received for per-request test") + } + + t.Log("Per-request test completed - check logs for JSON parsing behavior") +} + +func TestParsePartialJSON(t *testing.T) { + plugin, err := Init(PluginConfig{ + Usage: AllRequests, + CleanupInterval: 5 * time.Minute, + MaxAge: 30 * time.Minute, + }) + if err != nil { + t.Fatalf("Error initializing JSON parser plugin: %v", err) + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "Already valid JSON object", + input: `{"name": "John", "age": 30}`, + expected: `{"name": "John", "age": 30}`, + }, + { + name: "Partial JSON object missing closing brace", + input: `{"name": "John", "age": 30, "city": "New York"`, + expected: `{"name": "John", "age": 30, "city": "New York"}`, + }, + { + name: "Partial JSON array missing closing bracket", + input: `["apple", "banana", "cherry"`, + expected: `["apple", "banana", "cherry"]`, + }, + { + name: "Nested partial JSON", + input: `{"user": {"name": "John", "details": {"age": 30, "city": "NY"`, + expected: `{"user": {"name": "John", "details": {"age": 30, "city": "NY"}}}`, + }, + { + name: "Partial JSON with string containing newline", + input: `{"message": "Hello\nWorld"`, + expected: `{"message": "Hello\nWorld"}`, + }, + { + name: "Empty string", + input: "", + expected: "{}", + }, + { + name: "Whitespace only", + input: " \n\t ", + expected: "{}", + }, + { + name: "Non-JSON string", + input: "This is not JSON", + expected: "This is not JSON", + }, + { + name: "Partial JSON with escaped quotes", + input: `{"message": "He said \"Hello\""`, + expected: `{"message": "He said \"Hello\""}`, + }, + { + name: "Complex nested structure", + input: `{"data": {"users": [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"`, + expected: `{"data": {"users": [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"}]}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := plugin.parsePartialJSON(tt.input) + if result != tt.expected { + t.Errorf("parsePartialJSON(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/plugins/jsonparser/utils.go b/plugins/jsonparser/utils.go new file mode 100644 index 000000000..dd5f45082 --- /dev/null +++ b/plugins/jsonparser/utils.go @@ -0,0 +1,324 @@ +package jsonparser + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// getRequestID extracts a unique identifier for the request to maintain state +func (p *JsonParserPlugin) getRequestID(ctx *context.Context, result *schemas.BifrostResponse) string { + + // Try to get from result + if result != nil && result.ChatResponse != nil && result.ChatResponse.ID != "" { + return result.ChatResponse.ID + } + + // Try to get from context if not available in result + if ctx != nil { + if requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string); ok && requestID != "" { + return requestID + } + } + + return "" +} + +// shouldRun determines if the plugin should process the request based on usage type +func (p *JsonParserPlugin) shouldRun(ctx *context.Context, requestType schemas.RequestType) bool { + // Run only for chat completion stream requests + if requestType != schemas.ChatCompletionStreamRequest { + return false + } + + switch p.usage { + case AllRequests: + return true + case PerRequest: + // Check if the context contains the plugin-specific key + if ctx != nil { + if value, ok := (*ctx).Value(EnableStreamingJSONParser).(bool); ok { + return value + } + } + return false + default: + return false + } +} + +// accumulateContent adds new content to the accumulated content for a specific request +func (p *JsonParserPlugin) accumulateContent(requestID, newContent string) string { + p.mutex.Lock() + defer p.mutex.Unlock() + + // Get existing accumulated content + existing := p.accumulatedContent[requestID] + + if existing != nil { + // Append to existing builder + existing.Content.WriteString(newContent) + return existing.Content.String() + } else { + // Create new builder + builder := &strings.Builder{} + builder.WriteString(newContent) + p.accumulatedContent[requestID] = &AccumulatedContent{ + Content: builder, + Timestamp: time.Now(), + } + return builder.String() + } +} + +// parsePartialJSON parses a JSON string that may be missing closing braces +func (p *JsonParserPlugin) parsePartialJSON(s string) string { + // Trim whitespace + s = strings.TrimSpace(s) + if s == "" { + return "{}" + } + + // Quick check: if it starts with { or [, it might be JSON + if s[0] != '{' && s[0] != '[' { + return s + } + + // First, try to parse the string as-is (fast path) + if p.isValidJSON(s) { + return s + } + + // Use a more efficient approach: build the completion directly + return p.completeJSON(s) +} + +// completeJSON completes partial JSON with O(n) time complexity +func (p *JsonParserPlugin) completeJSON(s string) string { + // Pre-allocate buffer with estimated capacity + capacity := len(s) + 10 // Estimate max 10 closing characters needed + result := make([]byte, 0, capacity) + + var stack []byte + inString := false + escaped := false + + // Process the string once + for i := 0; i < len(s); i++ { + char := s[i] + result = append(result, char) + + if escaped { + escaped = false + continue + } + + if char == '\\' { + escaped = true + continue + } + + if char == '"' { + inString = !inString + continue + } + + if inString { + continue + } + + switch char { + case '{', '[': + if char == '{' { + stack = append(stack, '}') + } else { + stack = append(stack, ']') + } + case '}', ']': + if len(stack) > 0 && stack[len(stack)-1] == char { + stack = stack[:len(stack)-1] + } + } + } + + // Close any unclosed strings + if inString { + if escaped { + // Remove the trailing backslash + if len(result) > 0 { + result = result[:len(result)-1] + } + } + result = append(result, '"') + } + + // Add closing characters in reverse order + for i := len(stack) - 1; i >= 0; i-- { + result = append(result, stack[i]) + } + + // Validate the result + if p.isValidJSON(string(result)) { + return string(result) + } + + // If still invalid, try progressive truncation (but more efficiently) + return p.progressiveTruncation(s, result) +} + +// progressiveTruncation efficiently tries different truncation points +func (p *JsonParserPlugin) progressiveTruncation(original string, completed []byte) string { + // Try removing characters from the end until we get valid JSON + // Use binary search for better performance + left, right := 0, len(completed) + + for left < right { + mid := (left + right) / 2 + candidate := completed[:mid] + + if p.isValidJSON(string(candidate)) { + left = mid + 1 + } else { + right = mid + } + } + + // Try the best candidate + if left > 0 && p.isValidJSON(string(completed[:left-1])) { + return string(completed[:left-1]) + } + + // Fallback to original + return original +} + +// isValidJSON checks if a string is valid JSON +func (p *JsonParserPlugin) isValidJSON(s string) bool { + // Trim whitespace + s = strings.TrimSpace(s) + + // Empty string after trimming is not valid JSON + if s == "" { + return false + } + + return json.Valid([]byte(s)) +} + +// DEEP COPY METHODS + +// deepCopyBifrostResponse creates a deep copy of BifrostResponse to avoid modifying the original +func (p *JsonParserPlugin) deepCopyBifrostResponse(original *schemas.BifrostResponse) *schemas.BifrostResponse { + if original == nil { + return nil + } + + // Create a new BifrostResponse + result := &schemas.BifrostResponse{} + + // Copy ChatResponse if it exists (this is what we're interested in for the JSON parser) + if original.ChatResponse != nil { + result.ChatResponse = p.deepCopyBifrostChatResponse(original.ChatResponse) + } + + // Copy other response types if they exist (shallow copy since we don't modify them) + result.TextCompletionResponse = original.TextCompletionResponse + result.ResponsesResponse = original.ResponsesResponse + result.ResponsesStreamResponse = original.ResponsesStreamResponse + result.EmbeddingResponse = original.EmbeddingResponse + result.SpeechResponse = original.SpeechResponse + result.SpeechStreamResponse = original.SpeechStreamResponse + result.TranscriptionResponse = original.TranscriptionResponse + result.TranscriptionStreamResponse = original.TranscriptionStreamResponse + + return result +} + +// deepCopyBifrostChatResponse creates a deep copy of BifrostChatResponse +func (p *JsonParserPlugin) deepCopyBifrostChatResponse(original *schemas.BifrostChatResponse) *schemas.BifrostChatResponse { + if original == nil { + return nil + } + + result := &schemas.BifrostChatResponse{ + ID: original.ID, + Created: original.Created, + Model: original.Model, + Object: original.Object, + ServiceTier: original.ServiceTier, + SystemFingerprint: original.SystemFingerprint, + Usage: original.Usage, // Shallow copy - usage shouldn't be modified + ExtraFields: original.ExtraFields, // Shallow copy + } + + // Deep copy Choices slice + if original.Choices != nil { + result.Choices = make([]schemas.BifrostResponseChoice, len(original.Choices)) + for i, choice := range original.Choices { + result.Choices[i] = p.deepCopyBifrostResponseChoice(choice) + } + } + + return result +} + +// deepCopyBifrostResponseChoice creates a deep copy of BifrostResponseChoice +func (p *JsonParserPlugin) deepCopyBifrostResponseChoice(original schemas.BifrostResponseChoice) schemas.BifrostResponseChoice { + result := schemas.BifrostResponseChoice{ + Index: original.Index, + FinishReason: original.FinishReason, + LogProbs: original.LogProbs, + } + + // Deep copy ChatStreamResponseChoice if it exists (this is what we modify) + if original.ChatStreamResponseChoice != nil { + result.ChatStreamResponseChoice = p.deepCopyChatStreamResponseChoice(original.ChatStreamResponseChoice) + } + + // Shallow copy other choice types since we don't modify them + result.ChatNonStreamResponseChoice = original.ChatNonStreamResponseChoice + result.TextCompletionResponseChoice = original.TextCompletionResponseChoice + + return result +} + +// deepCopyChatStreamResponseChoice creates a deep copy of ChatStreamResponseChoice +func (p *JsonParserPlugin) deepCopyChatStreamResponseChoice(original *schemas.ChatStreamResponseChoice) *schemas.ChatStreamResponseChoice { + if original == nil { + return nil + } + + result := &schemas.ChatStreamResponseChoice{} + + // Deep copy Delta pointer if it exists + if original.Delta != nil { + result.Delta = p.deepCopyChatStreamResponseChoiceDelta(original.Delta) + } + + return result +} + +// deepCopyChatStreamResponseChoiceDelta creates a deep copy of ChatStreamResponseChoiceDelta +func (p *JsonParserPlugin) deepCopyChatStreamResponseChoiceDelta(original *schemas.ChatStreamResponseChoiceDelta) *schemas.ChatStreamResponseChoiceDelta { + if original == nil { + return nil + } + + result := &schemas.ChatStreamResponseChoiceDelta{ + Role: original.Role, + Thought: original.Thought, // Shallow copy + Refusal: original.Refusal, // Shallow copy + ToolCalls: original.ToolCalls, // Shallow copy - we don't modify tool calls + } + + // Deep copy Content pointer if it exists (this is what we modify) + if original.Content != nil { + contentCopy := *original.Content + result.Content = &contentCopy + } + + return result +} diff --git a/plugins/jsonparser/version b/plugins/jsonparser/version new file mode 100644 index 000000000..c0ff51de6 --- /dev/null +++ b/plugins/jsonparser/version @@ -0,0 +1 @@ +1.3.28 \ No newline at end of file diff --git a/plugins/logging/changelog.md b/plugins/logging/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/logging/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/logging/go.mod b/plugins/logging/go.mod new file mode 100644 index 000000000..c31fafdaf --- /dev/null +++ b/plugins/logging/go.mod @@ -0,0 +1,109 @@ +module github.com/maximhq/bifrost/plugins/logging + +go 1.24.0 + +toolchain go1.24.3 + +require ( + github.com/bytedance/sonic v1.14.1 + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/plugins/logging/go.sum b/plugins/logging/go.sum new file mode 100644 index 000000000..a01d845b4 --- /dev/null +++ b/plugins/logging/go.sum @@ -0,0 +1,255 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/plugins/logging/main.go b/plugins/logging/main.go new file mode 100644 index 000000000..99d65f158 --- /dev/null +++ b/plugins/logging/main.go @@ -0,0 +1,582 @@ +// Package logging provides a GORM-based logging plugin for Bifrost. +// This plugin stores comprehensive logs of all requests and responses with search, +// filter, and pagination capabilities. +package logging + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/framework/modelcatalog" + "github.com/maximhq/bifrost/framework/streaming" +) + +const ( + PluginName = "logging" +) + +// LogOperation represents the type of logging operation +type LogOperation string + +const ( + LogOperationCreate LogOperation = "create" + LogOperationUpdate LogOperation = "update" + LogOperationStreamUpdate LogOperation = "stream_update" +) + +// UpdateLogData contains data for log entry updates +type UpdateLogData struct { + Status string + TokenUsage *schemas.BifrostLLMUsage + Cost *float64 // Cost in dollars from pricing plugin + ChatOutput *schemas.ChatMessage + ResponsesOutput []schemas.ResponsesMessage + EmbeddingOutput []schemas.EmbeddingData + ErrorDetails *schemas.BifrostError + SpeechOutput *schemas.BifrostSpeechResponse // For non-streaming speech responses + TranscriptionOutput *schemas.BifrostTranscriptionResponse // For non-streaming transcription responses + RawResponse interface{} +} + +// LogMessage represents a message in the logging queue +type LogMessage struct { + Operation LogOperation + RequestID string // Unique ID for the request + ParentRequestID string // Unique ID for the parent request + NumberOfRetries int // Number of retries + FallbackIndex int // Fallback index + SelectedKeyID string // Selected key ID + SelectedKeyName string // Selected key name + VirtualKeyID string // Virtual key ID + VirtualKeyName string // Virtual key name + Timestamp time.Time // Of the preHook/postHook call + Latency int64 // For latency updates + InitialData *InitialLogData // For create operations + SemanticCacheDebug *schemas.BifrostCacheDebug // For semantic cache operations + UpdateData *UpdateLogData // For update operations + StreamResponse *streaming.ProcessedStreamResponse // For streaming delta updates +} + +// InitialLogData contains data for initial log entry creation +type InitialLogData struct { + Provider string + Model string + Object string + InputHistory []schemas.ChatMessage + ResponsesInputHistory []schemas.ResponsesMessage + Params interface{} + SpeechInput *schemas.SpeechInput + TranscriptionInput *schemas.TranscriptionInput + Tools []schemas.ChatTool +} + +// LogCallback is a function that gets called when a new log entry is created +type LogCallback func(*logstore.Log) + +type Config struct { + DisableContentLogging *bool `json:"disable_content_logging"` +} + +// LoggerPlugin implements the schemas.Plugin interface +type LoggerPlugin struct { + ctx context.Context + store logstore.LogStore + disableContentLogging *bool + pricingManager *modelcatalog.ModelCatalog + mu sync.Mutex + done chan struct{} + wg sync.WaitGroup + logger schemas.Logger + logCallback LogCallback + droppedRequests atomic.Int64 + cleanupTicker *time.Ticker // Ticker for cleaning up old processing logs + logMsgPool sync.Pool // Pool for reusing LogMessage structs + updateDataPool sync.Pool // Pool for reusing UpdateLogData structs + accumulator *streaming.Accumulator // Accumulator for streaming chunks +} + +// Init creates new logger plugin with given log store +func Init(ctx context.Context, config *Config, logger schemas.Logger, logsStore logstore.LogStore, pricingManager *modelcatalog.ModelCatalog) (*LoggerPlugin, error) { + if config == nil { + return nil, fmt.Errorf("config is required") + } + if logsStore == nil { + return nil, fmt.Errorf("logs store cannot be nil") + } + if pricingManager == nil { + logger.Warn("logging plugin requires model catalog to calculate cost, all cost calculations will be skipped.") + } + + plugin := &LoggerPlugin{ + ctx: ctx, + store: logsStore, + pricingManager: pricingManager, + disableContentLogging: config.DisableContentLogging, + done: make(chan struct{}), + logger: logger, + logMsgPool: sync.Pool{ + New: func() interface{} { + return &LogMessage{} + }, + }, + updateDataPool: sync.Pool{ + New: func() interface{} { + return &UpdateLogData{} + }, + }, + accumulator: streaming.NewAccumulator(pricingManager, logger), + } + + // Prewarm the pools for better performance at startup + for range 1000 { + plugin.logMsgPool.Put(&LogMessage{}) + plugin.updateDataPool.Put(&UpdateLogData{}) + } + + // Start cleanup ticker (runs every 1 minute) + plugin.cleanupTicker = time.NewTicker(1 * time.Minute) + plugin.wg.Add(1) + go plugin.cleanupWorker() + + return plugin, nil +} + +// cleanupWorker periodically removes old processing logs +func (p *LoggerPlugin) cleanupWorker() { + defer p.wg.Done() + for { + select { + case <-p.cleanupTicker.C: + p.cleanupOldProcessingLogs() + case <-p.done: + return + } + } +} + +// cleanupOldProcessingLogs removes processing logs older than 30 minutes +func (p *LoggerPlugin) cleanupOldProcessingLogs() { + // Calculate timestamp for 30 minutes ago + thirtyMinutesAgo := time.Now().Add(-1 * 30 * time.Minute) + // Delete processing logs older than 30 minutes using the store + if err := p.store.Flush(p.ctx, thirtyMinutesAgo); err != nil { + p.logger.Warn("failed to cleanup old processing logs: %v", err) + } +} + +// SetLogCallback sets a callback function that will be called for each log entry +func (p *LoggerPlugin) SetLogCallback(callback LogCallback) { + p.mu.Lock() + defer p.mu.Unlock() + p.logCallback = callback +} + +// GetName returns the name of the plugin +func (p *LoggerPlugin) GetName() string { + return PluginName +} + +// TransportInterceptor is not used for this plugin +func (p *LoggerPlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// PreHook is called before a request is processed - FULLY ASYNC, NO DATABASE I/O +func (p *LoggerPlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + if ctx == nil { + // Log error but don't fail the request + p.logger.Error("context is nil in PreHook") + return req, nil, nil + } + + // Extract request ID from context + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + // Log error but don't fail the request + p.logger.Error("request-id not found in context or is empty") + return req, nil, nil + } + + createdTimestamp := time.Now() + // If request type is streaming we create a stream accumulator + if bifrost.IsStreamRequestType(req.RequestType) { + p.accumulator.CreateStreamAccumulator(requestID, createdTimestamp) + } + + provider, model, _ := req.GetRequestFields() + + initialData := &InitialLogData{ + Provider: string(provider), + Model: model, + Object: string(req.RequestType), + } + + if p.disableContentLogging == nil || !*p.disableContentLogging { + inputHistory, responsesInputHistory := p.extractInputHistory(req) + initialData.InputHistory = inputHistory + initialData.ResponsesInputHistory = responsesInputHistory + + switch req.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + initialData.Params = req.TextCompletionRequest.Params + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + initialData.Params = req.ChatRequest.Params + initialData.Tools = req.ChatRequest.Params.Tools + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + initialData.Params = req.ResponsesRequest.Params + + var tools []schemas.ChatTool + for _, tool := range req.ResponsesRequest.Params.Tools { + tools = append(tools, *tool.ToChatTool()) + } + initialData.Tools = tools + case schemas.EmbeddingRequest: + initialData.Params = req.EmbeddingRequest.Params + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + initialData.Params = req.SpeechRequest.Params + initialData.SpeechInput = req.SpeechRequest.Input + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + initialData.Params = req.TranscriptionRequest.Params + initialData.TranscriptionInput = req.TranscriptionRequest.Input + } + } + + // Queue the log creation message (non-blocking) - Using sync.Pool + logMsg := p.getLogMessage() + logMsg.Operation = LogOperationCreate + + // If fallback request ID is present, use it instead of the primary request ID + fallbackRequestID, ok := (*ctx).Value(schemas.BifrostContextKeyFallbackRequestID).(string) + if ok && fallbackRequestID != "" { + logMsg.RequestID = fallbackRequestID + logMsg.ParentRequestID = requestID + } else { + logMsg.RequestID = requestID + } + + numberOfRetries := getIntFromContext(*ctx, schemas.BifrostContextKeyNumberOfRetries) + fallbackIndex := getIntFromContext(*ctx, schemas.BifrostContextKeyFallbackIndex) + + logMsg.Timestamp = createdTimestamp + logMsg.InitialData = initialData + logMsg.NumberOfRetries = numberOfRetries + logMsg.FallbackIndex = fallbackIndex + + go func(logMsg *LogMessage) { + defer p.putLogMessage(logMsg) // Return to pool when done + if err := p.insertInitialLogEntry( + p.ctx, + logMsg.RequestID, + logMsg.ParentRequestID, + logMsg.Timestamp, + logMsg.NumberOfRetries, + logMsg.FallbackIndex, + logMsg.InitialData, + ); err != nil { + p.logger.Warn("failed to insert initial log entry for request %s: %v", logMsg.RequestID, err) + } else { + // Call callback for initial log creation (WebSocket "create" message) + // Construct LogEntry directly from data we have to avoid database query + p.mu.Lock() + if p.logCallback != nil { + initialEntry := &logstore.Log{ + ID: logMsg.RequestID, + Timestamp: logMsg.Timestamp, + Object: logMsg.InitialData.Object, + Provider: logMsg.InitialData.Provider, + Model: logMsg.InitialData.Model, + NumberOfRetries: logMsg.NumberOfRetries, + FallbackIndex: logMsg.FallbackIndex, + InputHistoryParsed: logMsg.InitialData.InputHistory, + ResponsesInputHistoryParsed: logMsg.InitialData.ResponsesInputHistory, + ParamsParsed: logMsg.InitialData.Params, + ToolsParsed: logMsg.InitialData.Tools, + Status: "processing", + Stream: false, // Initially false, will be updated if streaming + CreatedAt: logMsg.Timestamp, + } + p.logCallback(initialEntry) + } + p.mu.Unlock() + } + }(logMsg) + + return req, nil, nil +} + +// PostHook is called after a response is received - FULLY ASYNC, NO DATABASE I/O +func (p *LoggerPlugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + if ctx == nil { + // Log error but don't fail the request + p.logger.Error("context is nil in PostHook") + return result, bifrostErr, nil + } + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + p.logger.Error("request-id not found in context or is empty") + return result, bifrostErr, nil + } + // If fallback request ID is present, use it instead of the primary request ID + fallbackRequestID, ok := (*ctx).Value(schemas.BifrostContextKeyFallbackRequestID).(string) + if ok && fallbackRequestID != "" { + requestID = fallbackRequestID + } + selectedKeyID := getStringFromContext(*ctx, schemas.BifrostContextKeySelectedKeyID) + selectedKeyName := getStringFromContext(*ctx, schemas.BifrostContextKeySelectedKeyName) + virtualKeyID := getStringFromContext(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-id")) + virtualKeyName := getStringFromContext(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-name")) + + go func() { + requestType, _, _ := bifrost.GetResponseFields(result, bifrostErr) + // Queue the log update message (non-blocking) - use same pattern for both streaming and regular + logMsg := p.getLogMessage() + logMsg.RequestID = requestID + logMsg.SelectedKeyID = selectedKeyID + logMsg.VirtualKeyID = virtualKeyID + logMsg.SelectedKeyName = selectedKeyName + logMsg.VirtualKeyName = virtualKeyName + defer p.putLogMessage(logMsg) // Return to pool when done + + if result != nil { + logMsg.Latency = result.GetExtraFields().Latency + } else { + logMsg.Latency = 0 + } + + // If response is nil, and there is an error, we update log with error + if result == nil && bifrostErr != nil { + // If request type is streaming, then we trigger cleanup as well + if bifrost.IsStreamRequestType(requestType) { + p.accumulator.CleanupStreamAccumulator(requestID) + } + logMsg.Operation = LogOperationUpdate + logMsg.UpdateData = &UpdateLogData{ + Status: "error", + ErrorDetails: bifrostErr, + } + processingErr := retryOnNotFound(p.ctx, func() error { + return p.updateLogEntry( + p.ctx, + logMsg.RequestID, + logMsg.SelectedKeyID, + logMsg.SelectedKeyName, + logMsg.Latency, + logMsg.VirtualKeyID, + logMsg.VirtualKeyName, + logMsg.SemanticCacheDebug, + logMsg.UpdateData, + ) + }) + if processingErr != nil { + p.logger.Warn("failed to process log update for request %s: %v", logMsg.RequestID, processingErr) + } else { + // Call callback immediately for both streaming and regular updates + // UI will handle debouncing if needed + p.mu.Lock() + if p.logCallback != nil { + if updatedEntry, getErr := p.getLogEntry(p.ctx, logMsg.RequestID); getErr == nil { + p.logCallback(updatedEntry) + } + } + p.mu.Unlock() + } + + return + } + if bifrost.IsStreamRequestType(requestType) { + p.logger.Debug("[logging] processing streaming response") + + streamResponse, err := p.accumulator.ProcessStreamingResponse(ctx, result, bifrostErr) + if err != nil { + p.logger.Debug("failed to process streaming response: %v", err) + } else if streamResponse != nil && streamResponse.Type == streaming.StreamResponseTypeFinal { + // Prepare final log data + logMsg.Operation = LogOperationStreamUpdate + logMsg.StreamResponse = streamResponse + processingErr := retryOnNotFound(p.ctx, func() error { + return p.updateStreamingLogEntry( + p.ctx, + logMsg.RequestID, + logMsg.SelectedKeyID, + logMsg.SelectedKeyName, + logMsg.VirtualKeyID, + logMsg.VirtualKeyName, + logMsg.SemanticCacheDebug, + logMsg.StreamResponse, + streamResponse.Type == streaming.StreamResponseTypeFinal, + ) + }) + if processingErr != nil { + p.logger.Warn("failed to process stream update for request %s: %v", logMsg.RequestID, processingErr) + } else { + // Call callback immediately for both streaming and regular updates + // UI will handle debouncing if needed + p.mu.Lock() + if p.logCallback != nil { + if updatedEntry, getErr := p.getLogEntry(p.ctx, logMsg.RequestID); getErr == nil { + p.logCallback(updatedEntry) + } + } + p.mu.Unlock() + } + } + } else { + // Handle regular response + logMsg.Operation = LogOperationUpdate + // Prepare update data (latency will be calculated in background worker) + updateData := p.getUpdateLogData() + if bifrostErr != nil { + // Error case + updateData.Status = "error" + updateData.ErrorDetails = bifrostErr + } else if result != nil { + // Success case + updateData.Status = "success" + // Token usage + var usage *schemas.BifrostLLMUsage + switch { + case result.TextCompletionResponse != nil && result.TextCompletionResponse.Usage != nil: + usage = result.TextCompletionResponse.Usage + case result.ChatResponse != nil && result.ChatResponse.Usage != nil: + usage = result.ChatResponse.Usage + case result.ResponsesResponse != nil && result.ResponsesResponse.Usage != nil: + usage = result.ResponsesResponse.Usage.ToBifrostLLMUsage() + case result.EmbeddingResponse != nil && result.EmbeddingResponse.Usage != nil: + usage = result.EmbeddingResponse.Usage + case result.TranscriptionResponse != nil && result.TranscriptionResponse.Usage != nil: + usage = &schemas.BifrostLLMUsage{} + if result.TranscriptionResponse.Usage.InputTokens != nil { + usage.PromptTokens = *result.TranscriptionResponse.Usage.InputTokens + } + if result.TranscriptionResponse.Usage.OutputTokens != nil { + usage.CompletionTokens = *result.TranscriptionResponse.Usage.OutputTokens + } + if result.TranscriptionResponse.Usage.TotalTokens != nil { + usage.TotalTokens = *result.TranscriptionResponse.Usage.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + } + updateData.TokenUsage = usage + // Extract raw response + extraFields := result.GetExtraFields() + if p.disableContentLogging == nil || !*p.disableContentLogging { + if extraFields.RawResponse != nil { + updateData.RawResponse = extraFields.RawResponse + } + if result.TextCompletionResponse != nil { + if len(result.TextCompletionResponse.Choices) > 0 { + choice := result.TextCompletionResponse.Choices[0] + if choice.TextCompletionResponseChoice != nil { + updateData.ChatOutput = &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: choice.TextCompletionResponseChoice.Text, + }, + } + } + } + } + if result.ChatResponse != nil { + // Output message and tool calls + if len(result.ChatResponse.Choices) > 0 { + choice := result.ChatResponse.Choices[0] + // Check if this is a non-stream response choice + if choice.ChatNonStreamResponseChoice != nil { + updateData.ChatOutput = choice.ChatNonStreamResponseChoice.Message + } + } + } + if result.ResponsesResponse != nil { + updateData.ResponsesOutput = result.ResponsesResponse.Output + } + if result.EmbeddingResponse != nil && len(result.EmbeddingResponse.Data) > 0 { + updateData.EmbeddingOutput = result.EmbeddingResponse.Data + } + // Handle speech and transcription outputs for NON-streaming responses + if result.SpeechResponse != nil { + updateData.SpeechOutput = result.SpeechResponse + } + if result.TranscriptionResponse != nil { + updateData.TranscriptionOutput = result.TranscriptionResponse + } + } + } + logMsg.UpdateData = updateData + + // Return pooled data structures to their respective pools + defer func() { + if logMsg.UpdateData != nil { + p.putUpdateLogData(logMsg.UpdateData) + } + }() + if result != nil { + logMsg.SemanticCacheDebug = result.GetExtraFields().CacheDebug + } + if logMsg.UpdateData != nil && p.pricingManager != nil { + cost := p.pricingManager.CalculateCostWithCacheDebug(result) + logMsg.UpdateData.Cost = &cost + } + // Here we pass plugin level context for background processing to avoid context cancellation + processingErr := retryOnNotFound(p.ctx, func() error { + return p.updateLogEntry( + p.ctx, + logMsg.RequestID, + logMsg.SelectedKeyID, + logMsg.SelectedKeyName, + logMsg.Latency, + logMsg.VirtualKeyID, + logMsg.VirtualKeyName, + logMsg.SemanticCacheDebug, + logMsg.UpdateData, + ) + }) + if processingErr != nil { + p.logger.Warn("failed to process log update for request %s: %v", logMsg.RequestID, processingErr) + } else { + // Call callback immediately for both streaming and regular updates + // UI will handle debouncing if needed + p.mu.Lock() + if p.logCallback != nil { + if updatedEntry, getErr := p.getLogEntry(p.ctx, logMsg.RequestID); getErr == nil { + updatedEntry.SelectedKey = &schemas.Key{ + ID: updatedEntry.SelectedKeyID, + Name: updatedEntry.SelectedKeyName, + } + if updatedEntry.VirtualKeyID != nil && updatedEntry.VirtualKeyName != nil { + updatedEntry.VirtualKey = &tables.TableVirtualKey{ + ID: *updatedEntry.VirtualKeyID, + Name: *updatedEntry.VirtualKeyName, + } + } + p.logCallback(updatedEntry) + } + } + p.mu.Unlock() + } + } + }() + return result, bifrostErr, nil +} + +// Cleanup is called when the plugin is being shut down +func (p *LoggerPlugin) Cleanup() error { + // Stop the cleanup ticker + if p.cleanupTicker != nil { + p.cleanupTicker.Stop() + } + // Signal the background worker to stop + close(p.done) + // Wait for the background worker to finish processing remaining items + p.wg.Wait() + p.accumulator.Cleanup() + // GORM handles connection cleanup automatically + return nil +} diff --git a/plugins/logging/operations.go b/plugins/logging/operations.go new file mode 100644 index 000000000..b9798fa54 --- /dev/null +++ b/plugins/logging/operations.go @@ -0,0 +1,396 @@ +// Package logging provides database operations for the GORM-based logging plugin +package logging + +import ( + "context" + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/framework/streaming" +) + +// insertInitialLogEntry creates a new log entry in the database using GORM +func (p *LoggerPlugin) insertInitialLogEntry( + ctx context.Context, + requestID string, + parentRequestID string, + timestamp time.Time, + numberOfRetries int, + fallbackIndex int, + data *InitialLogData, +) error { + entry := &logstore.Log{ + ID: requestID, + Timestamp: timestamp, + Object: data.Object, + Provider: data.Provider, + Model: data.Model, + NumberOfRetries: numberOfRetries, + FallbackIndex: fallbackIndex, + Status: "processing", + Stream: false, + CreatedAt: timestamp, + // Set parsed fields for serialization + InputHistoryParsed: data.InputHistory, + ResponsesInputHistoryParsed: data.ResponsesInputHistory, + ParamsParsed: data.Params, + ToolsParsed: data.Tools, + SpeechInputParsed: data.SpeechInput, + TranscriptionInputParsed: data.TranscriptionInput, + } + + if parentRequestID != "" { + entry.ParentRequestID = &parentRequestID + } + + return p.store.Create(ctx, entry) +} + +// updateLogEntry updates an existing log entry using GORM +func (p *LoggerPlugin) updateLogEntry( + ctx context.Context, + requestID string, + selectedKeyID string, + selectedKeyName string, + latency int64, + virtualKeyID string, + virtualKeyName string, + cacheDebug *schemas.BifrostCacheDebug, + data *UpdateLogData, +) error { + updates := make(map[string]interface{}) + updates["selected_key_id"] = selectedKeyID + updates["selected_key_name"] = selectedKeyName + if latency != 0 { + updates["latency"] = float64(latency) + } + updates["status"] = data.Status + if virtualKeyID != "" { + updates["virtual_key_id"] = virtualKeyID + } + if virtualKeyName != "" { + updates["virtual_key_name"] = virtualKeyName + } + // Handle JSON fields by setting them on a temporary entry and serializing + tempEntry := &logstore.Log{} + if data.ChatOutput != nil { + tempEntry.OutputMessageParsed = data.ChatOutput + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize output message: %v", err) + } else { + updates["output_message"] = tempEntry.OutputMessage + updates["content_summary"] = tempEntry.ContentSummary // Update content summary + } + } + + if p.disableContentLogging == nil || !*p.disableContentLogging { + if data.ResponsesOutput != nil { + tempEntry.ResponsesOutputParsed = data.ResponsesOutput + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize responses output: %v", err) + } else { + updates["responses_output"] = tempEntry.ResponsesOutput + } + } + + if data.EmbeddingOutput != nil { + tempEntry.EmbeddingOutputParsed = data.EmbeddingOutput + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize embedding output: %v", err) + } else { + updates["embedding_output"] = tempEntry.EmbeddingOutput + } + } + + if data.SpeechOutput != nil { + tempEntry.SpeechOutputParsed = data.SpeechOutput + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize speech output: %v", err) + } else { + updates["speech_output"] = tempEntry.SpeechOutput + } + } + + if data.TranscriptionOutput != nil { + tempEntry.TranscriptionOutputParsed = data.TranscriptionOutput + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize transcription output: %v", err) + } else { + updates["transcription_output"] = tempEntry.TranscriptionOutput + } + } + } + + if data.TokenUsage != nil { + tempEntry.TokenUsageParsed = data.TokenUsage + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize token usage: %v", err) + } else { + updates["token_usage"] = tempEntry.TokenUsage + updates["prompt_tokens"] = data.TokenUsage.PromptTokens + updates["completion_tokens"] = data.TokenUsage.CompletionTokens + updates["total_tokens"] = data.TokenUsage.TotalTokens + } + } + + // Handle cost from pricing plugin + if data.Cost != nil { + updates["cost"] = *data.Cost + } + + // Handle cache debug + if cacheDebug != nil { + tempEntry.CacheDebugParsed = cacheDebug + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize cache debug: %v", err) + } else { + updates["cache_debug"] = tempEntry.CacheDebug + } + } + + if data.ErrorDetails != nil { + tempEntry.ErrorDetailsParsed = data.ErrorDetails + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize error details: %v", err) + } else { + updates["error_details"] = tempEntry.ErrorDetails + } + } + + if p.disableContentLogging == nil || !*p.disableContentLogging && data.RawResponse != nil { + rawResponseBytes, err := sonic.Marshal(data.RawResponse) + if err != nil { + p.logger.Error("failed to marshal raw response: %v", err) + } else { + updates["raw_response"] = string(rawResponseBytes) + } + } + + return p.store.Update(ctx, requestID, updates) +} + +// updateStreamingLogEntry handles streaming updates using GORM +func (p *LoggerPlugin) updateStreamingLogEntry( + ctx context.Context, + requestID string, + selectedKeyID string, + selectedKeyName string, + virtualKeyID string, + virtualKeyName string, + cacheDebug *schemas.BifrostCacheDebug, + streamResponse *streaming.ProcessedStreamResponse, + isFinalChunk bool, +) error { + p.logger.Debug("[logging] updating streaming log entry %s", requestID) + updates := make(map[string]interface{}) + updates["selected_key_id"] = selectedKeyID + updates["selected_key_name"] = selectedKeyName + if virtualKeyID != "" { + updates["virtual_key_id"] = virtualKeyID + } + if virtualKeyName != "" { + updates["virtual_key_name"] = virtualKeyName + } + // Handle error case first + if streamResponse.Data.ErrorDetails != nil { + tempEntry := &logstore.Log{} + tempEntry.ErrorDetailsParsed = streamResponse.Data.ErrorDetails + if err := tempEntry.SerializeFields(); err != nil { + return fmt.Errorf("failed to serialize error details: %w", err) + } + return p.store.Update(ctx, requestID, map[string]interface{}{ + "status": "error", + "latency": float64(streamResponse.Data.Latency), + "error_details": tempEntry.ErrorDetails, + }) + } + + // Always mark as streaming and update timestamp + updates["stream"] = true + + // Calculate latency when stream finishes + tempEntry := &logstore.Log{} + + updates["latency"] = float64(streamResponse.Data.Latency) + + // Update model if provided + if streamResponse.Data.Model != "" { + updates["model"] = streamResponse.Data.Model + } + + // Update token usage if provided + if streamResponse.Data.TokenUsage != nil { + tempEntry.TokenUsageParsed = streamResponse.Data.TokenUsage + if err := tempEntry.SerializeFields(); err == nil { + updates["token_usage"] = tempEntry.TokenUsage + updates["prompt_tokens"] = streamResponse.Data.TokenUsage.PromptTokens + updates["completion_tokens"] = streamResponse.Data.TokenUsage.CompletionTokens + updates["total_tokens"] = streamResponse.Data.TokenUsage.TotalTokens + } + } + + // Handle cost from pricing plugin + if streamResponse.Data.Cost != nil { + updates["cost"] = *streamResponse.Data.Cost + } + // Handle finish reason - if present, mark as complete + if isFinalChunk { + updates["status"] = "success" + } + + if p.disableContentLogging == nil || !*p.disableContentLogging { + // Handle transcription output from stream updates + if streamResponse.Data.TranscriptionOutput != nil { + tempEntry.TranscriptionOutputParsed = streamResponse.Data.TranscriptionOutput + // Here we just log error but move one vs breaking the entire logging flow + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize transcription output: %v", err) + } else { + updates["transcription_output"] = tempEntry.TranscriptionOutput + } + } + // Handle speech output from stream updates + if streamResponse.Data.AudioOutput != nil { + tempEntry.SpeechOutputParsed = streamResponse.Data.AudioOutput + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize speech output: %v", err) + } else { + updates["speech_output"] = tempEntry.SpeechOutput + } + } + // Handle cache debug + if cacheDebug != nil { + tempEntry.CacheDebugParsed = cacheDebug + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize cache debug: %v", err) + } else { + updates["cache_debug"] = tempEntry.CacheDebug + } + } + // Create content summary + if streamResponse.Data.OutputMessage != nil { + tempEntry.OutputMessageParsed = streamResponse.Data.OutputMessage + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize output message: %v", err) + } else { + updates["output_message"] = tempEntry.OutputMessage + updates["content_summary"] = tempEntry.ContentSummary + } + } + // Handle responses output from stream updates + if streamResponse.Data.OutputMessages != nil { + tempEntry.ResponsesOutputParsed = streamResponse.Data.OutputMessages + if err := tempEntry.SerializeFields(); err != nil { + p.logger.Error("failed to serialize responses output: %v", err) + } else { + updates["responses_output"] = tempEntry.ResponsesOutput + } + } + } + // Only perform update if there's something to update + if len(updates) > 0 { + return p.store.Update(ctx, requestID, updates) + } + return nil +} + +// getLogEntry retrieves a log entry by ID using GORM +func (p *LoggerPlugin) getLogEntry(ctx context.Context, requestID string) (*logstore.Log, error) { + entry, err := p.store.FindFirst(ctx, map[string]interface{}{"id": requestID}) + if err != nil { + return nil, err + } + return entry, nil +} + +// SearchLogs searches logs with filters and pagination using GORM +func (p *LoggerPlugin) SearchLogs(ctx context.Context, filters logstore.SearchFilters, pagination logstore.PaginationOptions) (*logstore.SearchResult, error) { + // Set default pagination if not provided + if pagination.Limit == 0 { + pagination.Limit = 50 + } + if pagination.SortBy == "" { + pagination.SortBy = "timestamp" + } + if pagination.Order == "" { + pagination.Order = "desc" + } + // Build base query with all filters applied + return p.store.SearchLogs(ctx, filters, pagination) +} + +// GetAvailableModels returns all unique models from logs +func (p *LoggerPlugin) GetAvailableModels(ctx context.Context) []string { + result, err := p.store.FindAll(ctx, "model IS NOT NULL AND model != ''", "model") + if err != nil { + p.logger.Error("failed to get available models: %w", err) + return []string{} + } + return p.extractUniqueStrings(result, func(log *logstore.Log) string { return log.Model }) +} + +func (p *LoggerPlugin) GetAvailableSelectedKeys(ctx context.Context) []KeyPair { + result, err := p.store.FindAll(ctx, "selected_key_id IS NOT NULL AND selected_key_id != '' AND selected_key_name IS NOT NULL AND selected_key_name != ''", "selected_key_id, selected_key_name") + if err != nil { + p.logger.Error("failed to get available selected keys: %w", err) + return []KeyPair{} + } + return p.extractUniqueKeyPairs(result, func(log *logstore.Log) KeyPair { + return KeyPair{ + ID: log.SelectedKeyID, + Name: log.SelectedKeyName, + } + }) +} + +func (p *LoggerPlugin) GetAvailableVirtualKeys(ctx context.Context) []KeyPair { + result, err := p.store.FindAll(ctx, "virtual_key_id IS NOT NULL AND virtual_key_id != '' AND virtual_key_name IS NOT NULL AND virtual_key_name != ''", "virtual_key_id, virtual_key_name") + if err != nil { + p.logger.Error("failed to get available virtual keys: %w", err) + return []KeyPair{} + } + return p.extractUniqueKeyPairs(result, func(log *logstore.Log) KeyPair { + if log.VirtualKeyID != nil && log.VirtualKeyName != nil { + return KeyPair{ + ID: *log.VirtualKeyID, + Name: *log.VirtualKeyName, + } + } + return KeyPair{} + }) +} + +// extractUniqueKeyPairs extracts unique non-empty key pairs from logs using the provided extractor function +func (p *LoggerPlugin) extractUniqueKeyPairs(logs []*logstore.Log, extractor func(*logstore.Log) KeyPair) []KeyPair { + uniqueSet := make(map[string]KeyPair) + for _, log := range logs { + pair := extractor(log) + if pair.ID != "" && pair.Name != "" { + uniqueSet[pair.ID] = pair + } + } + + result := make([]KeyPair, 0, len(uniqueSet)) + for _, pair := range uniqueSet { + result = append(result, pair) + } + return result +} + +// extractUniqueStrings extracts unique non-empty string values from logs using the provided extractor function +func (p *LoggerPlugin) extractUniqueStrings(logs []*logstore.Log, extractor func(*logstore.Log) string) []string { + uniqueSet := make(map[string]bool) + for _, log := range logs { + if value := extractor(log); value != "" { + uniqueSet[value] = true + } + } + result := make([]string, 0, len(uniqueSet)) + for value := range uniqueSet { + result = append(result, value) + } + return result +} diff --git a/plugins/logging/pool.go b/plugins/logging/pool.go new file mode 100644 index 000000000..b054f649a --- /dev/null +++ b/plugins/logging/pool.go @@ -0,0 +1,46 @@ +package logging + +import ( + "time" +) + +// getLogMessage gets a LogMessage from the pool +func (p *LoggerPlugin) getLogMessage() *LogMessage { + return p.logMsgPool.Get().(*LogMessage) +} + +// putLogMessage returns a LogMessage to the pool after resetting it +func (p *LoggerPlugin) putLogMessage(msg *LogMessage) { + // Reset the message fields to avoid memory leaks + msg.Operation = "" + msg.RequestID = "" + msg.Timestamp = time.Time{} + msg.InitialData = nil + + // Don't reset UpdateData and StreamResponse here since they're returned + // to their own pools in the defer function - just clear the pointers + msg.UpdateData = nil + msg.StreamResponse = nil + + p.logMsgPool.Put(msg) +} + +// getUpdateLogData gets an UpdateLogData from the pool +func (p *LoggerPlugin) getUpdateLogData() *UpdateLogData { + return p.updateDataPool.Get().(*UpdateLogData) +} + +// putUpdateLogData returns an UpdateLogData to the pool after resetting it +func (p *LoggerPlugin) putUpdateLogData(data *UpdateLogData) { + // Reset all fields to avoid memory leaks + data.Status = "" + data.TokenUsage = nil + data.ChatOutput = nil + data.ResponsesOutput = nil + data.ErrorDetails = nil + data.SpeechOutput = nil + data.TranscriptionOutput = nil + data.EmbeddingOutput = nil + data.Cost = nil + p.updateDataPool.Put(data) +} diff --git a/plugins/logging/utils.go b/plugins/logging/utils.go new file mode 100644 index 000000000..0e8945948 --- /dev/null +++ b/plugins/logging/utils.go @@ -0,0 +1,184 @@ +// Package logging provides utility functions and interfaces for the GORM-based logging plugin +package logging + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/logstore" +) + +// KeyPair represents an ID-Name pair for keys +type KeyPair struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// LogManager defines the main interface that combines all logging functionality +type LogManager interface { + // Search searches for log entries based on filters and pagination + Search(ctx context.Context, filters *logstore.SearchFilters, pagination *logstore.PaginationOptions) (*logstore.SearchResult, error) + + // Get the number of dropped requests + GetDroppedRequests(ctx context.Context) int64 + + // GetAvailableModels returns all unique models from logs + GetAvailableModels(ctx context.Context) []string + + // GetAvailableSelectedKeys returns all unique selected key ID-Name pairs from logs + GetAvailableSelectedKeys(ctx context.Context) []KeyPair + + // GetAvailableVirtualKeys returns all unique virtual key ID-Name pairs from logs + GetAvailableVirtualKeys(ctx context.Context) []KeyPair +} + +// PluginLogManager implements LogManager interface wrapping the plugin +type PluginLogManager struct { + plugin *LoggerPlugin +} + +func (p *PluginLogManager) Search(ctx context.Context, filters *logstore.SearchFilters, pagination *logstore.PaginationOptions) (*logstore.SearchResult, error) { + if filters == nil || pagination == nil { + return nil, fmt.Errorf("filters and pagination cannot be nil") + } + return p.plugin.SearchLogs(ctx, *filters, *pagination) +} + +func (p *PluginLogManager) GetDroppedRequests(ctx context.Context) int64 { + return p.plugin.droppedRequests.Load() +} + +// GetAvailableModels returns all unique models from logs +func (p *PluginLogManager) GetAvailableModels(ctx context.Context) []string { + return p.plugin.GetAvailableModels(ctx) +} + +// GetAvailableSelectedKeys returns all unique selected key ID-Name pairs from logs +func (p *PluginLogManager) GetAvailableSelectedKeys(ctx context.Context) []KeyPair { + return p.plugin.GetAvailableSelectedKeys(ctx) +} + +// GetAvailableVirtualKeys returns all unique virtual key ID-Name pairs from logs +func (p *PluginLogManager) GetAvailableVirtualKeys(ctx context.Context) []KeyPair { + return p.plugin.GetAvailableVirtualKeys(ctx) +} + +// GetPluginLogManager returns a LogManager interface for this plugin +func (p *LoggerPlugin) GetPluginLogManager() *PluginLogManager { + return &PluginLogManager{ + plugin: p, + } +} + +// retryOnNotFound retries a function up to 3 times with 1-second delays if it returns logstore.ErrNotFound +func retryOnNotFound(ctx context.Context, operation func() error) error { + const maxRetries = 3 + const retryDelay = time.Second + + var lastErr error + for attempt := range maxRetries { + err := operation() + if err == nil { + return nil + } + + // Check if the error is logstore.ErrNotFound + if !errors.Is(err, logstore.ErrNotFound) { + return err + } + + lastErr = err + + // Don't wait after the last attempt + if attempt < maxRetries-1 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryDelay): + // Continue to next retry + } + } + } + + return lastErr +} + +// extractInputHistory extracts input history from request input +func (p *LoggerPlugin) extractInputHistory(request *schemas.BifrostRequest) ([]schemas.ChatMessage, []schemas.ResponsesMessage) { + if request.ChatRequest != nil { + return request.ChatRequest.Input, []schemas.ResponsesMessage{} + } + if request.ResponsesRequest != nil && len(request.ResponsesRequest.Input) > 0 { + return []schemas.ChatMessage{}, request.ResponsesRequest.Input + } + if request.TextCompletionRequest != nil { + var text string + if request.TextCompletionRequest.Input.PromptStr != nil { + text = *request.TextCompletionRequest.Input.PromptStr + } else { + var stringBuilder strings.Builder + for _, prompt := range request.TextCompletionRequest.Input.PromptArray { + stringBuilder.WriteString(prompt) + } + text = stringBuilder.String() + } + return []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: &text, + }, + }, + }, []schemas.ResponsesMessage{} + } + if request.EmbeddingRequest != nil { + texts := request.EmbeddingRequest.Input.Texts + + if len(texts) == 0 && request.EmbeddingRequest.Input.Text != nil { + texts = []string{*request.EmbeddingRequest.Input.Text} + } + + contentBlocks := make([]schemas.ChatContentBlock, len(texts)) + for i, text := range texts { + // Create a per-iteration copy to avoid reusing the same memory address + t := text + contentBlocks[i] = schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: &t, + } + } + return []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: contentBlocks, + }, + }, + }, []schemas.ResponsesMessage{} + } + return []schemas.ChatMessage{}, []schemas.ResponsesMessage{} +} + +// getStringFromContext safely extracts a string value from context +func getStringFromContext(ctx context.Context, key any) string { + if value := ctx.Value(key); value != nil { + if str, ok := value.(string); ok { + return str + } + } + return "" +} + +// getIntFromContext safely extracts an int value from context +func getIntFromContext(ctx context.Context, key any) int { + if value := ctx.Value(key); value != nil { + if intVal, ok := value.(int); ok { + return intVal + } + } + return 0 +} diff --git a/plugins/logging/version b/plugins/logging/version new file mode 100644 index 000000000..5574de9b7 --- /dev/null +++ b/plugins/logging/version @@ -0,0 +1 @@ +1.3.28 diff --git a/plugins/maxim-sdk.go b/plugins/maxim-sdk.go deleted file mode 100644 index c70ad59e7..000000000 --- a/plugins/maxim-sdk.go +++ /dev/null @@ -1,128 +0,0 @@ -// Package plugins provides plugins for the Bifrost system. -// This file contains the Plugin implementation using maxim's logger plugin for bifrost. -package plugins - -import ( - "context" - "fmt" - "time" - - "github.com/maximhq/bifrost/core/schemas" - - "github.com/maximhq/maxim-go" - "github.com/maximhq/maxim-go/logging" -) - -// NewMaximLogger initializes and returns a Plugin instance for Maxim's logger. -// -// Parameters: -// - apiKey: API key for Maxim SDK authentication -// - loggerId: ID for the Maxim logger instance -// -// Returns: -// - schemas.Plugin: A configured plugin instance for request/response tracing -// - error: Any error that occurred during plugin initialization -func NewMaximLoggerPlugin(apiKey string, loggerId string) (schemas.Plugin, error) { - // check if Maxim Logger variables are set - if apiKey == "" { - return nil, fmt.Errorf("apiKey is not set") - } - - if loggerId == "" { - return nil, fmt.Errorf("loggerId is not set") - } - - mx := maxim.Init(&maxim.MaximSDKConfig{ApiKey: apiKey}) - - logger, err := mx.GetLogger(&logging.LoggerConfig{Id: loggerId}) - if err != nil { - return nil, err - } - - plugin := &Plugin{logger} - - return plugin, nil -} - -// contextKey is a custom type for context keys to prevent key collisions in the context. -// It provides type safety for context values and ensures that context keys are unique -// across different packages. -type contextKey string - -// traceIDKey is the context key used to store and retrieve trace IDs. -// This constant provides a consistent key for tracking request traces -// throughout the request/response lifecycle. -const ( - traceIDKey contextKey = "traceID" -) - -// Plugin implements the schemas.Plugin interface for Maxim's logger. -// It provides request and response tracing functionality using the Maxim logger, -// allowing detailed tracking of requests and responses. -// -// Fields: -// - logger: A Maxim logger instance used for tracing requests and responses -type Plugin struct { - logger *logging.Logger -} - -// PreHook is called before a request is processed by Bifrost. -// It creates a new trace for the incoming request and stores the trace ID in the context. -// The trace includes request details that can be used for debugging and monitoring. -// -// Parameters: -// - ctx: Pointer to the context.Context that will store the trace ID -// - req: The incoming Bifrost request to be traced -// -// Returns: -// - *schemas.BifrostRequest: The original request, unmodified -// - error: Always returns nil as this implementation doesn't produce errors -// -// The trace ID format is "YYYYMMDD_HHmmssSSS" based on the current time. -// If the context is nil, tracing information will still be logged but not stored in context. -func (plugin *Plugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, error) { - traceID := time.Now().Format("20060102_150405000") - - trace := plugin.logger.Trace(&logging.TraceConfig{ - Id: traceID, - Name: maxim.StrPtr("bifrost"), - }) - - trace.SetInput(fmt.Sprintf("New Request Incoming: %v", req)) - - if ctx != nil { - // Store traceID in context - *ctx = context.WithValue(*ctx, traceIDKey, traceID) - } - - return req, nil -} - -// PostHook is called after a request has been processed by Bifrost. -// It retrieves the trace ID from the context and logs the response details. -// This completes the request trace by adding response information. -// -// Parameters: -// - ctxRef: Pointer to the context.Context containing the trace ID -// - res: The Bifrost response to be traced -// -// Returns: -// - *schemas.BifrostResponse: The original response, unmodified -// - error: Returns an error if the trace ID cannot be retrieved from the context -// -// If the context is nil or the trace ID is not found, an error will be returned -// but the response will still be passed through unmodified. -func (plugin *Plugin) PostHook(ctxRef *context.Context, res *schemas.BifrostResponse) (*schemas.BifrostResponse, error) { - // Get traceID from context - if ctxRef != nil { - ctx := *ctxRef - traceID, ok := ctx.Value(traceIDKey).(string) - if !ok { - return res, fmt.Errorf("traceID not found in context") - } - - plugin.logger.SetTraceOutput(traceID, fmt.Sprintf("Response: %v", res)) - } - - return res, nil -} diff --git a/plugins/maxim/changelog.md b/plugins/maxim/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/maxim/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/maxim/go.mod b/plugins/maxim/go.mod new file mode 100644 index 000000000..3dce9c677 --- /dev/null +++ b/plugins/maxim/go.mod @@ -0,0 +1,111 @@ +module github.com/maximhq/bifrost/plugins/maxim + +go 1.24.1 + +toolchain go1.24.3 + +require ( + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 + github.com/maximhq/maxim-go v0.1.14 +) + +require github.com/google/uuid v1.6.0 + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/plugins/maxim/go.sum b/plugins/maxim/go.sum new file mode 100644 index 000000000..3d28eddfb --- /dev/null +++ b/plugins/maxim/go.sum @@ -0,0 +1,257 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/maximhq/maxim-go v0.1.14 h1:NQgpf3aRoD2Kq1GAqeSrLn3rQresn1H6mPP3JJ85qhA= +github.com/maximhq/maxim-go v0.1.14/go.mod h1:0+UTWM7UZwNNE5VnljLtr/vpRGtYP8r/2q9WDwlLWFw= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/plugins/maxim/main.go b/plugins/maxim/main.go new file mode 100644 index 000000000..a0aa6f017 --- /dev/null +++ b/plugins/maxim/main.go @@ -0,0 +1,555 @@ +// Package maxim provides integration for Maxim's SDK as a Bifrost plugin. +// This file contains the main plugin implementation. +package maxim + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/streaming" + + "github.com/maximhq/maxim-go" + "github.com/maximhq/maxim-go/logging" +) + +// PluginName is the canonical name for the maxim plugin. +const ( + PluginName string = "maxim" + PluginLoggerPrefix string = "[Maxim Plugin]" +) + +// Config is the configuration for the maxim plugin. +// - APIKey: API key for Maxim SDK authentication +// - LogRepoID: Optional default ID for the Maxim logger instance +type Config struct { + LogRepoID string `json:"log_repo_id,omitempty"` // Optional - can be empty + APIKey string `json:"api_key"` +} + +// Plugin implements the schemas.Plugin interface for Maxim's logger. +// It provides request and response tracing functionality using Maxim logger, +// allowing detailed tracking of requests and responses across different log repositories. +// +// Fields: +// - mx: The Maxim SDK instance for creating new loggers +// - defaultLogRepoId: Default log repository ID from config (optional) +// - loggers: Map of log repo ID to logger instances +// - loggerMutex: RW mutex for thread-safe access to loggers map +type Plugin struct { + mx *maxim.Maxim + defaultLogRepoID string + loggers map[string]*logging.Logger + loggerMutex *sync.RWMutex + accumulator *streaming.Accumulator + logger schemas.Logger +} + +// Init initializes and returns a Plugin instance for Maxim's logger. +// +// Parameters: +// - config: Configuration for the maxim plugin +// +// Returns: +// - schemas.Plugin: A configured plugin instance for request/response tracing +// - error: Any error that occurred during plugin initialization +func Init(config *Config, logger schemas.Logger) (schemas.Plugin, error) { + if config == nil { + return nil, fmt.Errorf("config is required") + } + // check if Maxim Logger variables are set + if config.APIKey == "" { + return nil, fmt.Errorf("apiKey is not set") + } + + mx := maxim.Init(&maxim.MaximSDKConfig{ApiKey: config.APIKey}) + + plugin := &Plugin{ + mx: mx, + defaultLogRepoID: config.LogRepoID, + loggers: make(map[string]*logging.Logger), + loggerMutex: &sync.RWMutex{}, + accumulator: streaming.NewAccumulator(nil, logger), + logger: logger, + } + + // Initialize default logger if LogRepoId is provided + if config.LogRepoID != "" { + logger, err := mx.GetLogger(&logging.LoggerConfig{Id: config.LogRepoID}) + if err != nil { + return nil, fmt.Errorf("failed to initialize default logger: %w", err) + } + plugin.loggers[config.LogRepoID] = logger + } + + return plugin, nil +} + +// TraceIDKey is the context key used to store and retrieve trace IDs. +// This constant provides a consistent key for tracking request traces +// throughout the request/response lifecycle. +const ( + SessionIDKey schemas.BifrostContextKey = "session-id" + TraceIDKey schemas.BifrostContextKey = "trace-id" + TraceNameKey schemas.BifrostContextKey = "trace-name" + GenerationIDKey schemas.BifrostContextKey = "generation-id" + GenerationNameKey schemas.BifrostContextKey = "generation-name" + TagsKey schemas.BifrostContextKey = "maxim-tags" + LogRepoIDKey schemas.BifrostContextKey = "log-repo-id" +) + +// The plugin provides request/response tracing functionality by integrating with Maxim's logging system. +// It supports both chat completion and text completion requests, tracking the entire lifecycle of each request +// including inputs, parameters, and responses. +// +// Key Features: +// - Automatic trace and generation ID management +// - Support for both chat and text completion requests +// - Contextual tracking across request lifecycle +// - Graceful handling of existing trace/generation IDs +// +// The plugin uses context values to maintain trace and generation IDs throughout the request lifecycle. +// These IDs can be propagated from external systems through HTTP headers (x-bf-maxim-trace-id and x-bf-maxim-generation-id). + +// GetName returns the name of the plugin. +func (plugin *Plugin) GetName() string { + return PluginName +} + +// TransportInterceptor is not used for this plugin +func (plugin *Plugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// getEffectiveLogRepoID determines which single log repo ID to use based on priority: +// 1. Header log repo ID (if provided) +// 2. Default log repo ID from config (if configured) +// 3. Empty string (skip logging) +func (plugin *Plugin) getEffectiveLogRepoID(ctx *context.Context) string { + // Check for header log repo ID first (highest priority) + if ctx != nil { + if headerRepoID, ok := (*ctx).Value(LogRepoIDKey).(string); ok && headerRepoID != "" { + return headerRepoID + } + } + + // Fall back to default log repo ID from config + if plugin.defaultLogRepoID != "" { + return plugin.defaultLogRepoID + } + + // Return empty string if neither header nor default is available + return "" +} + +// getOrCreateLogger gets an existing logger or creates a new one for the given log repo ID +func (plugin *Plugin) getOrCreateLogger(logRepoID string) (*logging.Logger, error) { + // First, try to get existing logger (read lock) + plugin.loggerMutex.RLock() + if logger, exists := plugin.loggers[logRepoID]; exists { + plugin.loggerMutex.RUnlock() + return logger, nil + } + plugin.loggerMutex.RUnlock() + + // Logger doesn't exist, create it (write lock) + plugin.loggerMutex.Lock() + defer plugin.loggerMutex.Unlock() + + // Double-check in case another goroutine created it while we were waiting + if logger, exists := plugin.loggers[logRepoID]; exists { + return logger, nil + } + + // Create new logger + logger, err := plugin.mx.GetLogger(&logging.LoggerConfig{Id: logRepoID}) + if err != nil { + return nil, fmt.Errorf("failed to create logger for repo ID %s: %w", logRepoID, err) + } + + plugin.loggers[logRepoID] = logger + return logger, nil +} + +// PreHook is called before a request is processed by Bifrost. +// It manages trace and generation tracking for incoming requests by either: +// - Creating a new trace if none exists +// - Reusing an existing trace ID from the context +// - Creating a new generation within an existing trace +// - Skipping trace/generation creation if they already exist +// +// The function handles both chat completion and text completion requests, +// capturing relevant metadata such as: +// - Request type (chat/text completion) +// - Model information +// - Message content and role +// - Model parameters +// +// Parameters: +// - ctx: Pointer to the context.Context that may contain existing trace/generation IDs +// - req: The incoming Bifrost request to be traced +// +// Returns: +// - *schemas.BifrostRequest: The original request, unmodified +// - error: Any error that occurred during trace/generation creation +func (plugin *Plugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + var traceID string + var traceName string + var sessionID string + var generationName string + var tags map[string]string + + // Get effective log repo ID (header > default > skip) + effectiveLogRepoID := plugin.getEffectiveLogRepoID(ctx) + + // If no log repo ID available, skip logging + if effectiveLogRepoID == "" { + return req, nil, nil + } + + // Check if context already has traceID and generationID + if ctx != nil { + if existingGenerationID, ok := (*ctx).Value(GenerationIDKey).(string); ok && existingGenerationID != "" { + // If generationID exists, return early + return req, nil, nil + } + + if existingTraceID, ok := (*ctx).Value(TraceIDKey).(string); ok && existingTraceID != "" { + // If traceID exists, and no generationID, create a new generation on the trace + traceID = existingTraceID + } + + if existingSessionID, ok := (*ctx).Value(SessionIDKey).(string); ok && existingSessionID != "" { + sessionID = existingSessionID + } + + if existingTraceName, ok := (*ctx).Value(TraceNameKey).(string); ok && existingTraceName != "" { + traceName = existingTraceName + } + + if existingGenerationName, ok := (*ctx).Value(GenerationNameKey).(string); ok && existingGenerationName != "" { + generationName = existingGenerationName + } + + // retrieve all tags from context + // the transport layer now stores all maxim tags in a single map + if tagsValue := (*ctx).Value(TagsKey); tagsValue != nil { + if tagsMap, ok := tagsValue.(map[string]string); ok { + tags = make(map[string]string) + for key, value := range tagsMap { + tags[key] = value + } + } + } + } + + provider, model, _ := req.GetRequestFields() + + // Determine request type and set appropriate tags + var messages []logging.CompletionRequest + var latestMessage string + + // Initialize tags map if not already initialized from context + if tags == nil { + tags = make(map[string]string) + } + + // Add model to tags + tags["model"] = model + + modelParams := make(map[string]interface{}) + + switch req.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + messages = append(messages, logging.CompletionRequest{ + Role: string(schemas.ChatMessageRoleUser), + Content: req.TextCompletionRequest.Input, + }) + if req.TextCompletionRequest.Input.PromptStr != nil { + latestMessage = *req.TextCompletionRequest.Input.PromptStr + } else { + var stringBuilder strings.Builder + for _, prompt := range req.TextCompletionRequest.Input.PromptArray { + stringBuilder.WriteString(prompt) + } + latestMessage = stringBuilder.String() + } + + if req.TextCompletionRequest.Params != nil { + // Convert the struct to a map using reflection or JSON marshaling + jsonData, err := json.Marshal(req.TextCompletionRequest.Params) + if err == nil { + json.Unmarshal(jsonData, &modelParams) + } + } + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + for _, message := range req.ChatRequest.Input { + messages = append(messages, logging.CompletionRequest{ + Role: string(message.Role), + Content: message.Content, + }) + } + if len(req.ChatRequest.Input) > 0 { + lastMsg := req.ChatRequest.Input[len(req.ChatRequest.Input)-1] + if lastMsg.Content.ContentStr != nil { + latestMessage = *lastMsg.Content.ContentStr + } else if lastMsg.Content.ContentBlocks != nil { + // Find the last text content block + for i := len(lastMsg.Content.ContentBlocks) - 1; i >= 0; i-- { + block := (lastMsg.Content.ContentBlocks)[i] + if block.Type == schemas.ChatContentBlockTypeText && block.Text != nil { + latestMessage = *block.Text + break + } + } + // If no text block found, use placeholder + if latestMessage == "" { + latestMessage = "-" + } + } + } + + if req.ChatRequest.Params != nil { + // Convert the struct to a map using reflection or JSON marshaling + jsonData, err := json.Marshal(req.ChatRequest.Params) + if err == nil { + json.Unmarshal(jsonData, &modelParams) + } + } + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + for _, message := range req.ResponsesRequest.Input { + if message.Content != nil { + role := schemas.ChatMessageRoleUser + if message.Role != nil { + role = schemas.ChatMessageRole(*message.Role) + } + messages = append(messages, logging.CompletionRequest{ + Role: string(role), + Content: message.Content, + }) + } + } + if len(req.ResponsesRequest.Input) > 0 { + lastMsg := req.ResponsesRequest.Input[len(req.ResponsesRequest.Input)-1] + // Initialize to placeholder in case content is missing or empty + latestMessage = "-" + + // Check if Content is nil before accessing its fields + if lastMsg.Content != nil { + if lastMsg.Content.ContentStr != nil { + latestMessage = *lastMsg.Content.ContentStr + } else if lastMsg.Content.ContentBlocks != nil { + // Find the last text content block + for i := len(lastMsg.Content.ContentBlocks) - 1; i >= 0; i-- { + block := (lastMsg.Content.ContentBlocks)[i] + if block.Text != nil { + latestMessage = *block.Text + break + } + } + // If no text block found, keep the placeholder + } + } + } + + if req.ResponsesRequest.Params != nil { + // Convert the struct to a map using reflection or JSON marshaling + jsonData, err := json.Marshal(req.ResponsesRequest.Params) + if err == nil { + json.Unmarshal(jsonData, &modelParams) + } + } + } + + if traceID == "" { + // If traceID is not set, create a new trace + traceID = uuid.New().String() + } + + name := fmt.Sprintf("bifrost_%s", string(req.RequestType)) + if traceName != "" { + name = traceName + } + + traceConfig := logging.TraceConfig{ + Id: traceID, + Name: maxim.StrPtr(name), + Tags: &tags, + } + + if sessionID != "" { + traceConfig.SessionId = &sessionID + } + + // Create trace in the effective log repository + logger, err := plugin.getOrCreateLogger(effectiveLogRepoID) + if err != nil { + return req, nil, fmt.Errorf("failed to create trace: %w", err) + } + + trace := logger.Trace(&traceConfig) + trace.SetInput(latestMessage) + generationID := uuid.New().String() + + generationConfig := logging.GenerationConfig{ + Id: generationID, + Model: model, + Provider: string(provider), + Tags: &tags, + Messages: messages, + ModelParameters: modelParams, + } + + if generationName != "" { + generationConfig.Name = &generationName + } + + // Add generation to the effective log repository + logger.AddGenerationToTrace(traceID, &generationConfig) + + var requestID string + if ctx != nil { + if _, ok := (*ctx).Value(TraceIDKey).(string); !ok { + *ctx = context.WithValue(*ctx, TraceIDKey, traceID) + } + *ctx = context.WithValue(*ctx, GenerationIDKey, generationID) + + // Extract request ID from context, if not present, create a new one + var ok bool + requestID, ok = (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + requestID = uuid.New().String() + *ctx = context.WithValue(*ctx, schemas.BifrostContextKeyRequestID, requestID) + } + } + + if bifrost.IsStreamRequestType(req.RequestType) { + plugin.accumulator.CreateStreamAccumulator(requestID, time.Now()) + } + + return req, nil, nil +} + +// PostHook is called after a request has been processed by Bifrost. +// It completes the request trace by: +// - Adding response data to the generation if a generation ID exists +// - Logging error details if bifrostErr is provided +// - Ending the generation if it exists +// - Ending the trace if a trace ID exists +// - Flushing all pending log data +// +// The function gracefully handles cases where trace or generation IDs may be missing, +// ensuring that partial logging is still performed when possible. +// +// Parameters: +// - ctxRef: Pointer to the context.Context containing trace/generation IDs +// - result: The Bifrost response to be traced +// - bifrostErr: The BifrostError returned by the request, if any +// +// Returns: +// - *schemas.BifrostResponse: The original response, unmodified +// - *schemas.BifrostError: The original error, unmodified +// - error: Never returns an error as it handles missing IDs gracefully +func (plugin *Plugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + // Get effective log repo ID for this request + effectiveLogRepoID := plugin.getEffectiveLogRepoID(ctx) + if effectiveLogRepoID == "" { + return result, bifrostErr, nil + } + + requestID, ok := (*ctx).Value(schemas.BifrostContextKeyRequestID).(string) + if !ok || requestID == "" { + return result, bifrostErr, nil + } + + go func() { + requestType, _, _ := bifrost.GetResponseFields(result, bifrostErr) + + var streamResponse *streaming.ProcessedStreamResponse + var err error + if bifrost.IsStreamRequestType(requestType) { + streamResponse, err = plugin.accumulator.ProcessStreamingResponse(ctx, result, bifrostErr) + if err != nil { + plugin.logger.Debug("%s failed to process streaming response: %v", PluginLoggerPrefix, err) + return + } + + // Return the result if it is a delta response + if streamResponse == nil || streamResponse.Type == streaming.StreamResponseTypeDelta { + return + } + } + + logger, err := plugin.getOrCreateLogger(effectiveLogRepoID) + if err != nil { + return + } + generationID, ok := (*ctx).Value(GenerationIDKey).(string) + if ok { + if bifrostErr != nil { + genErr := logging.GenerationError{ + Message: bifrostErr.Error.Message, + Code: bifrostErr.Error.Code, + Type: bifrostErr.Error.Type, + } + logger.SetGenerationError(generationID, &genErr) + + if bifrost.IsStreamRequestType(requestType) { + plugin.accumulator.CleanupStreamAccumulator(requestID) + } + } else if result != nil { + switch requestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + if streamResponse != nil { + logger.AddResultToGeneration(generationID, streamResponse.ToBifrostResponse().TextCompletionResponse) + } else { + logger.AddResultToGeneration(generationID, result.TextCompletionResponse) + } + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + if streamResponse != nil { + logger.AddResultToGeneration(generationID, streamResponse.ToBifrostResponse().ChatResponse) + } else { + logger.AddResultToGeneration(generationID, result.ChatResponse) + } + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + if streamResponse != nil { + logger.AddResultToGeneration(generationID, streamResponse.ToBifrostResponse().ResponsesResponse) + } else { + logger.AddResultToGeneration(generationID, result.ResponsesResponse) + } + } + if streamResponse != nil && streamResponse.Type == streaming.StreamResponseTypeFinal { + plugin.accumulator.CleanupStreamAccumulator(requestID) + } + } + logger.EndGeneration(generationID) + } + traceID, ok := (*ctx).Value(TraceIDKey).(string) + if ok { + logger.EndTrace(traceID) + } + // Flush only the effective logger that was used for this request + logger.Flush() + }() + return result, bifrostErr, nil +} + +func (plugin *Plugin) Cleanup() error { + if plugin.accumulator != nil { + plugin.accumulator.Cleanup() + } + // Flush all loggers + plugin.loggerMutex.RLock() + for _, logger := range plugin.loggers { + logger.Flush() + } + plugin.loggerMutex.RUnlock() + + return nil +} diff --git a/plugins/maxim/plugin_test.go b/plugins/maxim/plugin_test.go new file mode 100644 index 000000000..fe2d702bd --- /dev/null +++ b/plugins/maxim/plugin_test.go @@ -0,0 +1,258 @@ +// Package maxim provides integration for Maxim's SDK as a Bifrost plugin. +// It includes tests for plugin initialization, Bifrost integration, and request/response tracing. +package maxim + +import ( + "context" + "fmt" + "log" + "os" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// getPlugin initializes and returns a Plugin instance for testing purposes. +// It sets up the Maxim logger with configuration from environment variables. +// +// Environment Variables: +// - MAXIM_API_KEY: API key for Maxim SDK authentication +// - MAXIM_LOG_REPO_ID: ID for the Maxim logger instance +// +// Returns: +// - schemas.Plugin: A configured plugin instance for request/response tracing +// - error: Any error that occurred during plugin initialization +func getPlugin() (schemas.Plugin, error) { + // check if Maxim Logger variables are set + if os.Getenv("MAXIM_API_KEY") == "" { + return nil, fmt.Errorf("MAXIM_API_KEY is not set, please set it in your environment variables") + } + + logger := bifrost.NewDefaultLogger(schemas.LogLevelDebug) + plugin, err := Init(&Config{ + APIKey: os.Getenv("MAXIM_API_KEY"), + LogRepoID: os.Getenv("MAXIM_LOG_REPO_ID"), + }, logger) + if err != nil { + return nil, err + } + + return plugin, nil +} + +// BaseAccount implements the schemas.Account interface for testing purposes. +// It provides mock implementations of the required methods to test the Maxim plugin +// with a basic OpenAI configuration. +type BaseAccount struct{} + +// GetConfiguredProviders returns a list of supported providers for testing. +// Currently only supports OpenAI for simplicity in testing. You are free to add more providers as needed. +func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{schemas.OpenAI}, nil +} + +// GetKeysForProvider returns a mock API key configuration for testing. +// Uses the OPENAI_API_KEY environment variable for authentication. +func (baseAccount *BaseAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{"gpt-4o-mini", "gpt-4-turbo"}, + Weight: 1.0, + }, + }, nil +} + +// GetConfigForProvider returns default provider configuration for testing. +// Uses standard network and concurrency settings. +func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil +} + +// TestMaximLoggerPlugin tests the integration of the Maxim Logger plugin with Bifrost. +// It performs the following steps: +// 1. Initializes the Maxim plugin with environment variables +// 2. Sets up a test Bifrost instance with the plugin +// 3. Makes a test chat completion request +// +// Required environment variables: +// - MAXIM_API_KEY: Your Maxim API key +// - MAXIM_LOGGER_ID: Your Maxim logger repository ID +// - OPENAI_API_KEY: Your OpenAI API key for the test request +func TestMaximLoggerPlugin(t *testing.T) { + ctx := context.Background() + // Initialize the Maxim plugin + plugin, err := getPlugin() + if err != nil { + t.Fatalf("Error setting up the plugin: %v", err) + } + + account := BaseAccount{} + + // Initialize Bifrost with the plugin + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + + // Make a test chat completion request + _, bifrostErr := client.ChatCompletionRequest(context.Background(), &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: "user", + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, how are you?"), + }, + }, + }, + }) + + if bifrostErr != nil { + log.Printf("Error in Bifrost request: %v", bifrostErr) + } + + log.Println("Bifrost request completed, check your Maxim Dashboard for the trace") + + client.Shutdown() +} + +// TestLogRepoIDSelection tests the single repository selection logic +func TestLogRepoIDSelection(t *testing.T) { + tests := []struct { + name string + defaultRepo string + headerRepo string + expectedRepo string + shouldLog bool + }{ + { + name: "Header repo takes priority", + defaultRepo: "default-repo", + headerRepo: "header-repo", + expectedRepo: "header-repo", + shouldLog: true, + }, + { + name: "Fall back to default repo when no header", + defaultRepo: "default-repo", + headerRepo: "", + expectedRepo: "default-repo", + shouldLog: true, + }, + { + name: "Use header repo when no default", + defaultRepo: "", + headerRepo: "header-repo", + expectedRepo: "header-repo", + shouldLog: true, + }, + { + name: "Skip logging when neither available", + defaultRepo: "", + headerRepo: "", + expectedRepo: "", + shouldLog: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create plugin with default repo + plugin := &Plugin{ + defaultLogRepoID: tt.defaultRepo, + } + + // Create context with header repo if provided + ctx := context.Background() + if tt.headerRepo != "" { + ctx = context.WithValue(ctx, LogRepoIDKey, tt.headerRepo) + } + + // Test the selection logic + result := plugin.getEffectiveLogRepoID(&ctx) + + if result != tt.expectedRepo { + t.Errorf("Expected repo '%s', got '%s'", tt.expectedRepo, result) + } + + shouldLog := result != "" + if shouldLog != tt.shouldLog { + t.Errorf("Expected shouldLog=%t, got shouldLog=%t", tt.shouldLog, shouldLog) + } + }) + } +} + +// TestPluginInitialization tests plugin initialization with different configs +func TestPluginInitialization(t *testing.T) { + logger := bifrost.NewDefaultLogger(schemas.LogLevelDebug) + tests := []struct { + name string + config Config + expectError bool + }{ + { + name: "Valid config with both fields", + config: Config{ + APIKey: "test-api-key", + LogRepoID: "test-repo-id", + }, + expectError: false, + }, + { + name: "Valid config with only API key", + config: Config{ + APIKey: "test-api-key", + LogRepoID: "", + }, + expectError: false, + }, + { + name: "Invalid config - missing API key", + config: Config{ + APIKey: "", + LogRepoID: "test-repo-id", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Skip actual Maxim SDK initialization in tests + if tt.expectError { + _, err := Init(&tt.config, logger) + if err == nil { + t.Error("Expected error but got none") + } + } else { + // For valid configs, we can't test actual initialization without real API key + // Just test the validation logic + if tt.config.APIKey == "" { + t.Skip("Skipping valid config test - would need real Maxim API key") + } + } + }) + } +} + +// TestPluginName tests the plugin name functionality +func TestPluginName(t *testing.T) { + plugin := &Plugin{} + if plugin.GetName() != PluginName { + t.Errorf("Expected plugin name '%s', got '%s'", PluginName, plugin.GetName()) + } + if PluginName != "maxim" { + t.Errorf("Expected PluginName constant to be 'maxim', got '%s'", PluginName) + } +} diff --git a/plugins/maxim/version b/plugins/maxim/version new file mode 100644 index 000000000..5e99adfcc --- /dev/null +++ b/plugins/maxim/version @@ -0,0 +1 @@ +1.4.27 diff --git a/plugins/mocker/benchmark_test.go b/plugins/mocker/benchmark_test.go new file mode 100644 index 000000000..fc5f726bf --- /dev/null +++ b/plugins/mocker/benchmark_test.go @@ -0,0 +1,316 @@ +package mocker + +import ( + "context" + "strconv" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// BenchmarkMockerPlugin_PreHook_SimpleRule benchmarks simple rule matching +func BenchmarkMockerPlugin_PreHook_SimpleRule(b *testing.B) { + plugin, err := Init(MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "simple-rule", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{ + Providers: []string{"openai"}, + }, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Benchmark response", + }, + }, + }, + }, + }, + }) + if err != nil { + b.Fatal(err) + } + + req := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, benchmark test"), + }, + }, + }, + } + + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + // Convert to BifrostRequest for PreHook compatibility + bifrostReq := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: req, + } + + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PreHook(&ctx, bifrostReq) + } +} + +// BenchmarkMockerPlugin_PreHook_RegexRule benchmarks regex rule matching +func BenchmarkMockerPlugin_PreHook_RegexRule(b *testing.B) { + plugin, err := Init(MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "regex-rule", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{ + MessageRegex: bifrost.Ptr(`(?i).*hello.*`), + }, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Regex matched response", + }, + }, + }, + }, + }, + }) + if err != nil { + b.Fatal(err) + } + + req := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, this should match the regex pattern"), + }, + }, + }, + } + + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + // Convert to BifrostRequest for PreHook compatibility + bifrostReq := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: req, + } + + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PreHook(&ctx, bifrostReq) + } +} + +// BenchmarkMockerPlugin_PreHook_MultipleRules benchmarks multiple rule evaluation +func BenchmarkMockerPlugin_PreHook_MultipleRules(b *testing.B) { + rules := make([]MockRule, 10) + for i := 0; i < 10; i++ { + rules[i] = MockRule{ + Name: "rule-" + strconv.Itoa(i), + Enabled: true, + Priority: 100 - i, // Descending priority + Probability: 1.0, + Conditions: Conditions{ + Models: []string{"gpt-" + strconv.Itoa(i)}, + }, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Response from rule " + strconv.Itoa(i), + }, + }, + }, + } + } + + // Add a matching rule at the end + rules = append(rules, MockRule{ + Name: "matching-rule", + Enabled: true, + Priority: 50, + Probability: 1.0, + Conditions: Conditions{ + Models: []string{"gpt-4"}, + }, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Matching rule response", + }, + }, + }, + }) + + plugin, err := Init(MockerConfig{ + Enabled: true, + Rules: rules, + }) + if err != nil { + b.Fatal(err) + } + + req := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test message"), + }, + }, + }, + } + + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + // Convert to BifrostRequest for PreHook compatibility + bifrostReq := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: req, + } + + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PreHook(&ctx, bifrostReq) + } +} + +// BenchmarkMockerPlugin_PreHook_NoMatch benchmarks when no rules match +func BenchmarkMockerPlugin_PreHook_NoMatch(b *testing.B) { + plugin, err := Init(MockerConfig{ + Enabled: true, + DefaultBehavior: DefaultBehaviorPassthrough, + Rules: []MockRule{ + { + Name: "non-matching-rule", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{ + Providers: []string{"anthropic"}, // Won't match OpenAI + }, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "This won't match", + }, + }, + }, + }, + }, + }) + if err != nil { + b.Fatal(err) + } + + req := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, // Different from rule condition + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test message"), + }, + }, + }, + } + + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + // Convert to BifrostRequest for PreHook compatibility + bifrostReq := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: req, + } + + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PreHook(&ctx, bifrostReq) + } +} + +// BenchmarkMockerPlugin_PreHook_Template benchmarks template processing +func BenchmarkMockerPlugin_PreHook_Template(b *testing.B) { + plugin, err := Init(MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "template-rule", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{}, // Match all + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + MessageTemplate: bifrost.Ptr("Hello from {{provider}} using model {{model}}!"), + }, + }, + }, + }, + }, + }) + if err != nil { + b.Fatal(err) + } + + req := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test message"), + }, + }, + }, + } + + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + // Convert to BifrostRequest for PreHook compatibility + bifrostReq := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: req, + } + + for i := 0; i < b.N; i++ { + _, _, _ = plugin.PreHook(&ctx, bifrostReq) + } +} diff --git a/plugins/mocker/changelog.md b/plugins/mocker/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/mocker/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/mocker/go.mod b/plugins/mocker/go.mod new file mode 100644 index 000000000..97c59e30d --- /dev/null +++ b/plugins/mocker/go.mod @@ -0,0 +1,56 @@ +module github.com/maximhq/bifrost/plugins/mocker + +go 1.24.1 + +toolchain go1.24.3 + +require ( + github.com/jaswdr/faker/v2 v2.8.0 + github.com/maximhq/bifrost/core v1.2.22 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/mocker/go.sum b/plugins/mocker/go.sum new file mode 100644 index 000000000..51f88a396 --- /dev/null +++ b/plugins/mocker/go.sum @@ -0,0 +1,131 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jaswdr/faker/v2 v2.8.0 h1:3AxdXW9U7dJmWckh/P0YgRbNlCcVsTyrUNUnLVP9b3Q= +github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/mocker/main.go b/plugins/mocker/main.go new file mode 100644 index 000000000..1dd73206f --- /dev/null +++ b/plugins/mocker/main.go @@ -0,0 +1,1208 @@ +package mocker + +import ( + "context" + "fmt" + "maps" + "math/rand" + "regexp" + "slices" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/jaswdr/faker/v2" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +const ( + PluginName = "bifrost-mocker" +) + +// Constants for type checking and validation +const ( + // Response types + ResponseTypeSuccess = "success" + ResponseTypeError = "error" + + // Default behaviors + DefaultBehaviorPassthrough = "passthrough" + DefaultBehaviorError = "error" + DefaultBehaviorSuccess = "success" + + // Latency types + LatencyTypeFixed = "fixed" + LatencyTypeUniform = "uniform" +) + +// compiledRule represents a rule with pre-compiled regex and normalized weights for performance +type compiledRule struct { + MockRule + compiledRegex *regexp.Regexp // Pre-compiled regex for fast matching + normalizedWeights []float64 // Pre-calculated normalized weights for fast response selection +} + +// MockerPlugin provides comprehensive request/response mocking capabilities +type MockerPlugin struct { + config MockerConfig + rules []MockRule + compiledRules []compiledRule // Pre-compiled rules for performance + mu sync.RWMutex + faker faker.Faker // Use jaswdr/faker library + + // Atomic counters for high-performance statistics tracking + totalRequests int64 + mockedRequests int64 + responsesGenerated int64 + errorsGenerated int64 + + // Rule hits tracking (still needs mutex for map access) + ruleHitsMu sync.RWMutex + ruleHits map[string]int64 +} + +// MockerConfig defines the overall configuration for the mocker plugin +type MockerConfig struct { + Enabled bool `json:"enabled"` // Enable/disable the mocker plugin + GlobalLatency *Latency `json:"global_latency"` // Global latency settings applied to all rules (can be overridden per rule) + Rules []MockRule `json:"rules"` // List of mock rules to be evaluated in priority order + DefaultBehavior string `json:"default_behavior"` // Action when no rules match: "passthrough", "error", or "success" +} + +// MockRule defines a single mocking rule with conditions and responses +// Rules are evaluated in priority order (higher numbers = higher priority) +type MockRule struct { + Name string `json:"name"` // Unique rule name for identification and statistics tracking + Enabled bool `json:"enabled"` // Enable/disable this rule (disabled rules are skipped) + Priority int `json:"priority"` // Higher priority rules are checked first (higher numbers = higher priority) + Conditions Conditions `json:"conditions"` // Conditions that must match for this rule to apply + Responses []Response `json:"responses"` // Possible responses (selected using weighted random selection) + Latency *Latency `json:"latency"` // Rule-specific latency override (overrides global latency if set) + Probability float64 `json:"probability"` // Probability of rule activation (0.0=never, 1.0=always, 0=disabled) +} + +// Conditions define when a mock rule should be applied +// All specified conditions must match for the rule to trigger +type Conditions struct { + Providers []string `json:"providers"` // Match specific providers (e.g., ["openai", "anthropic"]) + Models []string `json:"models"` // Match specific models (e.g., ["gpt-4", "claude-3"]) + MessageRegex *string `json:"message_regex"` // Regex pattern to match against message content + RequestSize *SizeRange `json:"request_size"` // Request size constraints in bytes +} + +// Response defines a mock response configuration +// Either Content (for success) or Error (for error) should be set, not both +type Response struct { + Type string `json:"type"` // Response type: "success" or "error" + Weight float64 `json:"weight"` // Weight for random selection (higher = more likely) + Content *SuccessResponse `json:"content"` // Success response content (required if Type="success") + Error *ErrorResponse `json:"error"` // Error response content (required if Type="error") + AllowFallbacks *bool `json:"allow_fallbacks"` // Control fallback behavior for errors (nil=true, false=no fallbacks) +} + +// SuccessResponse defines mock success response content +// Either Message or MessageTemplate should be set (MessageTemplate takes precedence) +type SuccessResponse struct { + Message string `json:"message"` // Static response message + Model *string `json:"model"` // Override model name in response (optional) + Usage *Usage `json:"usage"` // Token usage info (optional, defaults applied if nil) + FinishReason *string `json:"finish_reason"` // Completion reason (optional, defaults to "stop") + MessageTemplate *string `json:"message_template"` // Template with variables like {{model}}, {{provider}} (overrides Message) + CustomFields map[string]interface{} `json:"custom_fields"` // Additional fields stored in response metadata +} + +// ErrorResponse defines mock error response content +type ErrorResponse struct { + Message string `json:"message"` // Error message to return + Type *string `json:"type"` // Error type (e.g., "rate_limit", "auth_error") + Code *string `json:"code"` // Error code (e.g., "429", "401") + StatusCode *int `json:"status_code"` // HTTP status code for the error +} + +// Latency defines latency simulation settings +type Latency struct { + Min time.Duration `json:"min"` // Minimum latency as time.Duration (e.g., 100*time.Millisecond, NOT raw int) + Max time.Duration `json:"max"` // Maximum latency as time.Duration (e.g., 500*time.Millisecond, NOT raw int) + Type string `json:"type"` // Latency type: "fixed" or "uniform" +} + +// SizeRange defines request size constraints in bytes +type SizeRange struct { + Min int `json:"min"` // Minimum request size in bytes + Max int `json:"max"` // Maximum request size in bytes +} + +// Usage defines token usage information +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// MockStats tracks plugin statistics and rule execution counts +type MockStats struct { + TotalRequests int64 `json:"total_requests"` // Total number of requests processed + MockedRequests int64 `json:"mocked_requests"` // Number of requests that were mocked (rules matched) + RuleHits map[string]int64 `json:"rule_hits"` // Rule name -> hit count mapping + ErrorsGenerated int64 `json:"errors_generated"` // Number of error responses generated + ResponsesGenerated int64 `json:"responses_generated"` // Number of success responses generated +} + +// Init creates a new mocker plugin instance with sensible defaults +// Returns an error if required configuration is invalid or missing +func Init(config MockerConfig) (*MockerPlugin, error) { + // Validate configuration + if err := validateConfig(config); err != nil { + return nil, fmt.Errorf("invalid mocker plugin configuration: %w", err) + } + + // Apply defaults if not set + if config.DefaultBehavior == "" { + config.DefaultBehavior = DefaultBehaviorPassthrough // Default to passthrough if no rules match + } + + // If no rules provided, create a simple catch-all rule for quick testing + if len(config.Rules) == 0 && config.Enabled { + config.Rules = []MockRule{ + { + Name: "default-mock", + Enabled: true, + Priority: 1, + Conditions: Conditions{}, // Empty conditions = match all requests + Probability: 1.0, // Always activate + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Weight: 1.0, + Content: &SuccessResponse{ + Message: "This is a mock response from the Mocker plugin", + }, + }, + }, + }, + } + } + + plugin := &MockerPlugin{ + config: config, + rules: config.Rules, + ruleHits: make(map[string]int64), + faker: faker.New(), // Initialize faker + } + + // Pre-compile all regex patterns for performance + if err := plugin.compileRules(); err != nil { + return nil, fmt.Errorf("failed to compile rules: %w", err) + } + + return plugin, nil +} + +// compileRules pre-compiles all regex patterns and calculates normalized weights for performance +func (p *MockerPlugin) compileRules() error { + p.compiledRules = make([]compiledRule, 0, len(p.rules)) + + for _, rule := range p.rules { + compiled := compiledRule{MockRule: rule} + + // Pre-compile regex if present + if rule.Conditions.MessageRegex != nil { + regex, err := regexp.Compile(*rule.Conditions.MessageRegex) + if err != nil { + return fmt.Errorf("invalid regex in rule '%s': %w", rule.Name, err) + } + compiled.compiledRegex = regex + } + + // Pre-calculate normalized weights for fast response selection + compiled.normalizedWeights = p.calculateNormalizedWeights(rule.Responses) + + p.compiledRules = append(p.compiledRules, compiled) + } + + // Sort compiled rules by priority (higher first) + p.sortCompiledRulesByPriority() + + return nil +} + +// calculateNormalizedWeights pre-calculates normalized cumulative weights for fast response selection +func (p *MockerPlugin) calculateNormalizedWeights(responses []Response) []float64 { + if len(responses) == 0 { + return nil + } + + if len(responses) == 1 { + return []float64{1.0} // Single response always gets 100% probability + } + + // Calculate total weight, applying default weight of 1.0 if not specified + totalWeight := 0.0 + for _, response := range responses { + weight := response.Weight + if weight == 0 { + weight = 1.0 // Default weight + } + totalWeight += weight + } + + // Calculate normalized cumulative weights for O(1) selection + normalizedWeights := make([]float64, len(responses)) + cumulativeWeight := 0.0 + + for i, response := range responses { + weight := response.Weight + if weight == 0 { + weight = 1.0 // Default weight + } + cumulativeWeight += weight / totalWeight // Normalize to [0, 1] + normalizedWeights[i] = cumulativeWeight + } + + // Ensure the last weight is exactly 1.0 to handle floating point precision issues + if len(normalizedWeights) > 0 { + normalizedWeights[len(normalizedWeights)-1] = 1.0 + } + + return normalizedWeights +} + +// validateConfig validates the mocker plugin configuration +func validateConfig(config MockerConfig) error { + // Validate default behavior + if config.DefaultBehavior != "" { + switch config.DefaultBehavior { + case DefaultBehaviorPassthrough, DefaultBehaviorError, DefaultBehaviorSuccess: + // Valid + default: + return fmt.Errorf("invalid default_behavior '%s', must be one of: %s, %s, %s", + config.DefaultBehavior, DefaultBehaviorPassthrough, DefaultBehaviorError, DefaultBehaviorSuccess) + } + } + + // Validate global latency if provided + if config.GlobalLatency != nil { + if err := validateLatency(*config.GlobalLatency); err != nil { + return fmt.Errorf("invalid global_latency: %w", err) + } + } + + // Validate each rule + for i, rule := range config.Rules { + if err := validateRule(rule); err != nil { + return fmt.Errorf("invalid rule at index %d (%s): %w", i, rule.Name, err) + } + } + + return nil +} + +// validateRule validates a single mock rule +func validateRule(rule MockRule) error { + // Rule name is required + if rule.Name == "" { + return fmt.Errorf("rule name is required") + } + + // Priority should be reasonable (allow negative for low priority) + if rule.Priority < -1000 || rule.Priority > 1000 { + return fmt.Errorf("priority %d is out of reasonable range (-1000 to 1000)", rule.Priority) + } + + // Probability must be between 0 and 1 + if rule.Probability < 0 || rule.Probability > 1 { + return fmt.Errorf("probability %.2f must be between 0.0 and 1.0", rule.Probability) + } + + // At least one response is required + if len(rule.Responses) == 0 { + return fmt.Errorf("at least one response is required") + } + + // Validate rule-specific latency if provided + if rule.Latency != nil { + if err := validateLatency(*rule.Latency); err != nil { + return fmt.Errorf("invalid rule latency: %w", err) + } + } + + // Validate conditions + if err := validateConditions(rule.Conditions); err != nil { + return fmt.Errorf("invalid conditions: %w", err) + } + + // Validate each response + for i, response := range rule.Responses { + if err := validateResponse(response); err != nil { + return fmt.Errorf("invalid response at index %d: %w", i, err) + } + } + + return nil +} + +// validateLatency validates latency configuration +func validateLatency(latency Latency) error { + // Type is required + if latency.Type == "" { + return fmt.Errorf("latency type is required") + } + + // Validate type + switch latency.Type { + case LatencyTypeFixed, LatencyTypeUniform: + // Valid + default: + return fmt.Errorf("invalid latency type '%s', must be one of: %s, %s", + latency.Type, LatencyTypeFixed, LatencyTypeUniform) + } + + // Min latency should be non-negative + if latency.Min < 0 { + return fmt.Errorf("minimum latency cannot be negative") + } + + // For uniform type, max should be >= min + if latency.Type == LatencyTypeUniform { + if latency.Max < latency.Min { + return fmt.Errorf("maximum latency (%v) cannot be less than minimum latency (%v)", latency.Max, latency.Min) + } + } + + return nil +} + +// validateConditions validates rule conditions +func validateConditions(conditions Conditions) error { + // Validate regex if provided + if conditions.MessageRegex != nil { + _, err := regexp.Compile(*conditions.MessageRegex) + if err != nil { + return fmt.Errorf("invalid message regex '%s': %w", *conditions.MessageRegex, err) + } + } + + // Validate request size range if provided + if conditions.RequestSize != nil { + if conditions.RequestSize.Min < 0 { + return fmt.Errorf("request size minimum cannot be negative") + } + if conditions.RequestSize.Max < conditions.RequestSize.Min { + return fmt.Errorf("request size maximum (%d) cannot be less than minimum (%d)", + conditions.RequestSize.Max, conditions.RequestSize.Min) + } + } + + return nil +} + +// validateResponse validates a response configuration +func validateResponse(response Response) error { + // Type is required + if response.Type == "" { + return fmt.Errorf("response type is required") + } + + // Validate type + switch response.Type { + case ResponseTypeSuccess, ResponseTypeError: + // Valid + default: + return fmt.Errorf("invalid response type '%s', must be one of: %s, %s", + response.Type, ResponseTypeSuccess, ResponseTypeError) + } + + // Weight should be non-negative + if response.Weight < 0 { + return fmt.Errorf("response weight cannot be negative") + } + + // Validate response content based on type + if response.Type == ResponseTypeSuccess { + if response.Content == nil { + return fmt.Errorf("success response must have content") + } + if err := validateSuccessResponse(*response.Content); err != nil { + return fmt.Errorf("invalid success content: %w", err) + } + } else if response.Type == ResponseTypeError { + if response.Error == nil { + return fmt.Errorf("error response must have error content") + } + if err := validateErrorResponse(*response.Error); err != nil { + return fmt.Errorf("invalid error content: %w", err) + } + } + + return nil +} + +// validateSuccessResponse validates success response content +func validateSuccessResponse(content SuccessResponse) error { + // Either Message or MessageTemplate must be provided + if content.Message == "" && (content.MessageTemplate == nil || *content.MessageTemplate == "") { + return fmt.Errorf("either message or message_template is required") + } + + // If usage is provided, validate it + if content.Usage != nil { + if content.Usage.PromptTokens < 0 || content.Usage.CompletionTokens < 0 || content.Usage.TotalTokens < 0 { + return fmt.Errorf("token counts cannot be negative") + } + } + + return nil +} + +// validateErrorResponse validates error response content +func validateErrorResponse(errorContent ErrorResponse) error { + // Message is required + if errorContent.Message == "" { + return fmt.Errorf("error message is required") + } + + // Status code should be reasonable if provided + if errorContent.StatusCode != nil { + if *errorContent.StatusCode < 100 || *errorContent.StatusCode > 599 { + return fmt.Errorf("status code %d is out of valid HTTP range (100-599)", *errorContent.StatusCode) + } + } + + return nil +} + + + +// GetName returns the plugin name +func (p *MockerPlugin) GetName() string { + return PluginName +} +// TransportInterceptor is not used for this plugin +func (p *MockerPlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// PreHook intercepts requests and applies mocking rules based on configuration +// This is called before the actual provider request and can short-circuit the flow +func (p *MockerPlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + // Skip processing if plugin is disabled + if !p.config.Enabled { + return req, nil, nil + } + + skipMocker, ok := (*ctx).Value(schemas.BifrostContextKey("skip-mocker")).(bool) + if ok && skipMocker { + return req, nil, nil + } + + if req.RequestType != schemas.ChatCompletionRequest && req.RequestType != schemas.ResponsesRequest { + return req, nil, nil + } + + startTime := time.Now() + + // Track total request count using atomic operation (no lock needed) + atomic.AddInt64(&p.totalRequests, 1) + + // Find the first matching rule based on priority order + rule := p.findMatchingCompiledRule(req) + if rule == nil { + // No rules matched, handle according to default behavior + return p.handleDefaultBehavior(req) + } + + // Check if rule should activate based on probability (0.0 = never, 1.0 = always) + if rule.Probability > 0 && rand.Float64() > rule.Probability { + // Rule didn't activate due to probability, continue with normal flow + return req, nil, nil + } + + // Apply artificial latency simulation if configured + if latency := p.getLatency(&rule.MockRule); latency != nil { + delay := p.calculateLatency(latency) + time.Sleep(delay) + } + + // Select a response from the rule's possible responses using pre-calculated weights + response := p.selectResponse(rule) + if response == nil { + // No valid response configuration, continue with normal flow + return req, nil, nil + } + + // Update statistics using atomic operations and minimal locking + atomic.AddInt64(&p.mockedRequests, 1) + + // Rule hits still need a mutex since it's a map, but we minimize lock time + p.ruleHitsMu.Lock() + p.ruleHits[rule.Name]++ + p.ruleHitsMu.Unlock() + + // Generate appropriate mock response based on type + if response.Type == ResponseTypeSuccess { + return p.generateSuccessShortCircuit(req, response, startTime) + } else if response.Type == ResponseTypeError { + return p.generateErrorShortCircuit(req, response) + } + + // Fallback: continue with normal flow if response type is unrecognized + return req, nil, nil +} + +// PostHook processes responses after provider calls +func (p *MockerPlugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + return result, err, nil +} + +// Cleanup performs plugin cleanup and frees memory +// IMPORTANT: Call GetStats() before Cleanup() if you need the statistics, +// as this method clears all statistics data to free memory +func (p *MockerPlugin) Cleanup() error { + p.mu.Lock() + defer p.mu.Unlock() + + // Clear all statistics to free memory using atomic operations + atomic.StoreInt64(&p.totalRequests, 0) + atomic.StoreInt64(&p.mockedRequests, 0) + atomic.StoreInt64(&p.responsesGenerated, 0) + atomic.StoreInt64(&p.errorsGenerated, 0) + + // Clear rule hits map + p.ruleHitsMu.Lock() + p.ruleHits = make(map[string]int64) + p.ruleHitsMu.Unlock() + + // Clear rules to free memory + p.rules = nil + p.compiledRules = nil + + return nil +} + +// findMatchingCompiledRule finds the first rule that matches the request using pre-compiled rules +func (p *MockerPlugin) findMatchingCompiledRule(req *schemas.BifrostRequest) *compiledRule { + for i := range p.compiledRules { + rule := &p.compiledRules[i] + if !rule.Enabled { + continue + } + + if p.matchesConditionsFast(req, &rule.Conditions, rule.compiledRegex) { + return rule + } + } + return nil +} + +// matchesConditionsFast checks if request matches rule conditions with optimized performance +func (p *MockerPlugin) matchesConditionsFast(req *schemas.BifrostRequest, conditions *Conditions, compiledRegex *regexp.Regexp) bool { + provider, model, _ := req.GetRequestFields() + + // Check providers - optimized string comparison + if len(conditions.Providers) > 0 { + providerStr := string(provider) + found := slices.Contains(conditions.Providers, providerStr) + if !found { + return false + } + } + + // Check models - direct string comparison + if len(conditions.Models) > 0 { + found := false + for _, conditionModel := range conditions.Models { + if model == conditionModel { + found = true + break + } + } + if !found { + return false + } + } + + // Check message regex using pre-compiled regex (major performance improvement) + if compiledRegex != nil { + // Extract message content from request (cached if possible) + messageContent := p.extractMessageContentFast(req) + if !compiledRegex.MatchString(messageContent) { + return false + } + } + + // Check request size - only calculate if needed + if conditions.RequestSize != nil { + size := p.calculateRequestSizeFast(req) + if size < conditions.RequestSize.Min || size > conditions.RequestSize.Max { + return false + } + } + + // All conditions matched + return true +} + +// extractMessageContentFast extracts message content with optimized performance +func (p *MockerPlugin) extractMessageContentFast(req *schemas.BifrostRequest) string { + switch req.RequestType { + case schemas.TextCompletionRequest: + // Handle text completion input + if req.TextCompletionRequest.Input.PromptStr != nil { + return *req.TextCompletionRequest.Input.PromptStr + } else { + var stringBuilder strings.Builder + for _, prompt := range req.TextCompletionRequest.Input.PromptArray { + stringBuilder.WriteString(prompt) + } + return stringBuilder.String() + } + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + // Handle chat completion input - optimized for common cases + if req.ChatRequest.Input != nil { + messages := req.ChatRequest.Input + if len(messages) == 0 { + return "" + } + + // Fast path for single message + if len(messages) == 1 { + if messages[0].Content.ContentStr != nil { + return *messages[0].Content.ContentStr + } + return "" + } + + // Multiple messages - use string builder for efficiency + var builder strings.Builder + for i, message := range messages { + if message.Content.ContentStr != nil { + if i > 0 { + builder.WriteByte(' ') + } + builder.WriteString(*message.Content.ContentStr) + } + } + return builder.String() + } + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + // Handle responses input - optimized for common cases + if req.ResponsesRequest.Input != nil { + messages := req.ResponsesRequest.Input + if len(messages) == 0 { + return "" + } + + // Fast path for single message + if len(messages) == 1 { + if messages[0].Content != nil && messages[0].Content.ContentStr != nil { + return *messages[0].Content.ContentStr + } + return "" + } + + // Multiple messages - use string builder for efficiency + var builder strings.Builder + for i, message := range messages { + if message.Content == nil || message.Content.ContentStr == nil { + continue + } + if i > 0 { + builder.WriteByte(' ') + } + builder.WriteString(*message.Content.ContentStr) + } + return builder.String() + } + default: + return "" + } + + return "" +} + +// calculateRequestSizeFast calculates request size with minimal overhead +func (p *MockerPlugin) calculateRequestSizeFast(req *schemas.BifrostRequest) int { + provider, model, _ := req.GetRequestFields() + + // Approximate size calculation to avoid expensive JSON marshaling + size := len(model) + len(string(provider)) + + // Add input size + if req.TextCompletionRequest != nil { + if req.TextCompletionRequest.Input.PromptStr != nil { + size += len(*req.TextCompletionRequest.Input.PromptStr) + } else { + for _, prompt := range req.TextCompletionRequest.Input.PromptArray { + size += len(prompt) + } + } + } + + if req.ChatRequest.Input != nil { + for _, message := range req.ChatRequest.Input { + if message.Content.ContentStr != nil { + size += len(*message.Content.ContentStr) + } + size += 50 // Approximate overhead for message structure + } + } + + if req.ResponsesRequest.Input != nil { + for _, message := range req.ResponsesRequest.Input { + if message.Content != nil && message.Content.ContentStr != nil { + size += len(*message.Content.ContentStr) + } + size += 50 // Approximate overhead for message structure + } + } + + return size +} + +// generateSuccessShortCircuit creates a success response short-circuit with optimized allocations +func (p *MockerPlugin) generateSuccessShortCircuit(req *schemas.BifrostRequest, response *Response, startTime time.Time) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + if response.Content == nil { + return req, nil, nil + } + + content := response.Content + message := content.Message + + // Apply message template if provided + if content.MessageTemplate != nil { + message = p.applyTemplate(*content.MessageTemplate, req) + } + + // Apply defaults for token usage if not provided + var usage schemas.BifrostLLMUsage + if content.Usage != nil { + usage = schemas.BifrostLLMUsage{ + PromptTokens: p.getOrDefault(content.Usage.PromptTokens, 10), + CompletionTokens: p.getOrDefault(content.Usage.CompletionTokens, 20), + TotalTokens: p.getOrDefault(content.Usage.TotalTokens, content.Usage.PromptTokens+content.Usage.CompletionTokens), + } + } else { + // Default usage when none specified + usage = schemas.BifrostLLMUsage{ + PromptTokens: 10, + CompletionTokens: 20, + TotalTokens: 30, + } + } + + // Get finish reason with minimal allocation + var finishReason *string + if content.FinishReason != nil { + finishReason = content.FinishReason + } else { + // Use a static string to avoid allocation + static := "stop" + finishReason = &static + } + + provider, model, _ := req.GetRequestFields() + + // Create mock response with proper structure + mockResponse := &schemas.BifrostResponse{} + + if req.RequestType == schemas.ChatCompletionRequest { + mockResponse.ChatResponse = &schemas.BifrostChatResponse{ + Model: model, + Usage: &usage, + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: &message, + }, + }, + }, + FinishReason: finishReason, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: provider, + ModelRequested: model, + Latency: int64(time.Since(startTime).Milliseconds()), + }, + } + } else if req.RequestType == schemas.ResponsesRequest { + mockResponse.ResponsesResponse = &schemas.BifrostResponsesResponse{ + CreatedAt: int(time.Now().Unix()), + Output: []schemas.ResponsesMessage{ + { + Role: bifrost.Ptr(schemas.ResponsesInputMessageRoleAssistant), + Content: &schemas.ResponsesMessageContent{ + ContentStr: &message, + }, + Type: bifrost.Ptr(schemas.ResponsesMessageTypeMessage), + }, + }, + Usage: &schemas.ResponsesResponseUsage{ + InputTokens: usage.PromptTokens, + OutputTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ResponsesRequest, + Provider: provider, + ModelRequested: model, + Latency: int64(time.Since(startTime).Milliseconds()), + }, + } + } + + // Override model if specified + if content.Model != nil { + mockResponse.ChatResponse.Model = *content.Model + } + + // Only create raw response map if there are custom fields (avoid allocation) + if len(content.CustomFields) > 0 { + rawResponse := make(map[string]interface{}, len(content.CustomFields)+1) + + // Add custom fields + for key, value := range content.CustomFields { + rawResponse[key] = value + } + + // Add mock metadata + rawResponse["mock_rule"] = "success" + extraFields := mockResponse.GetExtraFields() + extraFields.RawResponse = rawResponse + } + + // Increment success response counter using atomic operation + atomic.AddInt64(&p.responsesGenerated, 1) + + return req, &schemas.PluginShortCircuit{ + Response: mockResponse, + }, nil +} + +// generateErrorShortCircuit creates an error response short-circuit with optimized performance +func (p *MockerPlugin) generateErrorShortCircuit(req *schemas.BifrostRequest, response *Response) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + if response.Error == nil { + return req, nil, nil + } + + provider, model, _ := req.GetRequestFields() + + errorContent := response.Error + allowFallbacks := response.AllowFallbacks + + // Create mock error + mockError := &schemas.BifrostError{ + Error: &schemas.ErrorField{ + Message: errorContent.Message, + }, + AllowFallbacks: allowFallbacks, + ExtraFields: schemas.BifrostErrorExtraFields{ + RequestType: req.RequestType, + Provider: provider, + ModelRequested: model, + }, + } + + // Set error type + if errorContent.Type != nil { + mockError.Error.Type = errorContent.Type + } + + // Set error code + if errorContent.Code != nil { + mockError.Error.Code = errorContent.Code + } + + // Set status code + if errorContent.StatusCode != nil { + mockError.StatusCode = errorContent.StatusCode + } + + // Increment error counter using atomic operation + atomic.AddInt64(&p.errorsGenerated, 1) + + return req, &schemas.PluginShortCircuit{ + Error: mockError, + }, nil +} + +// selectResponse selects a response using pre-calculated normalized weights for optimal performance +func (p *MockerPlugin) selectResponse(rule *compiledRule) *Response { + responses := rule.Responses + normalizedWeights := rule.normalizedWeights + + if len(responses) == 0 { + return nil + } + + if len(responses) == 1 { + return &responses[0] + } + + // Fast O(log n) binary search using pre-calculated cumulative weights + randomValue := rand.Float64() + + // Binary search for the selected response + left, right := 0, len(normalizedWeights)-1 + for left < right { + mid := (left + right) / 2 + if randomValue <= normalizedWeights[mid] { + right = mid + } else { + left = mid + 1 + } + } + + return &responses[left] +} + +// getLatency returns the applicable latency configuration +func (p *MockerPlugin) getLatency(rule *MockRule) *Latency { + if rule.Latency != nil { + return rule.Latency + } + return p.config.GlobalLatency +} + +// calculateLatency calculates the actual delay based on latency configuration +func (p *MockerPlugin) calculateLatency(latency *Latency) time.Duration { + switch latency.Type { + case LatencyTypeFixed: + return latency.Min + case LatencyTypeUniform: + if latency.Max <= latency.Min { + return latency.Min + } + // Calculate random duration between Min and Max + diff := latency.Max - latency.Min + return latency.Min + time.Duration(rand.Float64()*float64(diff)) + default: + // Default to fixed latency + return latency.Min + } +} + +// handleDefaultBehavior handles requests when no rules match +func (p *MockerPlugin) handleDefaultBehavior(req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + provider, model, _ := req.GetRequestFields() + + switch p.config.DefaultBehavior { + case DefaultBehaviorError: + return req, &schemas.PluginShortCircuit{ + Error: &schemas.BifrostError{ + Error: &schemas.ErrorField{ + Message: "Mock plugin default error", + }, + }, + }, nil + case DefaultBehaviorSuccess: + finishReason := "stop" + return req, &schemas.PluginShortCircuit{ + Response: &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + Model: model, + Usage: &schemas.BifrostLLMUsage{ + PromptTokens: 5, + CompletionTokens: 10, + TotalTokens: 15, + }, + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Mock plugin default response"), + }, + }, + }, + FinishReason: &finishReason, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ChatCompletionRequest, + Provider: provider, + ModelRequested: model, + }, + }, + }, + }, nil + default: // DefaultBehaviorPassthrough + return req, nil, nil + } +} + +// Helper functions + +// sortCompiledRulesByPriority sorts rules by priority (descending) +func (p *MockerPlugin) sortCompiledRulesByPriority() { + sort.Slice(p.compiledRules, func(i, j int) bool { + return p.compiledRules[i].Priority > p.compiledRules[j].Priority + }) +} + +// applyTemplate applies template variables with optimized string operations including faker support +func (p *MockerPlugin) applyTemplate(template string, req *schemas.BifrostRequest) string { + provider, model, _ := req.GetRequestFields() + + // Fast path: no template variables + if !strings.Contains(template, "{{") { + return template + } + + result := template + + // Replace basic variables first + replacer := strings.NewReplacer( + "{{provider}}", string(provider), + "{{model}}", model, + ) + result = replacer.Replace(result) + + // Handle faker variables with regex for more complex patterns + fakerRegex := regexp.MustCompile(`\{\{faker\.([^}]+)\}\}`) + result = fakerRegex.ReplaceAllStringFunc(result, func(match string) string { + // Extract the faker method name + submatch := fakerRegex.FindStringSubmatch(match) + if len(submatch) < 2 { + return match // Return original if no match + } + + fakerMethod := submatch[1] + return p.generateFakerValue(fakerMethod) + }) + + return result +} + +// generateFakerValue generates fake data based on the faker method name +func (p *MockerPlugin) generateFakerValue(method string) string { + // Parse method with potential parameters (e.g., "lorem_ipsum:20" for 20 words) + parts := strings.Split(method, ":") + baseMethod := parts[0] + + switch baseMethod { + case "name": + return p.faker.Person().Name() + case "first_name": + return p.faker.Person().FirstName() + case "last_name": + return p.faker.Person().LastName() + case "email": + return p.faker.Internet().Email() + case "phone": + return p.faker.Phone().Number() + case "address": + return p.faker.Address().Address() + case "city": + return p.faker.Address().City() + case "state": + return p.faker.Address().State() + case "zip_code": + return p.faker.Address().PostCode() + case "company": + return p.faker.Company().Name() + case "job_title": + return p.faker.Company().JobTitle() + case "lorem_ipsum": + wordCount := 10 // default + if len(parts) > 1 { + if count, err := fmt.Sscanf(parts[1], "%d", &wordCount); err != nil || count != 1 { + wordCount = 10 + } + } + return p.faker.Lorem().Sentence(wordCount) + case "uuid": + return p.faker.UUID().V4() + case "hex_color": + return p.faker.Color().Hex() + case "integer": + min, max := 1, 100 // defaults + if len(parts) > 1 { + params := strings.Split(parts[1], ",") + if len(params) >= 2 { + if _, err := fmt.Sscanf(params[0], "%d", &min); err != nil { + min = 1 // fallback to default on parse error + } + if _, err := fmt.Sscanf(params[1], "%d", &max); err != nil { + max = 100 // fallback to default on parse error + } + } + } + return fmt.Sprintf("%d", p.faker.IntBetween(min, max)) + case "float": + min, max := 0, 100 // defaults as integers + if len(parts) > 1 { + params := strings.Split(parts[1], ",") + if len(params) >= 2 { + if _, err := fmt.Sscanf(params[0], "%d", &min); err != nil { + min = 0 // fallback to default on parse error + } + if _, err := fmt.Sscanf(params[1], "%d", &max); err != nil { + max = 100 // fallback to default on parse error + } + } + } + return fmt.Sprintf("%.2f", p.faker.Float64(2, min, max)) + case "boolean": + return fmt.Sprintf("%t", p.faker.Bool()) + case "date": + return p.faker.Time().Time(time.Now()).Format("2006-01-02") + case "datetime": + return p.faker.Time().Time(time.Now()).Format("2006-01-02 15:04:05") + case "word": + return p.faker.Lorem().Word() + case "sentence": + wordCount := 8 // default + if len(parts) > 1 { + if count, err := fmt.Sscanf(parts[1], "%d", &wordCount); err != nil || count != 1 { + wordCount = 8 + } + } + return p.faker.Lorem().Sentence(wordCount) + default: + // Return the original placeholder if method is not recognized + return fmt.Sprintf("{{faker.%s}}", method) + } +} + +// getOrDefault returns value or default if 0 +func (p *MockerPlugin) getOrDefault(value, defaultValue int) int { + if value == 0 { + return defaultValue + } + return value +} + +// GetStats returns current plugin statistics +// IMPORTANT: Call this method before Cleanup() if you need the statistics, +// as Cleanup() clears all statistics data to free memory +func (p *MockerPlugin) GetStats() MockStats { + p.mu.RLock() + defer p.mu.RUnlock() + + // Create a deep copy using atomic reads for counters + statsCopy := MockStats{ + TotalRequests: atomic.LoadInt64(&p.totalRequests), + MockedRequests: atomic.LoadInt64(&p.mockedRequests), + ErrorsGenerated: atomic.LoadInt64(&p.errorsGenerated), + ResponsesGenerated: atomic.LoadInt64(&p.responsesGenerated), + RuleHits: make(map[string]int64), + } + + // Copy rule hits map (still needs lock) + p.ruleHitsMu.RLock() + maps.Copy(statsCopy.RuleHits, p.ruleHits) + p.ruleHitsMu.RUnlock() + + return statsCopy +} diff --git a/plugins/mocker/plugin_test.go b/plugins/mocker/plugin_test.go new file mode 100644 index 000000000..7fde1e781 --- /dev/null +++ b/plugins/mocker/plugin_test.go @@ -0,0 +1,532 @@ +package mocker + +import ( + "context" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// BaseAccount implements the schemas.Account interface for testing purposes. +// It provides mock implementations of the required methods to test the Mocker plugin +// with a basic OpenAI configuration. +type BaseAccount struct{} + +// GetConfiguredProviders returns a list of supported providers for testing. +func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{schemas.OpenAI, schemas.Anthropic}, nil +} + +// GetKeysForProvider returns a dummy API key configuration for testing. +// Since we're testing the mocker plugin, these keys should never be used +// as the plugin intercepts requests before they reach the actual providers. +func (baseAccount *BaseAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + return []schemas.Key{ + { + Value: "dummy-api-key-for-testing", // Dummy key + Models: []string{"gpt-4", "gpt-4-turbo", "claude-3"}, + Weight: 1.0, + }, + }, nil +} + +// GetConfigForProvider returns default provider configuration for testing. +func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil +} + +// TestMockerPlugin_GetName tests the plugin name +func TestMockerPlugin_GetName(t *testing.T) { + plugin, err := Init(MockerConfig{}) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + if plugin.GetName() != PluginName { + t.Errorf("Expected '%s', got '%s'", PluginName, plugin.GetName()) + } +} + +// TestMockerPlugin_Disabled tests that disabled plugin doesn't interfere +func TestMockerPlugin_Disabled(t *testing.T) { + ctx := context.Background() + config := MockerConfig{ + Enabled: false, + } + plugin, err := Init(config) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + + account := BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + // This should pass through to the real provider (but will fail due to dummy key) + _, bifrostErr := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, test message"), + }, + }, + }, + }) + + // Should get an authentication error from OpenAI, not a mock response + // This proves the plugin is disabled and not intercepting requests + if bifrostErr == nil { + t.Error("Expected error from real provider with dummy API key") + } +} + +// TestMockerPlugin_DefaultMockRule tests the default catch-all rule +func TestMockerPlugin_DefaultMockRule(t *testing.T) { + ctx := context.Background() + config := MockerConfig{ + Enabled: true, // No rules provided, should create default rule + } + plugin, err := Init(config) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + + account := BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + response, bifrostErr := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, test message"), + }, + }, + }, + }) + + if bifrostErr != nil { + t.Fatalf("Expected no error, got: %v", bifrostErr) + } + if response == nil { + t.Fatal("Expected response") + } + if len(response.Choices) == 0 { + t.Fatal("Expected at least one choice") + } + if response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr == nil { + t.Fatal("Expected content string") + } + if *response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr != "This is a mock response from the Mocker plugin" { + t.Errorf("Expected default mock message, got: %s", *response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr) + } +} + +// TestMockerPlugin_CustomSuccessRule tests custom success response +func TestMockerPlugin_CustomSuccessRule(t *testing.T) { + ctx := context.Background() + config := MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "openai-success", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{ + Providers: []string{"openai"}, + }, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Custom OpenAI mock response", + Usage: &Usage{ + PromptTokens: 15, + CompletionTokens: 25, + TotalTokens: 40, + }, + }, + }, + }, + }, + }, + } + plugin, err := Init(config) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + + account := BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + response, bifrostErr := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, test message"), + }, + }, + }, + }) + + if bifrostErr != nil { + t.Fatalf("Expected no error, got: %v", bifrostErr) + } + if response == nil { + t.Fatal("Expected response") + } + if len(response.Choices) == 0 { + t.Fatal("Expected at least one choice") + } + if response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr == nil { + t.Fatal("Expected content string") + } + if *response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr != "Custom OpenAI mock response" { + t.Errorf("Expected custom message, got: %s", *response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr) + } + if response.Usage.TotalTokens != 40 { + t.Errorf("Expected 40 total tokens, got %d", response.Usage.TotalTokens) + } +} + +// TestMockerPlugin_ErrorResponse tests error response generation +func TestMockerPlugin_ErrorResponse(t *testing.T) { + ctx := context.Background() + allowFallbacks := false + config := MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "rate-limit-error", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{ + Providers: []string{"openai"}, + }, + Responses: []Response{ + { + Type: ResponseTypeError, + AllowFallbacks: &allowFallbacks, + Error: &ErrorResponse{ + Message: "Rate limit exceeded", + Type: bifrost.Ptr("rate_limit"), + Code: bifrost.Ptr("429"), + StatusCode: bifrost.Ptr(429), + }, + }, + }, + }, + }, + } + plugin, err := Init(config) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + + account := BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + _, bifrostErr := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, test message"), + }, + }, + }, + }) + + if bifrostErr == nil { + t.Fatal("Expected error response") + } + if bifrostErr.Error.Message != "Rate limit exceeded" { + t.Errorf("Expected 'Rate limit exceeded', got: %s", bifrostErr.Error.Message) + } + if bifrostErr.StatusCode == nil || *bifrostErr.StatusCode != 429 { + t.Errorf("Expected status code 429, got: %v", bifrostErr.StatusCode) + } +} + +// TestMockerPlugin_MessageTemplate tests template variable substitution +func TestMockerPlugin_MessageTemplate(t *testing.T) { + ctx := context.Background() + config := MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "template-test", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{}, // Match all + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + MessageTemplate: bifrost.Ptr("Hello from {{provider}} using model {{model}}"), + }, + }, + }, + }, + }, + } + plugin, err := Init(config) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + + account := BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + response, bifrostErr := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, + Model: "claude-3", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, test message"), + }, + }, + }, + }) + + if bifrostErr != nil { + t.Fatalf("Expected no error, got: %v", bifrostErr) + } + if response == nil { + t.Fatal("Expected response") + } + if len(response.Choices) == 0 { + t.Fatal("Expected at least one choice") + } + if response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr == nil { + t.Fatal("Expected content string") + } + expectedMessage := "Hello from anthropic using model claude-3" + if *response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr != expectedMessage { + t.Errorf("Expected '%s', got: %s", expectedMessage, *response.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr) + } +} + +// TestMockerPlugin_Statistics tests plugin statistics tracking +func TestMockerPlugin_Statistics(t *testing.T) { + ctx := context.Background() + config := MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "stats-test", + Enabled: true, + Priority: 100, + Probability: 1.0, + Conditions: Conditions{}, // Match all + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Stats test response", + }, + }, + }, + }, + }, + } + plugin, err := Init(config) + if err != nil { + t.Fatalf("Expected no error creating plugin, got: %v", err) + } + + account := BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: []schemas.Plugin{plugin}, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelError), + }) + if err != nil { + t.Fatalf("Error initializing Bifrost: %v", err) + } + defer client.Shutdown() + + // Make multiple requests + for i := 0; i < 3; i++ { + _, _ = client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, test message"), + }, + }, + }, + }) + } + + // Check statistics + stats := plugin.GetStats() + if stats.TotalRequests != 3 { + t.Errorf("Expected 3 total requests, got %d", stats.TotalRequests) + } + if stats.MockedRequests != 3 { + t.Errorf("Expected 3 mocked requests, got %d", stats.MockedRequests) + } + if stats.ResponsesGenerated != 3 { + t.Errorf("Expected 3 responses generated, got %d", stats.ResponsesGenerated) + } + if stats.RuleHits["stats-test"] != 3 { + t.Errorf("Expected 3 hits for 'stats-test' rule, got %d", stats.RuleHits["stats-test"]) + } +} + +// TestMockerPlugin_ValidationErrors tests configuration validation +func TestMockerPlugin_ValidationErrors(t *testing.T) { + tests := []struct { + name string + config MockerConfig + expectError bool + }{ + { + name: "invalid default behavior", + config: MockerConfig{ + Enabled: true, + DefaultBehavior: "invalid", + }, + expectError: true, + }, + { + name: "missing rule name", + config: MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "", // Missing name + Enabled: true, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "test", + }, + }, + }, + }, + }, + }, + expectError: true, + }, + { + name: "invalid probability", + config: MockerConfig{ + Enabled: true, + Rules: []MockRule{ + { + Name: "test", + Enabled: true, + Probability: 1.5, // Invalid probability > 1 + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "test", + }, + }, + }, + }, + }, + }, + expectError: true, + }, + { + name: "valid configuration", + config: MockerConfig{ + Enabled: true, + DefaultBehavior: DefaultBehaviorPassthrough, + Rules: []MockRule{ + { + Name: "valid-rule", + Enabled: true, + Probability: 0.5, + Responses: []Response{ + { + Type: ResponseTypeSuccess, + Content: &SuccessResponse{ + Message: "Valid response", + }, + }, + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Init(tt.config) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} diff --git a/plugins/mocker/version b/plugins/mocker/version new file mode 100644 index 000000000..f23616f6c --- /dev/null +++ b/plugins/mocker/version @@ -0,0 +1 @@ +1.3.27 \ No newline at end of file diff --git a/plugins/otel/changelog.md b/plugins/otel/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/otel/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/otel/client.go b/plugins/otel/client.go new file mode 100644 index 000000000..4f036aa54 --- /dev/null +++ b/plugins/otel/client.go @@ -0,0 +1,11 @@ +package otel + +import ( + "context" +) + +// OtelClient is the interface for the OpenTelemetry client +type OtelClient interface { + Emit(ctx context.Context, rs []*ResourceSpan) error + Close() error +} diff --git a/plugins/otel/converter.go b/plugins/otel/converter.go new file mode 100644 index 000000000..ab26f50e3 --- /dev/null +++ b/plugins/otel/converter.go @@ -0,0 +1,689 @@ +package otel + +import ( + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/modelcatalog" + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" +) + +// kvStr creates a key-value pair with a string value +func kvStr(k, v string) *KeyValue { + return &KeyValue{Key: k, Value: &AnyValue{Value: &StringValue{StringValue: v}}} +} + +// kvInt creates a key-value pair with an integer value +func kvInt(k string, v int64) *KeyValue { + return &KeyValue{Key: k, Value: &AnyValue{Value: &IntValue{IntValue: v}}} +} + +// kvDbl creates a key-value pair with a double value +func kvDbl(k string, v float64) *KeyValue { + return &KeyValue{Key: k, Value: &AnyValue{Value: &DoubleValue{DoubleValue: v}}} +} + +// kvBool creates a key-value pair with a boolean value +func kvBool(k string, v bool) *KeyValue { + return &KeyValue{Key: k, Value: &AnyValue{Value: &BoolValue{BoolValue: v}}} +} + +// kvAny creates a key-value pair with an any value +func kvAny(k string, v *AnyValue) *KeyValue { + return &KeyValue{Key: k, Value: v} +} + +// arrValue converts a list of any values to an OpenTelemetry array value +func arrValue(vals ...*AnyValue) *AnyValue { + return &AnyValue{Value: &ArrayValue{ArrayValue: &ArrayValueValue{Values: vals}}} +} + +// listValue converts a list of key-value pairs to an OpenTelemetry list value +func listValue(kvs ...*KeyValue) *AnyValue { + return &AnyValue{Value: &ListValue{KvlistValue: &KeyValueList{Values: kvs}}} +} + +// hexToBytes converts a hex string to bytes, padding/truncating as needed +func hexToBytes(hexStr string, length int) []byte { + // Remove any non-hex characters + cleaned := strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { + return r + } + return -1 + }, hexStr) + // Ensure even length + if len(cleaned)%2 != 0 { + cleaned = "0" + cleaned + } + // Truncate or pad to desired length + if len(cleaned) > length*2 { + cleaned = cleaned[:length*2] + } else if len(cleaned) < length*2 { + cleaned = strings.Repeat("0", length*2-len(cleaned)) + cleaned + } + bytes, _ := hex.DecodeString(cleaned) + return bytes +} + +// getSpeechRequestParams handles the speech request +func getSpeechRequestParams(req *schemas.BifrostSpeechRequest) []*KeyValue { + params := []*KeyValue{} + if req.Params != nil { + if req.Params.VoiceConfig != nil { + if req.Params.VoiceConfig.Voice != nil { + params = append(params, kvStr("gen_ai.request.voice", *req.Params.VoiceConfig.Voice)) + } + if len(req.Params.VoiceConfig.MultiVoiceConfig) > 0 { + multiVoiceConfigParams := []*KeyValue{} + for _, voiceConfig := range req.Params.VoiceConfig.MultiVoiceConfig { + multiVoiceConfigParams = append(multiVoiceConfigParams, kvStr("gen_ai.request.voice", voiceConfig.Voice)) + } + params = append(params, kvAny("gen_ai.request.multi_voice_config", arrValue(listValue(multiVoiceConfigParams...)))) + } + } + params = append(params, kvStr("gen_ai.request.instructions", req.Params.Instructions)) + params = append(params, kvStr("gen_ai.request.response_format", req.Params.ResponseFormat)) + if req.Params.Speed != nil { + params = append(params, kvDbl("gen_ai.request.speed", *req.Params.Speed)) + } + } + if req.Input != nil { + params = append(params, kvStr("gen_ai.input.speech", req.Input.Input)) + } + return params +} + +// getEmbeddingRequestParams handles the embedding request +func getEmbeddingRequestParams(req *schemas.BifrostEmbeddingRequest) []*KeyValue { + params := []*KeyValue{} + if req.Params != nil { + if req.Params.Dimensions != nil { + params = append(params, kvInt("gen_ai.request.dimensions", int64(*req.Params.Dimensions))) + } + if req.Params.ExtraParams != nil { + for k, v := range req.Params.ExtraParams { + params = append(params, kvStr(k, fmt.Sprintf("%v", v))) + } + } + if req.Params.EncodingFormat != nil { + params = append(params, kvStr("gen_ai.request.encoding_format", *req.Params.EncodingFormat)) + } + } + if req.Input.Text != nil { + params = append(params, kvStr("gen_ai.input.text", *req.Input.Text)) + } + if req.Input.Texts != nil { + params = append(params, kvStr("gen_ai.input.text", strings.Join(req.Input.Texts, ","))) + } + if req.Input.Embedding != nil { + embedding := make([]string, len(req.Input.Embedding)) + for i, v := range req.Input.Embedding { + embedding[i] = fmt.Sprintf("%d", v) + } + params = append(params, kvStr("gen_ai.input.embedding", strings.Join(embedding, ","))) + } + return params +} + +// getTextCompletionRequestParams handles the text completion request +func getTextCompletionRequestParams(req *schemas.BifrostTextCompletionRequest) []*KeyValue { + params := []*KeyValue{} + if req.Params != nil { + if req.Params.MaxTokens != nil { + params = append(params, kvInt("gen_ai.request.max_tokens", int64(*req.Params.MaxTokens))) + } + if req.Params.Temperature != nil { + params = append(params, kvDbl("gen_ai.request.temperature", *req.Params.Temperature)) + } + if req.Params.TopP != nil { + params = append(params, kvDbl("gen_ai.request.top_p", *req.Params.TopP)) + } + if req.Params.Stop != nil { + params = append(params, kvStr("gen_ai.request.stop_sequences", strings.Join(req.Params.Stop, ","))) + } + if req.Params.PresencePenalty != nil { + params = append(params, kvDbl("gen_ai.request.presence_penalty", *req.Params.PresencePenalty)) + } + if req.Params.FrequencyPenalty != nil { + params = append(params, kvDbl("gen_ai.request.frequency_penalty", *req.Params.FrequencyPenalty)) + } + if req.Params.BestOf != nil { + params = append(params, kvInt("gen_ai.request.best_of", int64(*req.Params.BestOf))) + } + if req.Params.Echo != nil { + params = append(params, kvBool("gen_ai.request.echo", *req.Params.Echo)) + } + if req.Params.LogitBias != nil { + params = append(params, kvStr("gen_ai.request.logit_bias", fmt.Sprintf("%v", req.Params.LogitBias))) + } + if req.Params.LogProbs != nil { + params = append(params, kvInt("gen_ai.request.logprobs", int64(*req.Params.LogProbs))) + } + if req.Params.N != nil { + params = append(params, kvInt("gen_ai.request.n", int64(*req.Params.N))) + } + if req.Params.Seed != nil { + params = append(params, kvInt("gen_ai.request.seed", int64(*req.Params.Seed))) + } + if req.Params.Suffix != nil { + params = append(params, kvStr("gen_ai.request.suffix", *req.Params.Suffix)) + } + if req.Params.User != nil { + params = append(params, kvStr("gen_ai.request.user", *req.Params.User)) + } + if req.Params.ExtraParams != nil { + for k, v := range req.Params.ExtraParams { + params = append(params, kvStr(k, fmt.Sprintf("%v", v))) + } + } + } + if req.Input.PromptStr != nil { + params = append(params, kvStr("gen_ai.input.text", *req.Input.PromptStr)) + } + if req.Input.PromptArray != nil { + params = append(params, kvStr("gen_ai.input.text", strings.Join(req.Input.PromptArray, ","))) + } + return params +} + +// getChatRequestParams handles the chat completion request +func getChatRequestParams(req *schemas.BifrostChatRequest) []*KeyValue { + params := []*KeyValue{} + if req.Params != nil { + if req.Params.MaxCompletionTokens != nil { + params = append(params, kvInt("gen_ai.request.max_tokens", int64(*req.Params.MaxCompletionTokens))) + } + if req.Params.Temperature != nil { + params = append(params, kvDbl("gen_ai.request.temperature", *req.Params.Temperature)) + } + if req.Params.TopP != nil { + params = append(params, kvDbl("gen_ai.request.top_p", *req.Params.TopP)) + } + if req.Params.Stop != nil { + params = append(params, kvStr("gen_ai.request.stop_sequences", strings.Join(req.Params.Stop, ","))) + } + if req.Params.PresencePenalty != nil { + params = append(params, kvDbl("gen_ai.request.presence_penalty", *req.Params.PresencePenalty)) + } + if req.Params.FrequencyPenalty != nil { + params = append(params, kvDbl("gen_ai.request.frequency_penalty", *req.Params.FrequencyPenalty)) + } + if req.Params.ParallelToolCalls != nil { + params = append(params, kvBool("gen_ai.request.parallel_tool_calls", *req.Params.ParallelToolCalls)) + } + if req.Params.User != nil { + params = append(params, kvStr("gen_ai.request.user", *req.Params.User)) + } + if req.Params.ExtraParams != nil { + for k, v := range req.Params.ExtraParams { + params = append(params, kvStr(k, fmt.Sprintf("%v", v))) + } + } + } + // Handling chat completion + if req.Input != nil { + messages := []*AnyValue{} + for _, message := range req.Input { + if message.Content == nil { + continue + } + switch message.Role { + case schemas.ChatMessageRoleUser: + kvs := []*KeyValue{kvStr("role", "user")} + if message.Content.ContentStr != nil { + kvs = append(kvs, kvStr("content", *message.Content.ContentStr)) + } + messages = append(messages, listValue(kvs...)) + case schemas.ChatMessageRoleAssistant: + kvs := []*KeyValue{kvStr("role", "assistant")} + if message.Content.ContentStr != nil { + kvs = append(kvs, kvStr("content", *message.Content.ContentStr)) + } + messages = append(messages, listValue(kvs...)) + case schemas.ChatMessageRoleSystem: + kvs := []*KeyValue{kvStr("role", "system")} + if message.Content.ContentStr != nil { + kvs = append(kvs, kvStr("content", *message.Content.ContentStr)) + } + messages = append(messages, listValue(kvs...)) + case schemas.ChatMessageRoleTool: + kvs := []*KeyValue{kvStr("role", "tool")} + if message.Content.ContentStr != nil { + kvs = append(kvs, kvStr("content", *message.Content.ContentStr)) + } + messages = append(messages, listValue(kvs...)) + case schemas.ChatMessageRoleDeveloper: + kvs := []*KeyValue{kvStr("role", "developer")} + if message.Content.ContentStr != nil { + kvs = append(kvs, kvStr("content", *message.Content.ContentStr)) + } + messages = append(messages, listValue(kvs...)) + } + } + params = append(params, kvAny("gen_ai.input.messages", arrValue(messages...))) + } + return params +} + +// getTranscriptionRequestParams handles the transcription request +func getTranscriptionRequestParams(req *schemas.BifrostTranscriptionRequest) []*KeyValue { + params := []*KeyValue{} + if req.Params != nil { + if req.Params.Language != nil { + params = append(params, kvStr("gen_ai.request.language", *req.Params.Language)) + } + if req.Params.Prompt != nil { + params = append(params, kvStr("gen_ai.request.prompt", *req.Params.Prompt)) + } + if req.Params.ResponseFormat != nil { + params = append(params, kvStr("gen_ai.request.response_format", *req.Params.ResponseFormat)) + } + if req.Params.Format != nil { + params = append(params, kvStr("gen_ai.request.format", *req.Params.Format)) + } + } + return params +} + +// getResponsesRequestParams handles the responses request +func getResponsesRequestParams(req *schemas.BifrostResponsesRequest) []*KeyValue { + params := []*KeyValue{} + if req.Params != nil { + if req.Params.ParallelToolCalls != nil { + params = append(params, kvBool("gen_ai.request.parallel_tool_calls", *req.Params.ParallelToolCalls)) + } + if req.Params.PromptCacheKey != nil { + params = append(params, kvStr("gen_ai.request.prompt_cache_key", *req.Params.PromptCacheKey)) + } + if req.Params.Reasoning != nil { + if req.Params.Reasoning.Effort != nil { + params = append(params, kvStr("gen_ai.request.reasoning_effort", *req.Params.Reasoning.Effort)) + } + if req.Params.Reasoning.Summary != nil { + params = append(params, kvStr("gen_ai.request.reasoning_summary", *req.Params.Reasoning.Summary)) + } + if req.Params.Reasoning.GenerateSummary != nil { + params = append(params, kvStr("gen_ai.request.reasoning_generate_summary", *req.Params.Reasoning.GenerateSummary)) + } + } + if req.Params.SafetyIdentifier != nil { + params = append(params, kvStr("gen_ai.request.safety_identifier", *req.Params.SafetyIdentifier)) + } + if req.Params.ServiceTier != nil { + params = append(params, kvStr("gen_ai.request.service_tier", *req.Params.ServiceTier)) + } + if req.Params.Store != nil { + params = append(params, kvBool("gen_ai.request.store", *req.Params.Store)) + } + if req.Params.Temperature != nil { + params = append(params, kvDbl("gen_ai.request.temperature", *req.Params.Temperature)) + } + if req.Params.Text != nil { + if req.Params.Text.Verbosity != nil { + params = append(params, kvStr("gen_ai.request.text", *req.Params.Text.Verbosity)) + } + if req.Params.Text.Format != nil { + params = append(params, kvStr("gen_ai.request.text_format_type", req.Params.Text.Format.Type)) + } + + } + if req.Params.TopLogProbs != nil { + params = append(params, kvInt("gen_ai.request.top_logprobs", int64(*req.Params.TopLogProbs))) + } + if req.Params.TopP != nil { + params = append(params, kvDbl("gen_ai.request.top_p", *req.Params.TopP)) + } + if req.Params.ToolChoice != nil { + if req.Params.ToolChoice.ResponsesToolChoiceStr != nil && *req.Params.ToolChoice.ResponsesToolChoiceStr != "" { + params = append(params, kvStr("gen_ai.request.tool_choice_type", *req.Params.ToolChoice.ResponsesToolChoiceStr)) + } + if req.Params.ToolChoice.ResponsesToolChoiceStruct != nil && req.Params.ToolChoice.ResponsesToolChoiceStruct.Name != nil { + params = append(params, kvStr("gen_ai.request.tool_choice_name", *req.Params.ToolChoice.ResponsesToolChoiceStruct.Name)) + } + + } + if req.Params.Tools != nil { + tools := make([]string, len(req.Params.Tools)) + for i, tool := range req.Params.Tools { + tools[i] = string(tool.Type) + } + params = append(params, kvStr("gen_ai.request.tools", strings.Join(tools, ","))) + } + if req.Params.Truncation != nil { + params = append(params, kvStr("gen_ai.request.truncation", *req.Params.Truncation)) + } + if req.Params.ExtraParams != nil { + for k, v := range req.Params.ExtraParams { + params = append(params, kvStr(k, fmt.Sprintf("%v", v))) + } + } + } + return params +} + +// createResourceSpan creates a new resource span for a Bifrost request +func createResourceSpan(traceID, spanID string, timestamp time.Time, req *schemas.BifrostRequest) *ResourceSpan { + provider, model, _ := req.GetRequestFields() + + // preparing parameters + params := []*KeyValue{} + spanName := "span" + params = append(params, kvStr("gen_ai.provider.name", string(provider))) + params = append(params, kvStr("gen_ai.request.model", model)) + // Preparing parameters + switch req.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + spanName = "gen_ai.text" + params = append(params, getTextCompletionRequestParams(req.TextCompletionRequest)...) + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + spanName = "gen_ai.chat" + params = append(params, getChatRequestParams(req.ChatRequest)...) + case schemas.EmbeddingRequest: + spanName = "gen_ai.embedding" + params = append(params, getEmbeddingRequestParams(req.EmbeddingRequest)...) + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + spanName = "gen_ai.transcription" + params = append(params, getTranscriptionRequestParams(req.TranscriptionRequest)...) + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + spanName = "gen_ai.speech" + params = append(params, getSpeechRequestParams(req.SpeechRequest)...) + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + spanName = "gen_ai.responses" + params = append(params, getResponsesRequestParams(req.ResponsesRequest)...) + } + // Preparing final resource span + return &ResourceSpan{ + Resource: &resourcepb.Resource{ + Attributes: []*commonpb.KeyValue{ + kvStr("service.name", "bifrost"), + kvStr("service.version", "1.0.0"), + }, + }, + ScopeSpans: []*ScopeSpan{ + { + Scope: &commonpb.InstrumentationScope{ + Name: "bifrost-otel-plugin", + }, + Spans: []*Span{ + { + TraceId: hexToBytes(traceID, 16), + SpanId: hexToBytes(spanID, 8), + Kind: tracepb.Span_SPAN_KIND_SERVER, + StartTimeUnixNano: uint64(timestamp.UnixNano()), + EndTimeUnixNano: uint64(timestamp.UnixNano()), + Name: spanName, + Attributes: params, + }, + }, + }, + }, + } +} + +// completeResourceSpan completes a resource span for a Bifrost response +func completeResourceSpan( + span *ResourceSpan, + timestamp time.Time, + resp *schemas.BifrostResponse, + bifrostErr *schemas.BifrostError, + pricingManager *modelcatalog.ModelCatalog, + virtualKeyID string, + virtualKeyName string, + selectedKeyID string, + selectedKeyName string, + numberOfRetries int, + fallbackIndex int, +) *ResourceSpan { + params := []*KeyValue{} + + if resp != nil { + switch { // Accumulator wont return stream type responses + case resp.TextCompletionResponse != nil: + params = append(params, kvStr("gen_ai.text.id", resp.TextCompletionResponse.ID)) + params = append(params, kvStr("gen_ai.text.model", resp.TextCompletionResponse.Model)) + params = append(params, kvStr("gen_ai.text.object", resp.TextCompletionResponse.Object)) + params = append(params, kvStr("gen_ai.text.system_fingerprint", resp.TextCompletionResponse.SystemFingerprint)) + outputMessages := []*AnyValue{} + for _, choice := range resp.TextCompletionResponse.Choices { + if choice.TextCompletionResponseChoice == nil { + continue + } + kvs := []*KeyValue{kvStr("role", string(schemas.ChatMessageRoleAssistant))} + if choice.TextCompletionResponseChoice != nil && choice.TextCompletionResponseChoice.Text != nil { + kvs = append(kvs, kvStr("content", *choice.TextCompletionResponseChoice.Text)) + } + outputMessages = append(outputMessages, listValue(kvs...)) + } + params = append(params, kvAny("gen_ai.text.output_messages", arrValue(outputMessages...))) + if resp.TextCompletionResponse.Usage != nil { + params = append(params, kvInt("gen_ai.usage.prompt_tokens", int64(resp.TextCompletionResponse.Usage.PromptTokens))) + params = append(params, kvInt("gen_ai.usage.completion_tokens", int64(resp.TextCompletionResponse.Usage.CompletionTokens))) + params = append(params, kvInt("gen_ai.usage.total_tokens", int64(resp.TextCompletionResponse.Usage.TotalTokens))) + } + // Computing cost + if pricingManager != nil { + cost := pricingManager.CalculateCostWithCacheDebug(resp) + params = append(params, kvDbl("gen_ai.usage.cost", cost)) + } + case resp.ChatResponse != nil: + params = append(params, kvStr("gen_ai.chat.id", resp.ChatResponse.ID)) + params = append(params, kvStr("gen_ai.chat.model", resp.ChatResponse.Model)) + params = append(params, kvStr("gen_ai.chat.object", resp.ChatResponse.Object)) + params = append(params, kvStr("gen_ai.chat.system_fingerprint", resp.ChatResponse.SystemFingerprint)) + params = append(params, kvStr("gen_ai.chat.created", fmt.Sprintf("%d", resp.ChatResponse.Created))) + params = append(params, kvStr("gen_ai.chat.service_tier", resp.ChatResponse.ServiceTier)) + outputMessages := []*AnyValue{} + for _, choice := range resp.ChatResponse.Choices { + var role string + if choice.ChatNonStreamResponseChoice != nil && choice.ChatNonStreamResponseChoice.Message != nil && choice.ChatNonStreamResponseChoice.Message.Role != "" { + role = string(choice.ChatNonStreamResponseChoice.Message.Role) + } else { + role = string(schemas.ChatMessageRoleAssistant) + } + kvs := []*KeyValue{kvStr("role", role)} + + if choice.ChatNonStreamResponseChoice != nil && + choice.ChatNonStreamResponseChoice.Message != nil && + choice.ChatNonStreamResponseChoice.Message.Content != nil { + if choice.ChatNonStreamResponseChoice.Message.Content.ContentStr != nil { + kvs = append(kvs, kvStr("content", *choice.ChatNonStreamResponseChoice.Message.Content.ContentStr)) + } else if choice.ChatNonStreamResponseChoice.Message.Content.ContentBlocks != nil { + blockText := "" + for _, block := range choice.ChatNonStreamResponseChoice.Message.Content.ContentBlocks { + if block.Text != nil { + blockText += *block.Text + } + } + kvs = append(kvs, kvStr("content", blockText)) + } + } + outputMessages = append(outputMessages, listValue(kvs...)) + } + params = append(params, kvAny("gen_ai.chat.output_messages", arrValue(outputMessages...))) + if resp.ChatResponse.Usage != nil { + params = append(params, kvInt("gen_ai.usage.prompt_tokens", int64(resp.ChatResponse.Usage.PromptTokens))) + params = append(params, kvInt("gen_ai.usage.completion_tokens", int64(resp.ChatResponse.Usage.CompletionTokens))) + params = append(params, kvInt("gen_ai.usage.total_tokens", int64(resp.ChatResponse.Usage.TotalTokens))) + } + // Computing cost + if pricingManager != nil { + cost := pricingManager.CalculateCostWithCacheDebug(resp) + params = append(params, kvDbl("gen_ai.usage.cost", cost)) + } + case resp.ResponsesResponse != nil: + outputMessages := []*AnyValue{} + for _, message := range resp.ResponsesResponse.Output { + if message.Role == nil { + continue + } + kvs := []*KeyValue{kvStr("role", string(*message.Role))} + if message.Content != nil { + if message.Content.ContentStr != nil && *message.Content.ContentStr != "" { + kvs = append(kvs, kvStr("content", *message.Content.ContentStr)) + } else if message.Content.ContentBlocks != nil { + blockText := "" + for _, block := range message.Content.ContentBlocks { + if block.Text != nil { + blockText += *block.Text + } + } + kvs = append(kvs, kvStr("content", blockText)) + } + } + if message.ResponsesReasoning != nil && message.ResponsesReasoning.Summary != nil { + reasoningText := "" + for _, block := range message.ResponsesReasoning.Summary { + if block.Text != "" { + reasoningText += block.Text + } + } + kvs = append(kvs, kvStr("reasoning", reasoningText)) + } + outputMessages = append(outputMessages, listValue(kvs...)) + + } + params = append(params, kvAny("gen_ai.responses.output_messages", arrValue(outputMessages...))) + + responsesResponse := resp.ResponsesResponse + if responsesResponse.Include != nil { + params = append(params, kvStr("gen_ai.responses.include", strings.Join(responsesResponse.Include, ","))) + } + if responsesResponse.MaxOutputTokens != nil { + params = append(params, kvInt("gen_ai.responses.max_output_tokens", int64(*responsesResponse.MaxOutputTokens))) + } + if responsesResponse.MaxToolCalls != nil { + params = append(params, kvInt("gen_ai.responses.max_tool_calls", int64(*responsesResponse.MaxToolCalls))) + } + if responsesResponse.Metadata != nil { + params = append(params, kvStr("gen_ai.responses.metadata", fmt.Sprintf("%v", responsesResponse.Metadata))) + } + if responsesResponse.PreviousResponseID != nil { + params = append(params, kvStr("gen_ai.responses.previous_response_id", *responsesResponse.PreviousResponseID)) + } + if responsesResponse.PromptCacheKey != nil { + params = append(params, kvStr("gen_ai.responses.prompt_cache_key", *responsesResponse.PromptCacheKey)) + } + if responsesResponse.Reasoning != nil { + if responsesResponse.Reasoning.Summary != nil { + params = append(params, kvStr("gen_ai.responses.reasoning", *responsesResponse.Reasoning.Summary)) + } + if responsesResponse.Reasoning.Effort != nil { + params = append(params, kvStr("gen_ai.responses.reasoning_effort", *responsesResponse.Reasoning.Effort)) + } + if responsesResponse.Reasoning.GenerateSummary != nil { + params = append(params, kvStr("gen_ai.responses.reasoning_generate_summary", *responsesResponse.Reasoning.GenerateSummary)) + } + } + if responsesResponse.SafetyIdentifier != nil { + params = append(params, kvStr("gen_ai.responses.safety_identifier", *responsesResponse.SafetyIdentifier)) + } + if responsesResponse.ServiceTier != nil { + params = append(params, kvStr("gen_ai.responses.service_tier", *responsesResponse.ServiceTier)) + } + if responsesResponse.Store != nil { + params = append(params, kvBool("gen_ai.responses.store", *responsesResponse.Store)) + } + if responsesResponse.Temperature != nil { + params = append(params, kvDbl("gen_ai.responses.temperature", *responsesResponse.Temperature)) + } + if responsesResponse.Text != nil { + if responsesResponse.Text.Verbosity != nil { + params = append(params, kvStr("gen_ai.responses.text", *responsesResponse.Text.Verbosity)) + } + if responsesResponse.Text.Format != nil { + params = append(params, kvStr("gen_ai.responses.text_format_type", responsesResponse.Text.Format.Type)) + } + } + if responsesResponse.TopLogProbs != nil { + params = append(params, kvInt("gen_ai.responses.top_logprobs", int64(*responsesResponse.TopLogProbs))) + } + if responsesResponse.TopP != nil { + params = append(params, kvDbl("gen_ai.responses.top_p", *responsesResponse.TopP)) + } + if responsesResponse.ToolChoice != nil { + if responsesResponse.ToolChoice.ResponsesToolChoiceStruct != nil && responsesResponse.ToolChoice.ResponsesToolChoiceStr != nil { + params = append(params, kvStr("gen_ai.responses.tool_choice_type", *responsesResponse.ToolChoice.ResponsesToolChoiceStr)) + } + if responsesResponse.ToolChoice.ResponsesToolChoiceStruct != nil && responsesResponse.ToolChoice.ResponsesToolChoiceStruct.Name != nil { + params = append(params, kvStr("gen_ai.responses.tool_choice_name", *responsesResponse.ToolChoice.ResponsesToolChoiceStruct.Name)) + } + } + if responsesResponse.Truncation != nil { + params = append(params, kvStr("gen_ai.responses.truncation", *responsesResponse.Truncation)) + } + if responsesResponse.Tools != nil { + tools := make([]string, len(responsesResponse.Tools)) + for i, tool := range responsesResponse.Tools { + tools[i] = string(tool.Type) + } + params = append(params, kvStr("gen_ai.responses.tools", strings.Join(tools, ","))) + } + case resp.EmbeddingResponse != nil: + if resp.EmbeddingResponse.Usage != nil { + params = append(params, kvInt("gen_ai.usage.prompt_tokens", int64(resp.EmbeddingResponse.Usage.PromptTokens))) + params = append(params, kvInt("gen_ai.usage.completion_tokens", int64(resp.EmbeddingResponse.Usage.CompletionTokens))) + params = append(params, kvInt("gen_ai.usage.total_tokens", int64(resp.EmbeddingResponse.Usage.TotalTokens))) + } + case resp.SpeechResponse != nil: + if resp.SpeechResponse.Usage != nil { + params = append(params, kvInt("gen_ai.usage.input_tokens", int64(resp.SpeechResponse.Usage.InputTokens))) + params = append(params, kvInt("gen_ai.usage.output_tokens", int64(resp.SpeechResponse.Usage.OutputTokens))) + params = append(params, kvInt("gen_ai.usage.total_tokens", int64(resp.SpeechResponse.Usage.TotalTokens))) + } + case resp.TranscriptionResponse != nil: + outputMessages := []*AnyValue{} + kvs := []*KeyValue{kvStr("text", resp.TranscriptionResponse.Text)} + outputMessages = append(outputMessages, listValue(kvs...)) + params = append(params, kvAny("gen_ai.transcribe.output_messages", arrValue(outputMessages...))) + if resp.TranscriptionResponse.Usage != nil { + if resp.TranscriptionResponse.Usage.InputTokens != nil { + params = append(params, kvInt("gen_ai.usage.input_tokens", int64(*resp.TranscriptionResponse.Usage.InputTokens))) + } + if resp.TranscriptionResponse.Usage.OutputTokens != nil { + params = append(params, kvInt("gen_ai.usage.completion_tokens", int64(*resp.TranscriptionResponse.Usage.OutputTokens))) + } + if resp.TranscriptionResponse.Usage.TotalTokens != nil { + params = append(params, kvInt("gen_ai.usage.total_tokens", int64(*resp.TranscriptionResponse.Usage.TotalTokens))) + } + if resp.TranscriptionResponse.Usage.InputTokenDetails != nil { + params = append(params, kvInt("gen_ai.usage.input_token_details.text_tokens", int64(resp.TranscriptionResponse.Usage.InputTokenDetails.TextTokens))) + params = append(params, kvInt("gen_ai.usage.input_token_details.audio_tokens", int64(resp.TranscriptionResponse.Usage.InputTokenDetails.AudioTokens))) + } + } + } + } + + // This is a fallback for worst case scenario where latency is not available + status := tracepb.Status_STATUS_CODE_OK + if bifrostErr != nil { + status = tracepb.Status_STATUS_CODE_ERROR + if bifrostErr.Error.Type != nil { + params = append(params, kvStr("gen_ai.error.type", *bifrostErr.Error.Type)) + } + if bifrostErr.Error.Code != nil { + params = append(params, kvStr("gen_ai.error.code", *bifrostErr.Error.Code)) + } + params = append(params, kvStr("gen_ai.error", bifrostErr.Error.Message)) + } + // Adding request metadata to the span + if virtualKeyID != "" { + params = append(params, kvStr("gen_ai.virtual_key_id", virtualKeyID)) + params = append(params, kvStr("gen_ai.virtual_key_name", virtualKeyName)) + } + if selectedKeyID != "" { + params = append(params, kvStr("gen_ai.selected_key_id", selectedKeyID)) + params = append(params, kvStr("gen_ai.selected_key_name", selectedKeyName)) + } + params = append(params, kvInt("gen_ai.number_of_retries", int64(numberOfRetries))) + params = append(params, kvInt("gen_ai.fallback_index", int64(fallbackIndex))) + span.ScopeSpans[0].Spans[0].Attributes = append(span.ScopeSpans[0].Spans[0].Attributes, params...) + span.ScopeSpans[0].Spans[0].Status = &tracepb.Status{Code: status} + span.ScopeSpans[0].Spans[0].EndTimeUnixNano = uint64(timestamp.UnixNano()) + return span +} diff --git a/plugins/otel/docker-compose.yml b/plugins/otel/docker-compose.yml new file mode 100644 index 000000000..ff9f4c23f --- /dev/null +++ b/plugins/otel/docker-compose.yml @@ -0,0 +1,229 @@ +services: + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: otel-collector + command: ["--config=/etc/otelcol/config.yaml"] + configs: + - source: otel-collector-config + target: /etc/otelcol/config.yaml + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "8888:8888" # Collector /metrics + - "9464:9464" # Prometheus scrape endpoint + - "13133:13133" # Health check + - "1777:1777" # pprof + - "55679:55679" # zpages + restart: unless-stopped + depends_on: + - tempo + + tempo: + image: grafana/tempo:latest + container_name: tempo + command: ["-config.file=/etc/tempo.yaml"] + configs: + - source: tempo-config + target: /etc/tempo.yaml + ports: + - "3200:3200" # tempo HTTP/gRPC API (multiplexed) + expose: + - "4317" # OTLP gRPC (internal) + volumes: + - tempo-data:/var/tempo + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + depends_on: + - otel-collector + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + - "--web.enable-remote-write-receiver" + ports: + - "9090:9090" + volumes: + - prometheus-data:/prometheus + configs: + - source: prometheus-config + target: /etc/prometheus/prometheus.yml + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + depends_on: + - prometheus + - tempo + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "grafana-pyroscope-app,grafana-exploretraces-app,grafana-metricsdrilldown-app" + GF_PLUGINS_ENABLE_ALPHA: "true" + GF_INSTALL_PLUGINS: "" + GF_LOG_LEVEL: "warn" + GF_FEATURE_TOGGLES_ENABLE: "" + ports: + - "4000:3000" + volumes: + - grafana-data:/var/lib/grafana + configs: + - source: grafana-datasources + target: /etc/grafana/provisioning/datasources/datasources.yml + restart: unless-stopped + +configs: + otel-collector-config: + content: | + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + + processors: + batch: + + exporters: + prometheus: + endpoint: 0.0.0.0:9464 + namespace: otel + const_labels: + source: otelcol + + otlp/tempo: + endpoint: tempo:4317 + tls: + insecure: true + + debug: + verbosity: detailed + + extensions: + health_check: + endpoint: 0.0.0.0:13133 + pprof: + endpoint: 0.0.0.0:1777 + zpages: + endpoint: 0.0.0.0:55679 + + service: + extensions: [health_check, pprof, zpages] + telemetry: + logs: + level: debug + metrics: + level: detailed + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlp/tempo] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug, prometheus] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] + + tempo-config: + content: | + server: + http_listen_port: 3200 + grpc_listen_port: 3201 + log_level: info + + distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + + ingester: + max_block_duration: 5m + trace_idle_period: 10s + + compactor: + compaction: + block_retention: 1h + + storage: + trace: + backend: local + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + + metrics_generator: + registry: + external_labels: + source: tempo + storage: + path: /var/tempo/generator/wal + remote_write: + - url: http://prometheus:9090/api/v1/write + + prometheus-config: + content: | + global: + scrape_interval: 15s + scrape_configs: + - job_name: "otelcol-internal" + static_configs: + - targets: ["otel-collector:8888"] + - job_name: "otelcol-exporter" + static_configs: + - targets: ["otel-collector:9464"] + - job_name: "tempo" + static_configs: + - targets: ["tempo:3200"] + + grafana-datasources: + content: | + apiVersion: 1 + datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + orgId: 1 + url: http://prometheus:9090 + isDefault: true + editable: true + - name: Tempo + uid: tempo + type: tempo + access: proxy + orgId: 1 + url: http://tempo:3200 + editable: true + jsonData: + nodeGraph: + enabled: true + tracesToLogs: + datasourceUid: prometheus + tracesToMetrics: + datasourceUid: prometheus + serviceMap: + datasourceUid: prometheus + search: + hide: false + lokiSearch: + datasourceUid: prometheus + +volumes: + prometheus-data: + grafana-data: + tempo-data: diff --git a/plugins/otel/go.mod b/plugins/otel/go.mod new file mode 100644 index 000000000..2394bad72 --- /dev/null +++ b/plugins/otel/go.mod @@ -0,0 +1,115 @@ +module github.com/maximhq/bifrost/plugins/otel + +go 1.24.1 + +toolchain go1.24.3 + +require ( + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 + google.golang.org/grpc v1.76.0 + google.golang.org/protobuf v1.36.10 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) + +require ( + github.com/bytedance/sonic v1.14.1 + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + go.opentelemetry.io/proto/otlp v1.8.0 + golang.org/x/arch v0.22.0 // indirect + golang.org/x/sys v0.37.0 // indirect +) diff --git a/plugins/otel/go.sum b/plugins/otel/go.sum new file mode 100644 index 000000000..cab34ff79 --- /dev/null +++ b/plugins/otel/go.sum @@ -0,0 +1,261 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= +go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/plugins/otel/grpc.go b/plugins/otel/grpc.go new file mode 100644 index 000000000..eb6c0fe53 --- /dev/null +++ b/plugins/otel/grpc.go @@ -0,0 +1,43 @@ +package otel + +import ( + "context" + + collectorpb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +// OtelClientGRPC is the implementation of the OpenTelemetry client for gRPC +type OtelClientGRPC struct { + client collectorpb.TraceServiceClient + conn *grpc.ClientConn + headers map[string]string +} + +// NewOtelClientGRPC creates a new OpenTelemetry client for gRPC +func NewOtelClientGRPC(endpoint string, headers map[string]string) (*OtelClientGRPC, error) { + conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + return &OtelClientGRPC{client: collectorpb.NewTraceServiceClient(conn), conn: conn}, nil +} + +// Emit sends a trace to the OpenTelemetry collector +func (c *OtelClientGRPC) Emit(ctx context.Context, rs []*ResourceSpan) error { + if c.headers != nil { + ctx = metadata.NewOutgoingContext(ctx, metadata.New(c.headers)) + } + _, err := c.client.Export(ctx, &collectorpb.ExportTraceServiceRequest{ResourceSpans: rs}) + return err +} + +// Close closes the gRPC connection +func (c *OtelClientGRPC) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} diff --git a/plugins/otel/http.go b/plugins/otel/http.go new file mode 100644 index 000000000..3c2fd6cdd --- /dev/null +++ b/plugins/otel/http.go @@ -0,0 +1,81 @@ +package otel + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + collectorpb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/protobuf/proto" +) + +// OtelClientHTTP is the implementation of the OpenTelemetry client for HTTP +type OtelClientHTTP struct { + client *http.Client + endpoint string + headers map[string]string +} + +// NewOtelClientHTTP creates a new OpenTelemetry client for HTTP +func NewOtelClientHTTP(endpoint string, headers map[string]string) (*OtelClientHTTP, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 120 * time.Second + + return &OtelClientHTTP{client: &http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + }, endpoint: endpoint, headers: headers}, nil +} + +// Emit sends a trace to the OpenTelemetry collector +func (c *OtelClientHTTP) Emit(ctx context.Context, rs []*ResourceSpan) error { + payload, err := proto.Marshal(&collectorpb.ExportTraceServiceRequest{ResourceSpans: rs}) + if err != nil { + logger.Error("[otel] failed to marshal trace: %v", err) + return err + } + var body bytes.Buffer + body.Write(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, &body) + if err != nil { + logger.Error("[otel] failed to create request: %v", err) + return err + } + req.Header.Set("Content-Type", "application/x-protobuf") + if c.headers != nil { + for key, value := range c.headers { + if strings.ToLower(key) == "content-type" { + continue + } + req.Header.Set(key, value) + } + } + resp, err := c.client.Do(req) + if err != nil { + logger.Error("[otel] failed to send request to %s: %v", c.endpoint, err) + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + // Discard the body to avoid leaking memory + _, _ = io.Copy(io.Discard, resp.Body) + logger.Error("[otel] collector at %s returned status %s", c.endpoint, resp.Status) + return fmt.Errorf("collector returned %s", resp.Status) + } + logger.Debug("[otel] successfully sent trace to %s, status: %s", c.endpoint, resp.Status) + return nil +} + +// Close closes the HTTP client +func (c *OtelClientHTTP) Close() error { + if c.client != nil { + c.client.CloseIdleConnections() + } + return nil +} diff --git a/plugins/otel/main.go b/plugins/otel/main.go new file mode 100644 index 000000000..ad528bc1c --- /dev/null +++ b/plugins/otel/main.go @@ -0,0 +1,300 @@ +// Package otel is OpenTelemetry plugin for Bifrost +package otel + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/bytedance/sonic" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/modelcatalog" + "github.com/maximhq/bifrost/framework/streaming" +) + +// logger is the logger for the OTEL plugin +var logger schemas.Logger + +// ContextKey is a custom type for context keys to prevent collisions +type ContextKey string + +// Context keys for otel plugin +const ( + TraceIDKey ContextKey = "plugin-otel-trace-id" + SpanIDKey ContextKey = "plugin-otel-span-id" +) + +const PluginName = "otel" + +// TraceType is the type of trace to use for the OTEL collector +type TraceType string + +// TraceTypeGenAIExtension is the type of trace to use for the OTEL collector +const TraceTypeGenAIExtension TraceType = "genai_extension" + +// TraceTypeVercel is the type of trace to use for the OTEL collector +const TraceTypeVercel TraceType = "vercel" + +// TraceTypeOpenInference is the type of trace to use for the OTEL collector +const TraceTypeOpenInference TraceType = "open_inference" + +// Protocol is the protocol to use for the OTEL collector +type Protocol string + +// ProtocolHTTP is the default protocol +const ProtocolHTTP Protocol = "http" + +// ProtocolGRPC is the second protocol +const ProtocolGRPC Protocol = "grpc" + +type Config struct { + CollectorURL string `json:"collector_url"` + Headers map[string]string `json:"headers"` + TraceType TraceType `json:"trace_type"` + Protocol Protocol `json:"protocol"` +} + +// OtelPlugin is the plugin for OpenTelemetry +type OtelPlugin struct { + ctx context.Context + cancel context.CancelFunc + + url string + headers map[string]string + traceType TraceType + protocol Protocol + + ongoingSpans *TTLSyncMap + + client OtelClient + + pricingManager *modelcatalog.ModelCatalog + accumulator *streaming.Accumulator // Accumulator for streaming chunks + + emitWg sync.WaitGroup // Track in-flight emissions +} + +// Init function for the OTEL plugin +func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingManager *modelcatalog.ModelCatalog) (*OtelPlugin, error) { + if config == nil { + return nil, fmt.Errorf("config is required") + } + logger = _logger + if pricingManager == nil { + logger.Warn("otel plugin requires model catalog to calculate cost, all cost calculations will be skipped.") + } + var err error + // If headers are present , and any of them start with env., we will replace the value with the environment variable + if config.Headers != nil { + for key, value := range config.Headers { + if newValue, ok := strings.CutPrefix(value, "env."); ok { + config.Headers[key] = os.Getenv(newValue) + if config.Headers[key] == "" { + logger.Warn("environment variable %s not found", newValue) + return nil, fmt.Errorf("environment variable %s not found", newValue) + } + } + } + } + p := &OtelPlugin{ + url: config.CollectorURL, + traceType: config.TraceType, + headers: config.Headers, + ongoingSpans: NewTTLSyncMap(20*time.Minute, 1*time.Minute), + protocol: config.Protocol, + pricingManager: pricingManager, + accumulator: streaming.NewAccumulator(pricingManager, logger), + emitWg: sync.WaitGroup{}, + } + p.ctx, p.cancel = context.WithCancel(ctx) + if config.Protocol == ProtocolGRPC { + p.client, err = NewOtelClientGRPC(config.CollectorURL, config.Headers) + if err != nil { + return nil, err + } + } + if config.Protocol == ProtocolHTTP { + p.client, err = NewOtelClientHTTP(config.CollectorURL, config.Headers) + if err != nil { + return nil, err + } + } + if p.client == nil { + return nil, fmt.Errorf("otel client is not initialized. invalid protocol type") + } + return p, nil +} + +// GetName function for the OTEL plugin +func (p *OtelPlugin) GetName() string { + return PluginName +} + +// TransportInterceptor is not used for this plugin +func (p *OtelPlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// ValidateConfig function for the OTEL plugin +func (p *OtelPlugin) ValidateConfig(config any) (*Config, error) { + var otelConfig Config + // Checking if its a string, then we will JSON parse and confirm + if configStr, ok := config.(string); ok { + if err := sonic.Unmarshal([]byte(configStr), &otelConfig); err != nil { + return nil, err + } + } + // Checking if its a map[string]any, then we will JSON parse and confirm + if configMap, ok := config.(map[string]any); ok { + configString, err := sonic.Marshal(configMap) + if err != nil { + return nil, err + } + if err := sonic.Unmarshal([]byte(configString), &otelConfig); err != nil { + return nil, err + } + } + // Checking if its a Config, then we will confirm + if config, ok := config.(*Config); ok { + otelConfig = *config + } + // Validating fields + if otelConfig.CollectorURL == "" { + return nil, fmt.Errorf("collector url is required") + } + if otelConfig.TraceType == "" { + return nil, fmt.Errorf("trace type is required") + } + if otelConfig.Protocol == "" { + return nil, fmt.Errorf("protocol is required") + } + return &otelConfig, nil +} + +// PreHook function for the OTEL plugin +func (p *OtelPlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + if p.client == nil { + logger.Warn("otel client is not initialized") + return req, nil, nil + } + traceIDValue := (*ctx).Value(schemas.BifrostContextKeyRequestID) + if traceIDValue == nil { + logger.Warn("trace id not found in context") + return req, nil, nil + } + traceID, ok := traceIDValue.(string) + if !ok { + logger.Warn("trace id not found in context") + return req, nil, nil + } + spanID := fmt.Sprintf("%s-root-span", traceID) + createdTimestamp := time.Now() + if bifrost.IsStreamRequestType(req.RequestType) { + p.accumulator.CreateStreamAccumulator(traceID, createdTimestamp) + } + p.ongoingSpans.Set(traceID, createResourceSpan(traceID, spanID, time.Now(), req)) + return req, nil, nil +} + +// PostHook function for the OTEL plugin +func (p *OtelPlugin) PostHook(ctx *context.Context, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + traceIDValue := (*ctx).Value(schemas.BifrostContextKeyRequestID) + if traceIDValue == nil { + logger.Warn("trace id not found in context") + return resp, bifrostErr, nil + } + traceID, ok := traceIDValue.(string) + if !ok { + logger.Warn("trace id not found in context") + return resp, bifrostErr, nil + } + + virtualKeyID := bifrost.GetStringFromContext(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-id")) + virtualKeyName := bifrost.GetStringFromContext(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-name")) + + selectedKeyID := bifrost.GetStringFromContext(*ctx, schemas.BifrostContextKeySelectedKeyID) + selectedKeyName := bifrost.GetStringFromContext(*ctx, schemas.BifrostContextKeySelectedKeyName) + + numberOfRetries := bifrost.GetIntFromContext(*ctx, schemas.BifrostContextKeyNumberOfRetries) + fallbackIndex := bifrost.GetIntFromContext(*ctx, schemas.BifrostContextKeyFallbackIndex) + + // Track every PostHook emission, stream and non-stream. + p.emitWg.Add(1) + go func() { + defer p.emitWg.Done() + span, ok := p.ongoingSpans.Get(traceID) + if !ok { + logger.Warn("span not found in ongoing spans") + return + } + requestType, _, _ := bifrost.GetResponseFields(resp, bifrostErr) + if span, ok := span.(*ResourceSpan); ok { + // We handle streaming responses differently, we will use the accumulator to process the response and then emit the final response + if bifrost.IsStreamRequestType(requestType) { + streamResponse, err := p.accumulator.ProcessStreamingResponse(ctx, resp, bifrostErr) + if err != nil { + logger.Debug("failed to process streaming response: %v", err) + } + if streamResponse != nil && streamResponse.Type == streaming.StreamResponseTypeFinal { + defer p.ongoingSpans.Delete(traceID) + if err := p.client.Emit(p.ctx, []*ResourceSpan{completeResourceSpan( + span, + time.Now(), + streamResponse.ToBifrostResponse(), + bifrostErr, + p.pricingManager, + virtualKeyID, + virtualKeyName, + selectedKeyID, + selectedKeyName, + numberOfRetries, + fallbackIndex, + )}); err != nil { + logger.Error("failed to emit response span for request %s: %v", traceID, err) + } + } + return + } + defer p.ongoingSpans.Delete(traceID) + rs := completeResourceSpan( + span, + time.Now(), + resp, + bifrostErr, + p.pricingManager, + virtualKeyID, + virtualKeyName, + selectedKeyID, + selectedKeyName, + numberOfRetries, + fallbackIndex, + ) + if err := p.client.Emit(p.ctx, []*ResourceSpan{rs}); err != nil { + logger.Error("failed to emit response span for request %s: %v", traceID, err) + } + } + }() + return resp, bifrostErr, nil +} + +// Cleanup function for the OTEL plugin +func (p *OtelPlugin) Cleanup() error { + p.emitWg.Wait() + if p.cancel != nil { + p.cancel() + } + if p.ongoingSpans != nil { + p.ongoingSpans.Stop() + } + if p.accumulator != nil { + p.accumulator.Cleanup() + } + if p.client != nil { + return p.client.Close() + } + return nil +} diff --git a/plugins/otel/ttlsyncmap.go b/plugins/otel/ttlsyncmap.go new file mode 100644 index 000000000..d54999d1b --- /dev/null +++ b/plugins/otel/ttlsyncmap.go @@ -0,0 +1,184 @@ +package otel + +import ( + "sync" + "time" +) + +// TTLSyncMap is a thread-safe map with automatic cleanup of expired entries +type TTLSyncMap struct { + data sync.Map + ttl time.Duration + cleanupTicker *time.Ticker + stopCleanup chan struct{} + cleanupWg sync.WaitGroup + stopOnce sync.Once +} + +// entry stores the value along with its expiration time +type entry struct { + value interface{} + expiresAt time.Time +} + +// NewTTLSyncMap creates a new TTL sync map with the specified TTL and cleanup interval +// ttl: time to live for each entry +// cleanupInterval: how often to check for expired entries (should be <= ttl) +func NewTTLSyncMap(ttl time.Duration, cleanupInterval time.Duration) *TTLSyncMap { + if ttl <= 0 { + ttl = time.Minute + } + if cleanupInterval <= 0 { + cleanupInterval = ttl / 2 + if cleanupInterval <= 0 { + cleanupInterval = time.Minute + } + } + + m := &TTLSyncMap{ + ttl: ttl, + cleanupTicker: time.NewTicker(cleanupInterval), + stopCleanup: make(chan struct{}), + } + + // Start the cleanup goroutine + m.cleanupWg.Add(1) + go m.startCleanup() + + return m +} + +// Set stores a key-value pair with TTL +func (m *TTLSyncMap) Set(key, value interface{}) { + m.data.Store(key, &entry{ + value: value, + expiresAt: time.Now().Add(m.ttl), + }) +} + +// Get retrieves a value by key, returns (value, true) if found and not expired, +// (nil, false) otherwise +func (m *TTLSyncMap) Get(key interface{}) (interface{}, bool) { + val, ok := m.data.Load(key) + if !ok { + return nil, false + } + + e := val.(*entry) + if time.Now().After(e.expiresAt) { + // Entry has expired, delete it + m.data.Delete(key) + return nil, false + } + + return e.value, true +} + +// Delete removes a key-value pair from the map +func (m *TTLSyncMap) Delete(key interface{}) { + m.data.Delete(key) +} + +// Refresh updates the expiration time of an existing entry +func (m *TTLSyncMap) Refresh(key interface{}) bool { + val, ok := m.data.Load(key) + if !ok { + return false + } + e, _ := val.(*entry) + if e == nil || time.Now().After(e.expiresAt) { + m.data.Delete(key) + return false + } + m.data.Store(key, &entry{ + value: e.value, + expiresAt: time.Now().Add(m.ttl), + }) + return true +} + +// GetOrSet retrieves a value by key if it exists and is not expired, +// otherwise sets the new value and returns it +func (m *TTLSyncMap) GetOrSet(key, value interface{}) (actual interface{}, loaded bool) { + actual, loaded = m.Get(key) + if !loaded { + m.Set(key, value) + actual = value + } + return actual, loaded +} + +// Range calls f sequentially for each key and value present in the map. +// If f returns false, range stops the iteration. +// Only non-expired entries are included. +func (m *TTLSyncMap) Range(f func(key, value interface{}) bool) { + now := time.Now() + m.data.Range(func(key, val interface{}) bool { + e := val.(*entry) + if now.After(e.expiresAt) { + // Skip expired entry and delete it + m.data.Delete(key) + return true + } + return f(key, e.value) + }) +} + +// Len returns the number of non-expired entries in the map +func (m *TTLSyncMap) Len() int { + count := 0 + m.Range(func(_, _ interface{}) bool { + count++ + return true + }) + return count +} + +// startCleanup runs in a background goroutine to periodically remove expired entries +func (m *TTLSyncMap) startCleanup() { + defer m.cleanupWg.Done() + + for { + select { + case <-m.cleanupTicker.C: + m.cleanup() + case <-m.stopCleanup: + return + } + } +} + +// cleanup removes all expired entries from the map +func (m *TTLSyncMap) cleanup() { + now := time.Now() + m.data.Range(func(key, val interface{}) bool { + e := val.(*entry) + if now.After(e.expiresAt) { + m.data.Delete(key) + } + return true + }) + if m.Len() > 10000 { + logger.Warn("[otel] map cleanup done. current size: %d entries", m.Len()) + } else { + logger.Debug("[otel] map cleanup done. current size: %d entries", m.Len()) + } +} + +// Stop stops the cleanup goroutine and releases resources +// Call this when you're done with the map to prevent goroutine leaks +func (m *TTLSyncMap) Stop() { + m.stopOnce.Do(func() { + close(m.stopCleanup) + m.cleanupTicker.Stop() + m.cleanupWg.Wait() + }) +} + +// Clear removes all entries from the map +func (m *TTLSyncMap) Clear() { + m.data.Range(func(key, _ interface{}) bool { + m.data.Delete(key) + return true + }) +} diff --git a/plugins/otel/types.go b/plugins/otel/types.go new file mode 100644 index 000000000..528bf0db9 --- /dev/null +++ b/plugins/otel/types.go @@ -0,0 +1,48 @@ +package otel + +import ( + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" +) + +// ResourceSpan is a trace in the OpenTelemetry format +type ResourceSpan = tracepb.ResourceSpans + +// ScopeSpan is a group of spans in the OpenTelemetry format +type ScopeSpan = tracepb.ScopeSpans + +// Span is a span in the OpenTelemetry format +type Span = tracepb.Span + +// Event is an event in a span +type Event = tracepb.Span_Event + +// KeyValue is a key-value pair in the OpenTelemetry format +type KeyValue = commonpb.KeyValue + +// AnyValue is a value in the OpenTelemetry format +type AnyValue = commonpb.AnyValue + +// StringValue is a string value in the OpenTelemetry format +type StringValue = commonpb.AnyValue_StringValue + +// IntValue is an integer value in the OpenTelemetry format +type IntValue = commonpb.AnyValue_IntValue + +// DoubleValue is a double value in the OpenTelemetry format +type DoubleValue = commonpb.AnyValue_DoubleValue + +// BoolValue is a boolean value in the OpenTelemetry format +type BoolValue = commonpb.AnyValue_BoolValue + +// ArrayValue is an array value in the OpenTelemetry format +type ArrayValue = commonpb.AnyValue_ArrayValue + +// ArrayValueValue is an array value in the OpenTelemetry format +type ArrayValueValue = commonpb.ArrayValue + +// ListValue is a list value in the OpenTelemetry format +type ListValue = commonpb.AnyValue_KvlistValue + +// KeyValueList is a list value in the OpenTelemetry format +type KeyValueList = commonpb.KeyValueList diff --git a/plugins/otel/version b/plugins/otel/version new file mode 100644 index 000000000..3f11ef630 --- /dev/null +++ b/plugins/otel/version @@ -0,0 +1 @@ +1.0.27 \ No newline at end of file diff --git a/plugins/semanticcache/changelog.md b/plugins/semanticcache/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/semanticcache/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/semanticcache/go.mod b/plugins/semanticcache/go.mod new file mode 100644 index 000000000..2cec9b0e2 --- /dev/null +++ b/plugins/semanticcache/go.mod @@ -0,0 +1,111 @@ +module github.com/maximhq/bifrost/plugins/semanticcache + +go 1.24.1 + +toolchain go1.24.3 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 + github.com/google/uuid v1.6.0 + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 + github.com/maximhq/bifrost/plugins/mocker v1.3.20 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jaswdr/faker/v2 v2.8.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/plugins/semanticcache/go.sum b/plugins/semanticcache/go.sum new file mode 100644 index 000000000..8a7e81d7b --- /dev/null +++ b/plugins/semanticcache/go.sum @@ -0,0 +1,259 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jaswdr/faker/v2 v2.8.0 h1:3AxdXW9U7dJmWckh/P0YgRbNlCcVsTyrUNUnLVP9b3Q= +github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/maximhq/bifrost/plugins/mocker v1.3.20 h1:Wgn43k1V6ZX6nRXZ4NfcMbGJqDQtEcSpHGJIh0ArFwM= +github.com/maximhq/bifrost/plugins/mocker v1.3.20/go.mod h1:AzmO1n+oDm4Hq2vhWkqloGDhwswwF4EmOb+BWseY4SE= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/plugins/semanticcache/main.go b/plugins/semanticcache/main.go new file mode 100644 index 000000000..f53a81490 --- /dev/null +++ b/plugins/semanticcache/main.go @@ -0,0 +1,732 @@ +// Package semanticcache provides semantic caching integration for Bifrost plugin. +// This plugin caches responses using both direct hash matching (xxhash) and semantic similarity search (embeddings). +// It supports configurable caching behavior via the VectorStore abstraction, with TTL management and streaming response handling. +package semanticcache + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "sync" + "time" + + "github.com/google/uuid" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework" + "github.com/maximhq/bifrost/framework/vectorstore" +) + +// Config contains configuration for the semantic cache plugin. +// The VectorStore abstraction handles the underlying storage implementation and its defaults. +// Only specify values you want to override from the semantic cache defaults. +type Config struct { + // Embedding Model settings - REQUIRED for semantic caching + Provider schemas.ModelProvider `json:"provider"` + Keys []schemas.Key `json:"keys"` + EmbeddingModel string `json:"embedding_model,omitempty"` // Model to use for generating embeddings (optional) + + // Plugin behavior settings + CleanUpOnShutdown bool `json:"cleanup_on_shutdown,omitempty"` // Clean up cache on shutdown (default: false) + TTL time.Duration `json:"ttl,omitempty"` // Time-to-live for cached responses (default: 5min) + Threshold float64 `json:"threshold,omitempty"` // Cosine similarity threshold for semantic matching (default: 0.8) + VectorStoreNamespace string `json:"vector_store_namespace,omitempty"` // Namespace for vector store (optional) + Dimension int `json:"dimension"` // Dimension for vector store + + // Advanced caching behavior + ConversationHistoryThreshold int `json:"conversation_history_threshold,omitempty"` // Skip caching for requests with more than this number of messages in the conversation history (default: 3) + CacheByModel *bool `json:"cache_by_model,omitempty"` // Include model in cache key (default: true) + CacheByProvider *bool `json:"cache_by_provider,omitempty"` // Include provider in cache key (default: true) + ExcludeSystemPrompt *bool `json:"exclude_system_prompt,omitempty"` // Exclude system prompt in cache key (default: false) +} + +// UnmarshalJSON implements custom JSON unmarshaling for semantic cache Config. +// It supports TTL parsing from both string durations ("1m", "1hr") and numeric seconds for configurable cache behavior. +func (c *Config) UnmarshalJSON(data []byte) error { + // Define a temporary struct to avoid infinite recursion + type TempConfig struct { + Provider string `json:"provider"` + Keys []schemas.Key `json:"keys"` + EmbeddingModel string `json:"embedding_model,omitempty"` + CleanUpOnShutdown bool `json:"cleanup_on_shutdown,omitempty"` + Dimension int `json:"dimension"` + TTL interface{} `json:"ttl,omitempty"` + Threshold float64 `json:"threshold,omitempty"` + VectorStoreNamespace string `json:"vector_store_namespace,omitempty"` + ConversationHistoryThreshold int `json:"conversation_history_threshold,omitempty"` + CacheByModel *bool `json:"cache_by_model,omitempty"` + CacheByProvider *bool `json:"cache_by_provider,omitempty"` + ExcludeSystemPrompt *bool `json:"exclude_system_prompt,omitempty"` + } + + var temp TempConfig + if err := json.Unmarshal(data, &temp); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Set simple fields + c.Provider = schemas.ModelProvider(temp.Provider) + c.Keys = temp.Keys + c.EmbeddingModel = temp.EmbeddingModel + c.CleanUpOnShutdown = temp.CleanUpOnShutdown + c.Dimension = temp.Dimension + c.CacheByModel = temp.CacheByModel + c.CacheByProvider = temp.CacheByProvider + c.VectorStoreNamespace = temp.VectorStoreNamespace + c.ConversationHistoryThreshold = temp.ConversationHistoryThreshold + c.Threshold = temp.Threshold + c.ExcludeSystemPrompt = temp.ExcludeSystemPrompt + // Handle TTL field with custom parsing for VectorStore-backed cache behavior + if temp.TTL != nil { + switch v := temp.TTL.(type) { + case string: + // Try parsing as duration string (e.g., "1m", "1hr") for semantic cache TTL + duration, err := time.ParseDuration(v) + if err != nil { + return fmt.Errorf("failed to parse TTL duration string '%s': %w", v, err) + } + c.TTL = duration + case int: + // Handle integer seconds for semantic cache TTL + c.TTL = time.Duration(v) * time.Second + default: + // Try converting to string and parsing as number for semantic cache TTL + ttlStr := fmt.Sprintf("%v", v) + if seconds, err := strconv.ParseFloat(ttlStr, 64); err == nil { + c.TTL = time.Duration(seconds * float64(time.Second)) + } else { + return fmt.Errorf("unsupported TTL type: %T (value: %v)", v, v) + } + } + } + + return nil +} + +// StreamChunk represents a single chunk from a streaming response +type StreamChunk struct { + Timestamp time.Time // When chunk was received + Response *schemas.BifrostResponse // The actual response chunk + FinishReason *string // If this is the final chunk +} + +// StreamAccumulator manages accumulation of streaming chunks for caching +type StreamAccumulator struct { + RequestID string // The request ID + Chunks []*StreamChunk // All chunks for this stream + IsComplete bool // Whether the stream is complete + HasError bool // Whether any chunk in the stream had an error + FinalTimestamp time.Time // When the stream completed + Embedding []float32 // Embedding for the original request + Metadata map[string]interface{} // Metadata for caching + TTL time.Duration // TTL for this cache entry + mu sync.Mutex // Protects chunk operations +} + +// Plugin implements the schemas.Plugin interface for semantic caching. +// It caches responses using a two-tier approach: direct hash matching for exact requests +// and semantic similarity search for related content. The plugin supports configurable caching behavior +// via the VectorStore abstraction, including TTL management and streaming response handling. +// +// Fields: +// - store: VectorStore instance for semantic cache operations +// - config: Plugin configuration including semantic cache and caching settings +// - logger: Logger instance for plugin operations +type Plugin struct { + store vectorstore.VectorStore + config *Config + logger schemas.Logger + client *bifrost.Bifrost + streamAccumulators sync.Map // Track stream accumulators by request ID + waitGroup sync.WaitGroup +} + +// Plugin constants +const ( + PluginName string = "semantic_cache" + DefaultVectorStoreNamespace string = "BifrostSemanticCachePlugin" + PluginLoggerPrefix string = "[Semantic Cache]" + CacheConnectionTimeout time.Duration = 5 * time.Second + CreateNamespaceTimeout time.Duration = 30 * time.Second + CacheSetTimeout time.Duration = 30 * time.Second + DefaultCacheTTL time.Duration = 5 * time.Minute + DefaultCacheThreshold float64 = 0.8 + DefaultConversationHistoryThreshold int = 3 +) + +var SelectFields = []string{"request_hash", "response", "stream_chunks", "expires_at", "cache_key", "provider", "model"} + +var VectorStoreProperties = map[string]vectorstore.VectorStoreProperties{ + "request_hash": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The hash of the request", + }, + "response": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The response from the provider", + }, + "stream_chunks": { + DataType: vectorstore.VectorStorePropertyTypeStringArray, + Description: "The stream chunks from the provider", + }, + "expires_at": { + DataType: vectorstore.VectorStorePropertyTypeInteger, + Description: "The expiration time of the cache entry", + }, + "cache_key": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The cache key from the request", + }, + "provider": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The provider used for the request", + }, + "model": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The model used for the request", + }, + "params_hash": { + DataType: vectorstore.VectorStorePropertyTypeString, + Description: "The hash of the parameters used for the request", + }, + "from_bifrost_semantic_cache_plugin": { + DataType: vectorstore.VectorStorePropertyTypeBoolean, + Description: "Whether the cache entry was created by the BifrostSemanticCachePlugin", + }, +} + +type PluginAccount struct { + provider schemas.ModelProvider + keys []schemas.Key +} + +func (pa *PluginAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{pa.provider}, nil +} + +func (pa *PluginAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + return pa.keys, nil +} + +func (pa *PluginAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil +} + +// Dependencies is a list of dependencies that the plugin requires. +var Dependencies []framework.FrameworkDependency = []framework.FrameworkDependency{framework.FrameworkDependencyVectorStore} + +const ( + CacheKey schemas.BifrostContextKey = "semantic_cache_key" // To set the cache key for a request - REQUIRED for all requests + CacheTTLKey schemas.BifrostContextKey = "semantic_cache_ttl" // To explicitly set the TTL for a request + CacheThresholdKey schemas.BifrostContextKey = "semantic_cache_threshold" // To explicitly set the threshold for a request + CacheTypeKey schemas.BifrostContextKey = "semantic_cache_cache_type" // To explicitly set the cache type for a request + CacheNoStoreKey schemas.BifrostContextKey = "semantic_cache_no_store" // To explicitly disable storing the response in the cache + + // context keys for internal usage + requestIDKey schemas.BifrostContextKey = "semantic_cache_request_id" + requestHashKey schemas.BifrostContextKey = "semantic_cache_request_hash" + requestEmbeddingKey schemas.BifrostContextKey = "semantic_cache_embedding" + requestEmbeddingTokensKey schemas.BifrostContextKey = "semantic_cache_embedding_tokens" + requestParamsHashKey schemas.BifrostContextKey = "semantic_cache_params_hash" + requestModelKey schemas.BifrostContextKey = "semantic_cache_model" + requestProviderKey schemas.BifrostContextKey = "semantic_cache_provider" + isCacheHitKey schemas.BifrostContextKey = "semantic_cache_is_cache_hit" + cacheHitTypeKey schemas.BifrostContextKey = "semantic_cache_cache_hit_type" +) + +type CacheType string + +const ( + CacheTypeDirect CacheType = "direct" + CacheTypeSemantic CacheType = "semantic" +) + +// Init creates a new semantic cache plugin instance with the provided configuration. +// It uses the VectorStore abstraction for cache operations and returns a configured plugin. +// +// The VectorStore handles the underlying storage implementation and its defaults. +// The plugin only sets defaults for its own behavior (TTL, cache key generation, etc.). +// +// Parameters: +// - config: Semantic cache and plugin configuration (CacheKey is required) +// - logger: Logger instance for the plugin +// - store: VectorStore instance for cache operations +// +// Returns: +// - schemas.Plugin: A configured semantic cache plugin instance +// - error: Any error that occurred during plugin initialization +func Init(ctx context.Context, config *Config, logger schemas.Logger, store vectorstore.VectorStore) (schemas.Plugin, error) { + if config == nil { + return nil, fmt.Errorf("config is required") + } + if store == nil { + return nil, fmt.Errorf("store is required") + } + // Set plugin-specific defaults + if config.VectorStoreNamespace == "" { + logger.Debug(PluginLoggerPrefix + " Vector store namespace is not set, using default of " + DefaultVectorStoreNamespace) + config.VectorStoreNamespace = DefaultVectorStoreNamespace + } + if config.TTL == 0 { + logger.Debug(PluginLoggerPrefix + " TTL is not set, using default of 5 minutes") + config.TTL = DefaultCacheTTL + } + if config.Threshold == 0 { + logger.Debug(PluginLoggerPrefix + " Threshold is not set, using default of " + strconv.FormatFloat(DefaultCacheThreshold, 'f', -1, 64)) + config.Threshold = DefaultCacheThreshold + } + if config.ConversationHistoryThreshold == 0 { + logger.Debug(PluginLoggerPrefix + " Conversation history threshold is not set, using default of " + strconv.Itoa(DefaultConversationHistoryThreshold)) + config.ConversationHistoryThreshold = DefaultConversationHistoryThreshold + } + + // Set cache behavior defaults + if config.CacheByModel == nil { + config.CacheByModel = bifrost.Ptr(true) + } + if config.CacheByProvider == nil { + config.CacheByProvider = bifrost.Ptr(true) + } + + plugin := &Plugin{ + store: store, + config: config, + logger: logger, + waitGroup: sync.WaitGroup{}, + } + + if config.Provider == "" || len(config.Keys) == 0 { + logger.Warn(PluginLoggerPrefix + " Provider and keys are required for semantic cache, falling back to direct search only") + } else { + bifrost, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Logger: logger, + Account: &PluginAccount{ + provider: config.Provider, + keys: config.Keys, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize bifrost for semantic cache: %w", err) + } + + plugin.client = bifrost + } + + createCtx, cancel := context.WithTimeout(ctx, CreateNamespaceTimeout) + defer cancel() + if err := store.CreateNamespace(createCtx, config.VectorStoreNamespace, config.Dimension, VectorStoreProperties); err != nil { + return nil, fmt.Errorf("failed to create namespace for semantic cache: %w", err) + } + + return plugin, nil +} + +// GetName returns the canonical name of the semantic cache plugin. +// This name is used for plugin identification and logging purposes. +// +// Returns: +// - string: The plugin name for semantic cache +func (plugin *Plugin) GetName() string { + return PluginName +} + +// TransportInterceptor is not used for this plugin +func (plugin *Plugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// PreHook is called before a request is processed by Bifrost. +// It performs a two-stage cache lookup: first direct hash matching, then semantic similarity search. +// Uses UUID-based keys for entries stored in the VectorStore. +// +// Parameters: +// - ctx: Pointer to the context.Context +// - req: The incoming Bifrost request +// +// Returns: +// - *schemas.BifrostRequest: The original request +// - *schemas.BifrostResponse: Cached response if found, nil otherwise +// - error: Any error that occurred during cache lookup +func (plugin *Plugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + provider, model, _ := req.GetRequestFields() + + // Get the cache key from the context + var cacheKey string + var ok bool + + cacheKey, ok = (*ctx).Value(CacheKey).(string) + if !ok || cacheKey == "" { + plugin.logger.Debug(PluginLoggerPrefix + " No cache key found in context, continuing without caching") + return req, nil, nil + } + + if plugin.isConversationHistoryThresholdExceeded(req) { + plugin.logger.Debug(PluginLoggerPrefix + " Skipping caching for request with conversation history threshold exceeded") + return req, nil, nil + } + + // Generate UUID for this request + requestID := uuid.New().String() + + // Store request ID, model, and provider in context for PostHook + *ctx = context.WithValue(*ctx, requestIDKey, requestID) + *ctx = context.WithValue(*ctx, requestModelKey, model) + *ctx = context.WithValue(*ctx, requestProviderKey, provider) + + performDirectSearch, performSemanticSearch := true, true + if (*ctx).Value(CacheTypeKey) != nil { + cacheTypeVal, ok := (*ctx).Value(CacheTypeKey).(CacheType) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix + " Cache type is not a CacheType, using all available cache types") + } else { + performDirectSearch = cacheTypeVal == CacheTypeDirect + performSemanticSearch = cacheTypeVal == CacheTypeSemantic + } + } + + if performDirectSearch { + shortCircuit, err := plugin.performDirectSearch(ctx, req, cacheKey) + if err != nil { + plugin.logger.Warn(PluginLoggerPrefix + " Direct search failed: " + err.Error()) + // Don't return - continue to semantic search fallback + shortCircuit = nil // Ensure we don't use an invalid shortCircuit + } + + if shortCircuit != nil { + return req, shortCircuit, nil + } + } + + if performSemanticSearch && plugin.client != nil { + if req.EmbeddingRequest != nil || req.TranscriptionRequest != nil { + plugin.logger.Debug(PluginLoggerPrefix + " Skipping semantic search for embedding/transcription input") + return req, nil, nil + } + + // Try semantic search as fallback + shortCircuit, err := plugin.performSemanticSearch(ctx, req, cacheKey) + if err != nil { + return req, nil, nil + } + + if shortCircuit != nil { + return req, shortCircuit, nil + } + } + + return req, nil, nil +} + +// PostHook is called after a response is received from a provider. +// It caches responses in the VectorStore using UUID-based keys with unified metadata structure +// including provider, model, request hash, and TTL. Handles both single and streaming responses. +// +// The function performs the following operations: +// 1. Checks configurable caching behavior and skips caching for unsuccessful responses if configured +// 2. Retrieves the request hash and ID from the context (set during PreHook) +// 3. Marshals the response for storage +// 4. Stores the unified cache entry in the VectorStore asynchronously (non-blocking) +// +// The VectorStore Add operation runs in a separate goroutine to avoid blocking the response. +// The function gracefully handles errors and continues without caching if any step fails, +// ensuring that response processing is never interrupted by caching issues. +// +// Parameters: +// - ctx: Pointer to the context.Context containing the request hash and ID +// - res: The response from the provider to be cached +// - bifrostErr: The error from the provider, if any (used for success determination) +// +// Returns: +// - *schemas.BifrostResponse: The original response, unmodified +// - *schemas.BifrostError: The original error, unmodified +// - error: Any error that occurred during caching preparation (always nil as errors are handled gracefully) +func (plugin *Plugin) PostHook(ctx *context.Context, res *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + if bifrostErr != nil { + return res, bifrostErr, nil + } + + isCacheHit := (*ctx).Value(isCacheHitKey) + if isCacheHit != nil { + isCacheHitValue, ok := isCacheHit.(bool) + if ok && isCacheHitValue { + return res, nil, nil + } + } + + // Check if caching is explicitly disabled + noStore := (*ctx).Value(CacheNoStoreKey) + if noStore != nil { + noStoreValue, ok := noStore.(bool) + if ok && noStoreValue { + plugin.logger.Debug(PluginLoggerPrefix + " Caching is explicitly disabled for this request, continuing without caching") + return res, nil, nil + } + } + + // Get the cache key from context + cacheKey, ok := (*ctx).Value(CacheKey).(string) + if !ok { + return res, nil, nil + } + + // Get the request ID from context + requestID, ok := (*ctx).Value(requestIDKey).(string) + if !ok { + return res, nil, nil + } + // Check cache type to optimize embedding handling + var embedding []float32 + var hash string + var shouldStoreEmbeddings = true + var shouldStoreHash = true + + if (*ctx).Value(CacheTypeKey) != nil { + cacheTypeVal, ok := (*ctx).Value(CacheTypeKey).(CacheType) + if ok { + if cacheTypeVal == CacheTypeDirect { + // For direct-only caching, skip embedding operations entirely + shouldStoreEmbeddings = false + plugin.logger.Debug(PluginLoggerPrefix + " Skipping embedding operations for direct-only cache type") + } else if cacheTypeVal == CacheTypeSemantic { + shouldStoreHash = false + plugin.logger.Debug(PluginLoggerPrefix + " Skipping hash operations for semantic cache type") + } + } + } + + if shouldStoreHash { + // Get the hash from context + hash, ok = (*ctx).Value(requestHashKey).(string) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix+" Hash is not a string, its %T. Continuing without caching", hash) + return res, nil, nil + } + } + + extraFields := res.GetExtraFields() + requestType := extraFields.RequestType + + // Get embedding from context if available and needed + if shouldStoreEmbeddings && requestType != schemas.EmbeddingRequest && requestType != schemas.TranscriptionRequest { + embeddingValue := (*ctx).Value(requestEmbeddingKey) + if embeddingValue != nil { + embedding, ok = embeddingValue.([]float32) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix + " Embedding is not a []float32, continuing without caching") + return res, nil, nil + } + } + // Note: embedding can be nil for direct cache hits or when semantic search is disabled + // This is fine - we can still cache using direct hash matching + } + + // Get the provider from context + provider, ok := (*ctx).Value(requestProviderKey).(schemas.ModelProvider) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix + " Provider is not a schemas.ModelProvider, continuing without caching") + return res, nil, nil + } + + // Get the model from context + model, ok := (*ctx).Value(requestModelKey).(string) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix + " Model is not a string, continuing without caching") + return res, nil, nil + } + + isFinalChunk := bifrost.IsFinalChunk(ctx) + + // Get the input tokens from context (can be nil if not set) + inputTokens, ok := (*ctx).Value(requestEmbeddingTokensKey).(int) + if ok { + isStreamRequest := bifrost.IsStreamRequestType(requestType) + + if !isStreamRequest || (isStreamRequest && isFinalChunk) { + if extraFields.CacheDebug == nil { + extraFields.CacheDebug = &schemas.BifrostCacheDebug{} + } + extraFields.CacheDebug.CacheHit = false + extraFields.CacheDebug.ProviderUsed = bifrost.Ptr(string(plugin.config.Provider)) + extraFields.CacheDebug.ModelUsed = bifrost.Ptr(plugin.config.EmbeddingModel) + extraFields.CacheDebug.InputTokens = &inputTokens + } + } + + cacheTTL := plugin.config.TTL + + ttlValue := (*ctx).Value(CacheTTLKey) + if ttlValue != nil { + // Get the request TTL from the context + ttl, ok := ttlValue.(time.Duration) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix + " TTL is not a time.Duration, using default TTL") + } else { + cacheTTL = ttl + } + } + + // Cache everything in a unified VectorEntry asynchronously to avoid blocking the response + plugin.waitGroup.Add(1) + go func() { + defer plugin.waitGroup.Done() + // Create a background context with timeout for the cache operation + cacheCtx, cancel := context.WithTimeout(context.Background(), CacheSetTimeout) + defer cancel() + + // Get metadata from context + paramsHash, _ := (*ctx).Value(requestParamsHashKey).(string) + + // Build unified metadata with provider, model, and all params + unifiedMetadata := plugin.buildUnifiedMetadata(provider, model, paramsHash, hash, cacheKey, cacheTTL) + + // Handle streaming vs non-streaming responses + // Pass nil for embedding if we're in direct-only mode to optimize storage + embeddingToStore := embedding + if !shouldStoreEmbeddings { + embeddingToStore = nil + } + + if bifrost.IsStreamRequestType(requestType) { + if err := plugin.addStreamingResponse(cacheCtx, requestID, res, bifrostErr, embeddingToStore, unifiedMetadata, cacheTTL, isFinalChunk); err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to cache streaming response: %v", PluginLoggerPrefix, err)) + } + } else { + if err := plugin.addSingleResponse(cacheCtx, requestID, res, embeddingToStore, unifiedMetadata, cacheTTL); err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to cache single response: %v", PluginLoggerPrefix, err)) + } + } + }() + + return res, nil, nil +} + +// Cleanup performs cleanup operations for the semantic cache plugin. +// It removes all cached entries created by this plugin from the VectorStore only if CleanUpOnShutdown is true. +// Identifies cache entries by the presence of semantic cache-specific fields (request_hash, cache_key). +// +// The function performs the following operations: +// 1. Checks if cleanup is enabled via CleanUpOnShutdown config +// 2. Retrieves all entries and filters client-side to identify cache entries +// 3. Deletes all matching cache entries from the VectorStore in batches +// +// This method should be called when shutting down the application to ensure +// proper resource cleanup if configured to do so. +// +// Returns: +// - error: Any error that occurred during cleanup operations +func (plugin *Plugin) Cleanup() error { + plugin.waitGroup.Wait() + + // Clean up old stream accumulators first + plugin.cleanupOldStreamAccumulators() + + // Only clean up cache entries if configured to do so + if !plugin.config.CleanUpOnShutdown { + plugin.logger.Debug(PluginLoggerPrefix + " Cleanup on shutdown is disabled, skipping cache cleanup") + return nil + } + + // Clean up all cache entries created by this plugin + ctx, cancel := context.WithTimeout(context.Background(), CacheSetTimeout) + defer cancel() + + plugin.logger.Debug(PluginLoggerPrefix + " Starting cleanup of cache entries...") + + // Delete all cache entries created by this plugin + queries := []vectorstore.Query{ + { + Field: "from_bifrost_semantic_cache_plugin", + Operator: vectorstore.QueryOperatorEqual, + Value: true, + }, + } + + results, err := plugin.store.DeleteAll(ctx, plugin.config.VectorStoreNamespace, queries) + if err != nil { + return fmt.Errorf("failed to delete cache entries: %w", err) + } + + for _, result := range results { + if result.Status == vectorstore.DeleteStatusError { + plugin.logger.Warn(fmt.Sprintf("%s Failed to delete cache entry: %s", PluginLoggerPrefix, result.Error)) + } + } + plugin.logger.Info(fmt.Sprintf("%s Cleanup completed - deleted all cache entries", PluginLoggerPrefix)) + + if err := plugin.store.DeleteNamespace(ctx, plugin.config.VectorStoreNamespace); err != nil { + return fmt.Errorf("failed to delete namespace: %w", err) + } + + return nil +} + +// Public Methods for External Use + +// ClearCacheForKey deletes cache entries for a specific cache key. +// Uses the unified VectorStore interface for deletion of all entries with the given cache key. +// +// Parameters: +// - cacheKey: The specific cache key to delete +// +// Returns: +// - error: Any error that occurred during cache key deletion +func (plugin *Plugin) ClearCacheForKey(cacheKey string) error { + // Delete all entries with "cache_key" equal to the given cacheKey + queries := []vectorstore.Query{ + { + Field: "cache_key", + Operator: vectorstore.QueryOperatorEqual, + Value: cacheKey, + }, + { + Field: "from_bifrost_semantic_cache_plugin", + Operator: vectorstore.QueryOperatorEqual, + Value: true, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), CacheSetTimeout) + defer cancel() + results, err := plugin.store.DeleteAll(ctx, plugin.config.VectorStoreNamespace, queries) + if err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to delete cache entries for key '%s': %v", PluginLoggerPrefix, cacheKey, err)) + return err + } + + for _, result := range results { + if result.Status == vectorstore.DeleteStatusError { + plugin.logger.Warn(fmt.Sprintf("%s Failed to delete cache entry for key %s: %s", PluginLoggerPrefix, result.ID, result.Error)) + } + } + + plugin.logger.Debug(fmt.Sprintf("%s Deleted all cache entries for key %s", PluginLoggerPrefix, cacheKey)) + + return nil +} + +// ClearCacheForRequestID deletes cache entries for a specific request ID. +// Uses the unified VectorStore interface to delete the single entry by its UUID. +// +// Parameters: +// - requestID: The UUID-based request ID to delete cache entries for +// +// Returns: +// - error: Any error that occurred during cache key deletion +func (plugin *Plugin) ClearCacheForRequestID(requestID string) error { + // With the unified VectorStore interface, we delete the single entry by its UUID + ctx, cancel := context.WithTimeout(context.Background(), CacheSetTimeout) + defer cancel() + if err := plugin.store.Delete(ctx, plugin.config.VectorStoreNamespace, requestID); err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to delete cache entry: %v", PluginLoggerPrefix, err)) + return err + } + + plugin.logger.Debug(fmt.Sprintf("%s Deleted cache entry for key %s", PluginLoggerPrefix, requestID)) + + return nil +} diff --git a/plugins/semanticcache/plugin_cache_type_test.go b/plugins/semanticcache/plugin_cache_type_test.go new file mode 100644 index 000000000..603e00726 --- /dev/null +++ b/plugins/semanticcache/plugin_cache_type_test.go @@ -0,0 +1,256 @@ +package semanticcache + +import ( + "context" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestCacheTypeDirectOnly tests that CacheTypeKey set to "direct" only performs direct hash matching +func TestCacheTypeDirectOnly(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // First, cache a response using normal behavior (both direct and semantic) + ctx1 := CreateContextWithCacheKey("test-cache-type-direct") + testRequest := CreateBasicChatRequest("What is Bifrost?", 0.7, 50) + + t.Log("Making first request to populate cache...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Now test with CacheTypeKey set to direct only + ctx2 := CreateContextWithCacheKeyAndType("test-cache-type-direct", CacheTypeDirect) + + t.Log("Making second request with CacheTypeKey=direct...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err2 != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } + + // Should be a cache hit from direct search + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + t.Log("βœ… CacheTypeKey=direct correctly performs only direct hash matching") +} + +// TestCacheTypeSemanticOnly tests that CacheTypeKey set to "semantic" only performs semantic search +func TestCacheTypeSemanticOnly(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // First, cache a response using normal behavior + ctx1 := CreateContextWithCacheKey("test-cache-type-semantic") + testRequest := CreateBasicChatRequest("Explain machine learning concepts", 0.7, 50) + + t.Log("Making first request to populate cache...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Test with slightly different wording that should match semantically but not directly + similarRequest := CreateBasicChatRequest("Can you explain concepts in machine learning", 0.7, 50) + + // Try with semantic-only search + ctx2 := CreateContextWithCacheKeyAndType("test-cache-type-semantic", CacheTypeSemantic) + + t.Log("Making second request with similar content and CacheTypeKey=semantic...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, similarRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // This might be a cache hit if semantic similarity is high enough + // The test validates that semantic search is attempted + if response2.ExtraFields.CacheDebug != nil && response2.ExtraFields.CacheDebug.CacheHit { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "semantic") + t.Log("βœ… CacheTypeKey=semantic correctly found semantic match") + } else { + t.Log("ℹ️ No semantic match found (threshold may be too high for these similar phrases)") + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) + } + + t.Log("βœ… CacheTypeKey=semantic correctly performs only semantic search") +} + +// TestCacheTypeDirectWithSemanticFallback tests the default behavior (both direct and semantic) +func TestCacheTypeDirectWithSemanticFallback(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Cache a response first + ctx1 := CreateContextWithCacheKey("test-cache-type-fallback") + testRequest := CreateBasicChatRequest("Define artificial intelligence", 0.7, 50) + + t.Log("Making first request to populate cache...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Test exact match (should hit direct cache) + ctx2 := CreateContextWithCacheKey("test-cache-type-fallback") + + t.Log("Making second identical request (should hit direct cache)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + // Test similar request (should potentially hit semantic cache) + similarRequest := CreateBasicChatRequest("What is artificial intelligence", 0.7, 50) + + t.Log("Making third similar request (should attempt semantic match)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, similarRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + + // May or may not be a cache hit depending on semantic similarity + if response3.ExtraFields.CacheDebug != nil && response3.ExtraFields.CacheDebug.CacheHit { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "semantic") + t.Log("βœ… Default behavior correctly found semantic match") + } else { + t.Log("ℹ️ No semantic match found (normal for different wording)") + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) + } + + t.Log("βœ… Default behavior correctly attempts both direct and semantic search") +} + +// TestCacheTypeInvalidValue tests behavior with invalid CacheTypeKey values +func TestCacheTypeInvalidValue(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Create context with invalid cache type + ctx := CreateContextWithCacheKey("test-invalid-cache-type") + ctx = context.WithValue(ctx, CacheTypeKey, "invalid_type") + + testRequest := CreateBasicChatRequest("Test invalid cache type", 0.7, 50) + + t.Log("Making request with invalid CacheTypeKey value...") + response, err := setup.Client.ChatCompletionRequest(ctx, testRequest) + if err != nil { + return // Test will be skipped by retry function + } + + // Should fall back to default behavior (both direct and semantic) + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response}) + + t.Log("βœ… Invalid CacheTypeKey value falls back to default behavior") +} + +// TestCacheTypeWithEmbeddingRequests tests CacheTypeKey behavior with embedding requests +func TestCacheTypeWithEmbeddingRequests(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + embeddingRequest := CreateEmbeddingRequest([]string{"Test embedding with cache type"}) + + // Cache first request + ctx1 := CreateContextWithCacheKey("test-embedding-cache-type") + t.Log("Making first embedding request...") + response1, err1 := setup.Client.EmbeddingRequest(ctx1, embeddingRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response1}) + + WaitForCache() + + // Test with direct-only cache type + ctx2 := CreateContextWithCacheKeyAndType("test-embedding-cache-type", CacheTypeDirect) + t.Log("Making second embedding request with CacheTypeKey=direct...") + response2, err2 := setup.Client.EmbeddingRequest(ctx2, embeddingRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response2}, "direct") + + // Test with semantic-only cache type (should not find semantic match for embeddings) + ctx3 := CreateContextWithCacheKeyAndType("test-embedding-cache-type", CacheTypeSemantic) + t.Log("Making third embedding request with CacheTypeKey=semantic...") + response3, err3 := setup.Client.EmbeddingRequest(ctx3, embeddingRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + // Semantic search should be skipped for embedding requests + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response3}) + + t.Log("βœ… CacheTypeKey works correctly with embedding requests") +} + +// TestCacheTypePerformanceCharacteristics tests that different cache types have expected performance +func TestCacheTypePerformanceCharacteristics(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Performance test for cache types", 0.7, 50) + + // Cache first request + ctx1 := CreateContextWithCacheKey("test-cache-performance") + t.Log("Making first request to populate cache...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Test direct-only performance + ctx2 := CreateContextWithCacheKeyAndType("test-cache-performance", CacheTypeDirect) + start2 := time.Now() + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + duration2 := time.Since(start2) + if err2 != nil { + t.Fatalf("Direct cache request failed: %v", err2) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + t.Logf("Direct cache lookup took: %v", duration2) + + // Test default behavior (both direct and semantic) performance + ctx3 := CreateContextWithCacheKey("test-cache-performance") + start3 := time.Now() + response3, err3 := setup.Client.ChatCompletionRequest(ctx3, testRequest) + duration3 := time.Since(start3) + if err3 != nil { + t.Fatalf("Default cache request failed: %v", err3) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "direct") + + t.Logf("Default cache lookup took: %v", duration3) + + // Both should be fast since they hit direct cache + // Direct-only might be slightly faster as it doesn't need to prepare for semantic fallback + t.Log("βœ… Cache type performance characteristics validated") +} diff --git a/plugins/semanticcache/plugin_conversation_config_test.go b/plugins/semanticcache/plugin_conversation_config_test.go new file mode 100644 index 000000000..21529141e --- /dev/null +++ b/plugins/semanticcache/plugin_conversation_config_test.go @@ -0,0 +1,454 @@ +package semanticcache + +import ( + "strconv" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// TestConversationHistoryThresholdBasic tests basic conversation history threshold functionality +func TestConversationHistoryThresholdBasic(t *testing.T) { + // Test with threshold of 2 messages + setup := CreateTestSetupWithConversationThreshold(t, 2) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-conversation-threshold-basic") + + // Test 1: Conversation with exactly 2 messages (should cache) + conversation1 := BuildConversationHistory("", + []string{"Hello", "Hi there!"}, + ) + request1 := CreateConversationRequest(conversation1, 0.7, 50) + + t.Log("Testing conversation with exactly 2 messages (at threshold)...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Fresh request + + WaitForCache() + + // Verify it was cached + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request1) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should be cached + + // Test 2: Conversation with 3 messages (exceeds threshold, should NOT cache) + conversation2 := BuildConversationHistory("", + []string{"Hello", "Hi there!"}, + []string{"How are you?", "I'm doing well!"}, + ) + messages2 := AddUserMessage(conversation2, "What's the weather?") + request2 := CreateConversationRequest(messages2, 0.7, 50) // 5 messages total > 2 + + t.Log("Testing conversation with 5 messages (exceeds threshold)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx, request2) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) // Should not cache + + WaitForCache() + + // Verify it was NOT cached + t.Log("Verifying conversation exceeding threshold was not cached...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx, request2) + if err4 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}) // Should still be fresh (not cached) + + t.Log("βœ… Conversation history threshold works correctly") +} + +// TestConversationHistoryThresholdWithSystemPrompt tests threshold with system messages +func TestConversationHistoryThresholdWithSystemPrompt(t *testing.T) { + // Test with threshold of 3, ExcludeSystemPrompt = false + setup := CreateTestSetupWithConversationThreshold(t, 3) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-threshold-system-prompt") + + // System prompt + 2 user/assistant pairs = 5 messages total > 3 + conversation := BuildConversationHistory( + "You are a helpful assistant", // System message (counts toward threshold) + []string{"Hello", "Hi there!"}, + []string{"How are you?", "I'm doing well!"}, + ) + request := CreateConversationRequest(conversation, 0.7, 50) + + t.Log("Testing conversation with system prompt (5 total messages > 3 threshold)...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Should not cache (exceeds threshold) + + WaitForCache() + + // Verify not cached + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) // Should not be cached + + t.Log("βœ… Conversation threshold correctly counts system messages") +} + +// TestConversationHistoryThresholdWithExcludeSystemPrompt tests interaction between threshold and exclude system prompt +func TestConversationHistoryThresholdWithExcludeSystemPrompt(t *testing.T) { + // Create setup with both threshold=3 and ExcludeSystemPrompt=true + setup := CreateTestSetupWithThresholdAndExcludeSystem(t, 3, true) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-threshold-exclude-system") + + // Create conversation with exactly 3 non-system messages to test threshold boundary + // System + 1.5 user/assistant pairs = 4 messages total + // With ExcludeSystemPrompt=true, should only count 3 non-system messages for threshold + conversation := BuildConversationHistory( + "You are helpful", // System (excluded from count) + []string{"Hello", "Hi"}, // User + Assistant = 2 messages + []string{"Thanks", ""}, // User only = 1 message (no assistant response) + ) + // No slicing needed; BuildConversationHistory skips empty assistant entries. + request := CreateConversationRequest(conversation, 0.7, 50) // 3 non-system messages exactly + + t.Log("Testing threshold with ExcludeSystemPrompt=true (3 non-system messages = at threshold)...") + + // Test logic: + // - Total messages: 4 (1 system + 3 others) + // - With ExcludeSystemPrompt=true: counts as 3 non-system messages + // - Threshold is 3, so 3 <= 3 should allow caching + + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Fresh request, should not hit cache + + WaitForCache() + + // Second request should hit cache (3 non-system messages <= 3 threshold) + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should cache since 3 <= 3 after excluding system + + t.Log("βœ… Conversation threshold respects ExcludeSystemPrompt setting") +} + +// TestConversationHistoryThresholdDifferentValues tests different threshold values +func TestConversationHistoryThresholdDifferentValues(t *testing.T) { + testCases := []struct { + name string + threshold int + messages int + shouldCache bool + }{ + {"Threshold 1, 1 message", 1, 1, true}, + {"Threshold 1, 2 messages", 1, 2, false}, + {"Threshold 5, 4 messages", 5, 4, true}, + {"Threshold 5, 6 messages", 5, 6, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + setup := CreateTestSetupWithConversationThreshold(t, tc.threshold) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-threshold-" + tc.name) + + // Build conversation with specified number of messages + var conversation []schemas.ChatMessage + for i := 0; i < tc.messages; i++ { + role := schemas.ChatMessageRoleUser + if i%2 == 1 { + role = schemas.ChatMessageRoleAssistant + } + message := schemas.ChatMessage{ + Role: role, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Message " + strconv.Itoa(i+1)), + }, + } + conversation = append(conversation, message) + } + + request := CreateConversationRequest(conversation, 0.7, 50) + + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Always fresh first time + + WaitForCache() + + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request) + if err2 != nil { + return // Test will be skipped by retry function + } + + if tc.shouldCache { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + } else { + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) + } + }) + } + + t.Log("βœ… Different conversation threshold values work correctly") +} + +// TestExcludeSystemPromptBasic tests basic ExcludeSystemPrompt functionality +func TestExcludeSystemPromptBasic(t *testing.T) { + // Test with ExcludeSystemPrompt = true + setup := CreateTestSetupWithExcludeSystemPrompt(t, true) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-exclude-system-basic") + + // Create two conversations with different system prompts but same user/assistant messages + conversation1 := BuildConversationHistory( + "You are a helpful assistant", + []string{"What is AI?", "AI is artificial intelligence."}, + ) + + conversation2 := BuildConversationHistory( + "You are a technical expert", // Different system prompt + []string{"What is AI?", "AI is artificial intelligence."}, // Same user/assistant + ) + + request1 := CreateConversationRequest(conversation1, 0.7, 50) + request2 := CreateConversationRequest(conversation2, 0.7, 50) + + t.Log("Caching conversation with system prompt 1...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + t.Log("Testing conversation with different system prompt (should hit cache due to ExcludeSystemPrompt=true)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + // Should hit cache because system prompts are excluded from cache key + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + t.Log("βœ… ExcludeSystemPrompt=true correctly ignores system prompts in cache keys") +} + +// TestExcludeSystemPromptComparison tests ExcludeSystemPrompt true vs false +func TestExcludeSystemPromptComparison(t *testing.T) { + // Test 1: ExcludeSystemPrompt = false (default) + setup1 := CreateTestSetupWithExcludeSystemPrompt(t, false) + defer setup1.Cleanup() + + ctx1 := CreateContextWithCacheKey("test-exclude-system-false") + + conversation1 := BuildConversationHistory( + "You are helpful", + []string{"Hello", "Hi there!"}, + ) + + conversation2 := BuildConversationHistory( + "You are an expert", // Different system prompt + []string{"Hello", "Hi there!"}, // Same user/assistant + ) + + request1 := CreateConversationRequest(conversation1, 0.7, 50) + request2 := CreateConversationRequest(conversation2, 0.7, 50) + + t.Log("Testing ExcludeSystemPrompt=false...") + response1, err1 := setup1.Client.ChatCompletionRequest(ctx1, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + response2, err2 := setup1.Client.ChatCompletionRequest(ctx1, request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + // Should NOT hit direct cache, but might hit semantic cache due to similar content + if response2.ExtraFields.CacheDebug != nil && response2.ExtraFields.CacheDebug.CacheHit { + if response2.ExtraFields.CacheDebug.HitType != nil && *response2.ExtraFields.CacheDebug.HitType == "semantic" { + t.Log("βœ… Found semantic cache match (expected with similar content)") + } else { + t.Error("❌ Unexpected direct cache hit with different system prompts") + } + } else { + t.Log("βœ… No cache hit (system prompts create different cache keys)") + } + + // Test 2: ExcludeSystemPrompt = true + setup2 := CreateTestSetupWithExcludeSystemPrompt(t, true) + defer setup2.Cleanup() + + ctx2 := CreateContextWithCacheKey("test-exclude-system-true") + + t.Log("Testing ExcludeSystemPrompt=true...") + response3, err3 := setup2.Client.ChatCompletionRequest(ctx2, request1) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) + + WaitForCache() + + response4, err4 := setup2.Client.ChatCompletionRequest(ctx2, request2) + if err4 != nil { + t.Fatalf("Fourth request failed: %v", err4) + } + // Should hit cache because system prompts are excluded from cache key + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}, "direct") + + t.Log("βœ… ExcludeSystemPrompt true vs false comparison works correctly") +} + +// TestExcludeSystemPromptWithMultipleSystemMessages tests behavior with multiple system messages +func TestExcludeSystemPromptWithMultipleSystemMessages(t *testing.T) { + setup := CreateTestSetupWithExcludeSystemPrompt(t, true) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-multiple-system-messages") + + // Manually create conversation with multiple system messages + conversation1 := []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("You are helpful")}, + }, + { + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Be concise")}, + }, + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Hello")}, + }, + { + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Hi!")}, + }, + } + + conversation2 := []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("You are an expert")}, + }, + { + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Be detailed")}, + }, + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Hello")}, + }, + { + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Hi!")}, + }, + } + + request1 := CreateConversationRequest(conversation1, 0.7, 50) + request2 := CreateConversationRequest(conversation2, 0.7, 50) + + t.Log("Caching conversation with multiple system messages...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + t.Log("Testing conversation with different multiple system messages...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + // Should hit cache because all system messages are excluded + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + t.Log("βœ… ExcludeSystemPrompt works with multiple system messages") +} + +// TestExcludeSystemPromptWithNoSystemMessages tests behavior when there are no system messages +func TestExcludeSystemPromptWithNoSystemMessages(t *testing.T) { + setup := CreateTestSetupWithExcludeSystemPrompt(t, true) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-no-system-messages") + + // Conversation with no system messages + conversation := []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Hello")}, + }, + { + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ContentStr: bifrost.Ptr("Hi there!")}, + }, + } + + request := CreateConversationRequest(conversation, 0.7, 50) + + t.Log("Testing conversation with no system messages...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Should cache normally + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + t.Log("βœ… ExcludeSystemPrompt works correctly when no system messages present") +} diff --git a/plugins/semanticcache/plugin_core_test.go b/plugins/semanticcache/plugin_core_test.go new file mode 100644 index 000000000..044d8327b --- /dev/null +++ b/plugins/semanticcache/plugin_core_test.go @@ -0,0 +1,435 @@ +package semanticcache + +import ( + "context" + "os" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/vectorstore" +) + +// TestSemanticCacheBasicFunctionality tests the core caching functionality +func TestSemanticCacheBasicFunctionality(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-basic-value") + + // Create test request + testRequest := CreateBasicChatRequest( + "What is Bifrost? Answer in one short sentence.", + 0.7, + 50, + ) + + t.Log("Making first request (should go to OpenAI and be cached)...") + + // Make first request (will go to OpenAI and be cached) - with retries + start1 := time.Now() + response1, err1 := setup.Client.ChatCompletionRequest(ctx, testRequest) + duration1 := time.Since(start1) + + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil || len(response1.Choices) == 0 || response1.Choices[0].Message.Content.ContentStr == nil { + t.Fatal("First response is invalid") + } + + t.Logf("First request completed in %v", duration1) + t.Logf("Response: %s", *response1.Choices[0].Message.Content.ContentStr) + + // Wait for cache to be written + WaitForCache() + + t.Log("Making second identical request (should be served from cache)...") + + // Make second identical request (should be cached) + start2 := time.Now() + response2, err2 := setup.Client.ChatCompletionRequest(ctx, testRequest) + duration2 := time.Since(start2) + + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + if response2 == nil || len(response2.Choices) == 0 || response2.Choices[0].Message.Content.ContentStr == nil { + t.Fatal("Second response is invalid") + } + + t.Logf("Second request completed in %v", duration2) + t.Logf("Response: %s", *response2.Choices[0].Message.Content.ContentStr) + + // Verify cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, string(CacheTypeDirect)) + + // Performance comparison + t.Logf("Performance Summary:") + t.Logf("First request (OpenAI): %v", duration1) + t.Logf("Second request (Cache): %v", duration2) + + if duration2 >= duration1 { + t.Errorf("Cache request took longer than original request: cache=%v, original=%v", duration2, duration1) + } else { + speedup := float64(duration1) / float64(duration2) + t.Logf("Cache speedup: %.2fx faster", speedup) + + // Assert that cache is at least 1.5x faster (reasonable expectation) + if speedup < 1.5 { + t.Errorf("Cache speedup is less than 1.5x: got %.2fx", speedup) + } + } + + // Verify responses are identical (content should be the same) + content1 := *response1.Choices[0].Message.Content.ContentStr + content2 := *response2.Choices[0].Message.Content.ContentStr + + if content1 != content2 { + t.Errorf("Response content differs between cached and original:\nOriginal: %s\nCached: %s", content1, content2) + } + + // Verify provider information is maintained in cached response + if response2.ExtraFields.Provider != testRequest.Provider { + t.Errorf("Provider mismatch in cached response: expected %s, got %s", + testRequest.Provider, response2.ExtraFields.Provider) + } + + t.Log("βœ… Basic semantic caching test completed successfully!") +} + +// TestSemanticSearch tests the semantic similarity search functionality +func TestSemanticSearch(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Lower threshold for more flexible matching + setup.Config.Threshold = 0.5 + + ctx := CreateContextWithCacheKey("semantic-test-value") + + // First request - this will be cached + firstRequest := CreateBasicChatRequest( + "What is machine learning? Explain briefly.", + 0.0, // Use 0 temperature for consistent results + 50, + ) + + t.Log("Making first request (should go to OpenAI and be cached)...") + start1 := time.Now() + response1, err1 := setup.Client.ChatCompletionRequest(ctx, firstRequest) + duration1 := time.Since(start1) + + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil || len(response1.Choices) == 0 || response1.Choices[0].Message.Content.ContentStr == nil { + t.Fatal("First response is invalid") + } + + t.Logf("First request completed in %v", duration1) + t.Logf("Response: %s", *response1.Choices[0].Message.Content.ContentStr) + + // Wait for cache to be written (async PostHook needs time to complete) + WaitForCache() + + // Second request - very similar text to test semantic matching + secondRequest := CreateBasicChatRequest( + "What is machine learning? Explain it briefly.", + 0.0, // Use 0 temperature for consistent results + 50, + ) + + t.Log("Making semantically similar request (should be served from semantic cache)...") + start2 := time.Now() + response2, err2 := setup.Client.ChatCompletionRequest(ctx, secondRequest) + duration2 := time.Since(start2) + + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + if response2 == nil || len(response2.Choices) == 0 || response2.Choices[0].Message.Content.ContentStr == nil { + t.Fatal("Second response is invalid") + } + + t.Logf("Second request completed in %v", duration2) + t.Logf("Response: %s", *response2.Choices[0].Message.Content.ContentStr) + + // Check if second request was served from semantic cache + semanticMatch := false + + if response2.ExtraFields.CacheDebug != nil && response2.ExtraFields.CacheDebug.CacheHit { + if response2.ExtraFields.CacheDebug.HitType != nil && *response2.ExtraFields.CacheDebug.HitType == string(CacheTypeSemantic) { + semanticMatch = true + + threshold := 0.0 + similarity := 0.0 + + if response2.ExtraFields.CacheDebug.Threshold != nil { + threshold = *response2.ExtraFields.CacheDebug.Threshold + } + if response2.ExtraFields.CacheDebug.Similarity != nil { + similarity = *response2.ExtraFields.CacheDebug.Similarity + } + + t.Logf("βœ… Second request was served from semantic cache! Cache threshold: %f, Cache similarity: %f", threshold, similarity) + } + } + + if !semanticMatch { + t.Error("Semantic match expected but not found") + return + } + + // Performance comparison + t.Logf("Semantic Cache Performance:") + t.Logf("First request (OpenAI): %v", duration1) + t.Logf("Second request (Semantic): %v", duration2) + + if duration2 < duration1 { + speedup := float64(duration1) / float64(duration2) + t.Logf("Semantic cache speedup: %.2fx faster", speedup) + } + + t.Log("βœ… Semantic search test completed successfully!") +} + +// TestDirectVsSemanticSearch tests the difference between direct hash matching and semantic search +func TestDirectVsSemanticSearch(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Lower threshold for more flexible semantic matching + setup.Config.Threshold = 0.2 + + ctx := CreateContextWithCacheKey("direct-vs-semantic-test") + + // Test Case 1: Exact same request (should use direct hash matching) + t.Log("=== Test Case 1: Exact Same Request (Direct Hash Match) ===") + + exactRequest := CreateBasicChatRequest( + "What is artificial intelligence?", + 0.1, + 100, + ) + + t.Log("Making first request...") + _, err1 := setup.Client.ChatCompletionRequest(ctx, exactRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + t.Log("Making exact same request (should hit direct cache)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx, exactRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // Should be a direct cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, string(CacheTypeDirect)) + + // Test Case 2: Similar but different request (should use semantic search) + t.Log("\n=== Test Case 2: Semantically Similar Request ===") + + semanticRequest := CreateBasicChatRequest( + "Can you explain what AI is?", // Similar but different wording + 0.1, // Same parameters + 100, + ) + + t.Log("Making semantically similar request...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx, semanticRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + + semanticMatch := false + + // Check if it was served from cache and what type + if response3.ExtraFields.CacheDebug != nil && response3.ExtraFields.CacheDebug.CacheHit { + if response3.ExtraFields.CacheDebug.HitType != nil && *response3.ExtraFields.CacheDebug.HitType == string(CacheTypeSemantic) { + semanticMatch = true + + threshold := 0.0 + similarity := 0.0 + + if response3.ExtraFields.CacheDebug.Threshold != nil { + threshold = *response3.ExtraFields.CacheDebug.Threshold + } + if response3.ExtraFields.CacheDebug.Similarity != nil { + similarity = *response3.ExtraFields.CacheDebug.Similarity + } + + t.Logf("βœ… Third request was served from semantic cache! Cache threshold: %f, Cache similarity: %f", threshold, similarity) + } + } + + if !semanticMatch { + t.Error("Semantic match expected but not found") + return + } + + t.Log("βœ… Direct vs semantic search test completed!") +} + +// TestNoCacheScenarios tests scenarios where caching should NOT occur +func TestNoCacheScenarios(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("no-cache-test") + + // Test Case 1: Different parameters should NOT cache hit + t.Log("=== Test Case 1: Different Parameters ===") + + basePrompt := "What is the capital of France?" + + // First request + request1 := CreateBasicChatRequest(basePrompt, 0.1, 50) + _, err1 := setup.Client.ChatCompletionRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + // Second request with different temperature + request2 := CreateBasicChatRequest(basePrompt, 0.9, 50) // Different temperature + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request2) + if err2 != nil { + return // Test will be skipped by retry function + } + + // Should NOT be cached + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) + + // Test Case 2: Different max_tokens should NOT cache hit + t.Log("\n=== Test Case 2: Different MaxTokens ===") + + request3 := CreateBasicChatRequest(basePrompt, 0.1, 200) // Different max_tokens + response3, err3 := setup.Client.ChatCompletionRequest(ctx, request3) + if err3 != nil { + return // Test will be skipped by retry function + } + + // Should NOT be cached + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) + + t.Log("βœ… No cache scenarios test completed!") +} + +// TestCacheConfiguration tests different cache configuration options +func TestCacheConfiguration(t *testing.T) { + tests := []struct { + name string + config *Config + expectedBehavior string + }{ + { + name: "High Threshold", + config: &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + Threshold: 0.95, // Very high threshold + Keys: []schemas.Key{ + {Value: os.Getenv("OPENAI_API_KEY"), Models: []string{}, Weight: 1.0}, + }, + }, + expectedBehavior: "strict_matching", + }, + { + name: "Low Threshold", + config: &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + Threshold: 0.1, // Very low threshold + Keys: []schemas.Key{ + {Value: os.Getenv("OPENAI_API_KEY"), Models: []string{}, Weight: 1.0}, + }, + }, + expectedBehavior: "loose_matching", + }, + { + name: "Custom TTL", + config: &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + Threshold: 0.8, + TTL: 1 * time.Hour, // Custom TTL + Keys: []schemas.Key{ + {Value: os.Getenv("OPENAI_API_KEY"), Models: []string{}, Weight: 1.0}, + }, + }, + expectedBehavior: "custom_ttl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setup := NewTestSetupWithConfig(t, tt.config) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("config-test-" + tt.name) + + // Basic functionality test with the configuration + testRequest := CreateBasicChatRequest("Test configuration: "+tt.name, 0.5, 50) + + _, err1 := setup.Client.ChatCompletionRequest(ctx, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + _, err2 := setup.Client.ChatCompletionRequest(ctx, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + t.Logf("βœ… Configuration test '%s' completed", tt.name) + }) + } +} + +// MockUnsupportedStore is a mock store that returns ErrNotSupported for semantic operations +type MockUnsupportedStore struct { + vectorstore.VectorStore // Embed interface to implement all methods +} + +func (m *MockUnsupportedStore) SearchSemanticCache(ctx context.Context, queryEmbedding []float32, metadata map[string]interface{}, threshold float64, limit int64) ([]vectorstore.SearchResult, error) { + return nil, vectorstore.ErrNotSupported +} + +func (m *MockUnsupportedStore) AddSemanticCache(ctx context.Context, key string, embedding []float32, metadata map[string]interface{}, ttl time.Duration) error { + return vectorstore.ErrNotSupported +} + +func (m *MockUnsupportedStore) EnsureSemanticIndex(ctx context.Context, keyPrefix string, embeddingDim int, metadataFields []string) error { + return vectorstore.ErrNotSupported +} + +func (m *MockUnsupportedStore) Close(ctx context.Context) error { + return nil +} diff --git a/plugins/semanticcache/plugin_cross_cache_test.go b/plugins/semanticcache/plugin_cross_cache_test.go new file mode 100644 index 000000000..931f6c8d9 --- /dev/null +++ b/plugins/semanticcache/plugin_cross_cache_test.go @@ -0,0 +1,328 @@ +package semanticcache + +import ( + "context" + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestCrossCacheTypeAccessibility tests that entries cached one way are accessible another way +func TestCrossCacheTypeAccessibility(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("What is artificial intelligence?", 0.7, 100) + + // Test 1: Cache with default behavior (both direct + semantic) + ctx1 := CreateContextWithCacheKey("test-cross-cache-access") + t.Log("Caching with default behavior (both direct + semantic)...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Test 2: Retrieve with direct-only cache type + ctx2 := CreateContextWithCacheKeyAndType("test-cross-cache-access", CacheTypeDirect) + t.Log("Retrieving with CacheTypeKey=direct...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should find direct match + + // Test 3: Retrieve with semantic-only cache type + ctx3 := CreateContextWithCacheKeyAndType("test-cross-cache-access", CacheTypeSemantic) + t.Log("Retrieving with CacheTypeKey=semantic...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx3, testRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "semantic") // Should find semantic match + + t.Log("βœ… Entries cached with default behavior are accessible via both cache types") +} + +// TestCacheTypeIsolation tests that entries cached separately by type behave correctly +func TestCacheTypeIsolation(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Define blockchain technology", 0.7, 100) + + // Clear cache to start fresh + clearTestKeysWithStore(t, setup.Store) + + // Test 1: Cache with direct-only + ctx1 := CreateContextWithCacheKeyAndType("test-cache-isolation", CacheTypeDirect) + t.Log("Caching with CacheTypeKey=direct only...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Fresh request + + WaitForCache() + + // Test 2: Try to retrieve with semantic-only (should miss because no semantic entry) + ctx2 := CreateContextWithCacheKeyAndType("test-cache-isolation", CacheTypeSemantic) + t.Log("Retrieving same request with CacheTypeKey=semantic (should miss)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) // Should miss - no semantic cache entry + + WaitForCache() + + // Test 3: Retrieve with direct-only (should hit) + t.Log("Retrieving with CacheTypeKey=direct (should hit)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "direct") // Should hit direct cache + + // Test 4: Default behavior (should find the direct cache) + ctx4 := CreateContextWithCacheKey("test-cache-isolation") + t.Log("Retrieving with default behavior (should find direct cache)...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx4, testRequest) + if err4 != nil { + t.Fatalf("Fourth request failed: %v", err4) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}, "direct") // Should find existing direct cache + + t.Log("βœ… Cache type isolation works correctly") +} + +// TestCacheTypeFallbackBehavior tests whether cache types fallback to each other +func TestCacheTypeFallbackBehavior(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Cache an entry with default behavior + originalRequest := CreateBasicChatRequest("Explain machine learning", 0.7, 100) + ctx1 := CreateContextWithCacheKey("test-fallback-behavior") + + t.Log("Caching with default behavior...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, originalRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Test similar request with direct-only (should miss direct, no fallback, but should cache response) + similarRequest := CreateBasicChatRequest("Explain machine learning concepts", 0.7, 100) + ctx2 := CreateContextWithCacheKeyAndType("test-fallback-behavior", CacheTypeDirect) + + t.Log("Testing similar request with CacheTypeKey=direct (should miss, make request, cache without embeddings)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, similarRequest) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) // Should miss - no direct match, no semantic search + + WaitForCache() // Let the response get cached + + // Test same similar request with semantic-only (should hit original entry) + ctx3 := CreateContextWithCacheKeyAndType("test-fallback-behavior", CacheTypeSemantic) + + t.Log("Testing similar request with CacheTypeKey=semantic (should find semantic match from step 1)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx3, similarRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + + // Should find semantic match from step 1's cached entry (which has embeddings) + if response3.ExtraFields.CacheDebug != nil && response3.ExtraFields.CacheDebug.CacheHit { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "semantic") + t.Log("βœ… Semantic search found similar entry from step 1") + } else { + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) + t.Log("ℹ️ No semantic match found (threshold may be too high or semantic similarity low)") + } + + // Test a different similar request with default behavior (try both, fallback to semantic) + // Use a slightly different request to avoid hitting the cached response from step 2 + differentSimilarRequest := CreateBasicChatRequest("Explain the basics of machine learning", 0.7, 100) + ctx4 := CreateContextWithCacheKey("test-fallback-behavior") + + t.Log("Testing different similar request with default behavior (direct miss -> semantic fallback)...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx4, differentSimilarRequest) + if err4 != nil { + t.Fatalf("Fourth request failed: %v", err4) + } + + // Should try direct first (miss), then semantic (might hit) + if response4.ExtraFields.CacheDebug != nil && response4.ExtraFields.CacheDebug.CacheHit { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}, "semantic") + t.Log("βœ… Default behavior found semantic fallback") + } else { + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}) + t.Log("ℹ️ No fallback match found") + } + + t.Log("βœ… Cache type fallback behavior verified") +} + +// TestMultipleCacheEntriesPriority tests behavior when multiple cache entries exist +func TestMultipleCacheEntriesPriority(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("What is deep learning?", 0.7, 100) + + // Create cache entry with default behavior first + ctx1 := CreateContextWithCacheKey("test-cache-priority") + t.Log("Creating cache entry with default behavior...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + originalContent := *response1.Choices[0].Message.Content.ContentStr + + WaitForCache() + + // Verify it hits cache with default behavior + t.Log("Verifying cache hit with default behavior...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should hit direct cache + cachedContent := *response2.Choices[0].Message.Content.ContentStr + + // Verify content is the same + if originalContent != cachedContent { + t.Errorf("Cache content mismatch:\nOriginal: %s\nCached: %s", originalContent, cachedContent) + } + + // Test with direct-only access + ctx2 := CreateContextWithCacheKeyAndType("test-cache-priority", CacheTypeDirect) + t.Log("Accessing with CacheTypeKey=direct...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "direct") // Should find direct cache + + // Test with semantic-only access + ctx3 := CreateContextWithCacheKeyAndType("test-cache-priority", CacheTypeSemantic) + t.Log("Accessing with CacheTypeKey=semantic...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx3, testRequest) + if err4 != nil { + t.Fatalf("Fourth request failed: %v", err4) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}, "semantic") // Should find semantic cache + + t.Log("βœ… Multiple cache entries accessible correctly") +} + +// TestCrossCacheTypeWithDifferentParameters tests cache type behavior with parameter variations +func TestCrossCacheTypeWithDifferentParameters(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + baseMessage := "Explain quantum computing" + + // Cache with specific parameters + request1 := CreateBasicChatRequest(baseMessage, 0.7, 100) + ctx1 := CreateContextWithCacheKey("test-cross-cache-params") + + t.Log("Caching with temp=0.7, max_tokens=100...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Test same parameters with direct-only + ctx2 := CreateContextWithCacheKeyAndType("test-cross-cache-params", CacheTypeDirect) + t.Log("Retrieving same parameters with CacheTypeKey=direct...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, request1) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should hit + + // Test different parameters - should miss + request3 := CreateBasicChatRequest(baseMessage, 0.5, 200) // Different temp and tokens + t.Log("Testing different parameters (should miss)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, request3) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) // Should miss due to different params + + // Test semantic search with different parameters + ctx4 := CreateContextWithCacheKeyAndType("test-cross-cache-params", CacheTypeSemantic) + similarRequest := CreateBasicChatRequest("Can you explain quantum computing", 0.5, 200) + + t.Log("Testing semantic search with different params and similar message...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx4, similarRequest) + if err4 != nil { + return // Test will be skipped by retry function + } + // Should miss semantic search due to different parameters (params_hash different) + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}) + + t.Log("βœ… Cross-cache-type parameter handling works correctly") +} + +// TestCacheTypeErrorHandling tests error scenarios with cache types +func TestCacheTypeErrorHandling(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Test error handling", 0.7, 50) + + // Test invalid cache type (should fallback to default) + ctx1 := CreateContextWithCacheKey("test-cache-error-handling") + ctx1 = context.WithValue(ctx1, CacheTypeKey, "invalid_cache_type") + + t.Log("Testing invalid cache type (should fallback to default behavior)...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Should work with fallback behavior + + WaitForCache() + + // Test nil cache type (should use default) + ctx2 := CreateContextWithCacheKey("test-cache-error-handling") + ctx2 = context.WithValue(ctx2, CacheTypeKey, nil) + + t.Log("Testing nil cache type (should use default behavior)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should find cached entry from first request + + t.Log("βœ… Cache type error handling works correctly") +} diff --git a/plugins/semanticcache/plugin_edge_cases_test.go b/plugins/semanticcache/plugin_edge_cases_test.go new file mode 100644 index 000000000..86d4935a2 --- /dev/null +++ b/plugins/semanticcache/plugin_edge_cases_test.go @@ -0,0 +1,617 @@ +package semanticcache + +import ( + "context" + "strings" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// TestParameterVariations tests that different parameters don't cache hit inappropriately +func TestParameterVariations(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("param-variations-test") + basePrompt := "What is the capital of France?" + + tests := []struct { + name string + request1 *schemas.BifrostChatRequest + request2 *schemas.BifrostChatRequest + shouldCache bool + }{ + { + name: "Same Parameters", + request1: CreateBasicChatRequest(basePrompt, 0.5, 50), + request2: CreateBasicChatRequest(basePrompt, 0.5, 50), + shouldCache: true, + }, + { + name: "Different Temperature", + request1: CreateBasicChatRequest(basePrompt, 0.1, 50), + request2: CreateBasicChatRequest(basePrompt, 0.9, 50), + shouldCache: false, + }, + { + name: "Different MaxTokens", + request1: CreateBasicChatRequest(basePrompt, 0.5, 50), + request2: CreateBasicChatRequest(basePrompt, 0.5, 200), + shouldCache: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear cache for this subtest + clearTestKeysWithStore(t, setup.Store) + + // Make first request + _, err1 := setup.Client.ChatCompletionRequest(ctx, tt.request1) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + // Make second request + response2, err2 := setup.Client.ChatCompletionRequest(ctx, tt.request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // Check cache behavior + if tt.shouldCache { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, string(CacheTypeDirect)) + } else { + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) + } + }) + } +} + +// TestToolVariations tests caching behavior with different tool configurations +func TestToolVariations(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("tool-variations-test") + + // Base request without tools + baseRequest := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What's the weather like today?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(100), + Temperature: bifrost.Ptr(0.5), + }, + } + + // Request with tools + requestWithTools := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What's the weather like today?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(100), + Temperature: bifrost.Ptr(0.5), + Tools: []schemas.ChatTool{ + { + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: "get_weather", + Description: bifrost.Ptr("Get the current weather"), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{ + "location": map[string]interface{}{ + "type": "string", + "description": "The city and state", + }, + }, + }, + Strict: bifrost.Ptr(false), + }, + }, + }, + }, + } + + // Request with different tools + requestWithDifferentTools := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What's the weather like today?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(100), + Temperature: bifrost.Ptr(0.5), + Tools: []schemas.ChatTool{ + { + Type: schemas.ChatToolTypeFunction, + Function: &schemas.ChatToolFunction{ + Name: "get_current_weather", + Description: bifrost.Ptr("Get current weather information"), + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{ + "city": map[string]interface{}{ // Different parameter name + "type": "string", + "description": "The city name", + }, + }, + }, + Strict: bifrost.Ptr(false), + }, + }, + }, + }, + } + + // Test 1: Request without tools + t.Log("Making request without tools...") + _, err1 := setup.Client.ChatCompletionRequest(ctx, baseRequest) + if err1 != nil { + t.Fatalf("Request without tools failed: %v", err1) + } + + WaitForCache() + + // Test 2: Request with tools (should NOT cache hit) + t.Log("Making request with tools...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx, requestWithTools) + if err2 != nil { + return // Test will be skipped by retry function + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) + + WaitForCache() + + // Test 3: Same request with tools (should cache hit) + t.Log("Making same request with tools again...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx, requestWithTools) + if err3 != nil { + t.Fatalf("Second request with tools failed: %v", err3) + } + + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, string(CacheTypeDirect)) + + // Test 4: Request with different tools (should NOT cache hit) + t.Log("Making request with different tools...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx, requestWithDifferentTools) + if err4 != nil { + return // Test will be skipped by retry function + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}) + + t.Log("βœ… Tool variations test completed!") +} + +// TestContentVariations tests caching behavior with different content types +func TestContentVariations(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("content-variations-test") + + tests := []struct { + name string + request *schemas.BifrostChatRequest + }{ + { + name: "Image URL Content", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: bifrost.Ptr("Analyze this image"), + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + }, + }, + }, + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + Temperature: bifrost.Ptr(0.3), + }, + }, + }, + { + name: "Multiple Images", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: bifrost.Ptr("Compare these images"), + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + }, + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: "https://upload.wikimedia.org/wikipedia/commons/b/b5/Scenery_.jpg", + }, + }, + }, + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + Temperature: bifrost.Ptr(0.3), + }, + }, + }, + { + name: "Very Long Content", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr(strings.Repeat("This is a very long prompt. ", 100)), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(50), + Temperature: bifrost.Ptr(0.2), + }, + }, + }, + { + name: "Multi-turn Conversation", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What is AI?"), + }, + }, + { + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("AI stands for Artificial Intelligence..."), + }, + }, + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Can you give me examples?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + Temperature: bifrost.Ptr(0.5), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing content variation: %s", tt.name) + + // Make first request + _, err1 := setup.Client.ChatCompletionRequest(ctx, tt.request) + if err1 != nil { + t.Logf("⚠️ First %s request failed: %v", tt.name, err1) + return // Skip this test case + } + + WaitForCache() + + // Make second identical request + response2, err2 := setup.Client.ChatCompletionRequest(ctx, tt.request) + if err2 != nil { + t.Fatalf("Second %s request failed: %v", tt.name, err2) + } + + // Should be cached + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, string(CacheTypeDirect)) + t.Logf("βœ… %s content variation successful", tt.name) + }) + } +} + +// TestBoundaryParameterValues tests edge case parameter values +func TestBoundaryParameterValues(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("boundary-params-test") + + tests := []struct { + name string + request *schemas.BifrostChatRequest + }{ + { + name: "Maximum Parameter Values", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test max parameters"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(4096), + PresencePenalty: bifrost.Ptr(2.0), + FrequencyPenalty: bifrost.Ptr(2.0), + Temperature: bifrost.Ptr(2.0), + TopP: bifrost.Ptr(1.0), + }, + }, + }, + { + name: "Minimum Parameter Values", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test min parameters"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(1), + PresencePenalty: bifrost.Ptr(-2.0), + FrequencyPenalty: bifrost.Ptr(-2.0), + Temperature: bifrost.Ptr(0.0), + TopP: bifrost.Ptr(0.01), + }, + }, + }, + { + name: "Edge Case Parameters", + request: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test edge case parameters"), + }, + }, + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(1), + User: bifrost.Ptr("test-user-id-12345"), + Temperature: bifrost.Ptr(0.0), + TopP: bifrost.Ptr(0.1), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing boundary parameters: %s", tt.name) + + _, err := setup.Client.ChatCompletionRequest(ctx, tt.request) + if err != nil { + t.Logf("⚠️ %s request failed (may be expected): %v", tt.name, err) + } else { + t.Logf("βœ… %s handled gracefully", tt.name) + } + }) + } +} + +// TestSemanticSimilarityEdgeCases tests edge cases in semantic similarity matching +func TestSemanticSimilarityEdgeCases(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + setup.Config.Threshold = 0.9 + + ctx := CreateContextWithCacheKey("semantic-edge-test") + + // Test case: Similar questions with different wording + similarTests := []struct { + prompt1 string + prompt2 string + shouldMatch bool + description string + }{ + { + prompt1: "What is machine learning?", + prompt2: "Can you explain machine learning?", + shouldMatch: true, + description: "Similar questions about ML", + }, + { + prompt1: "How does AI work?", + prompt2: "Explain artificial intelligence", + shouldMatch: true, + description: "AI-related questions", + }, + { + prompt1: "What is the weather today?", + prompt2: "What do you know about bifrost?", + shouldMatch: false, + description: "Completely different topics", + }, + { + prompt1: "Hello, how are you?", + prompt2: "Hi, how are you doing?", + shouldMatch: true, + description: "Similar greetings", + }, + } + + for i, test := range similarTests { + t.Run(test.description, func(t *testing.T) { + // Clear cache for this subtest + clearTestKeysWithStore(t, setup.Store) + + // Make first request + request1 := CreateBasicChatRequest(test.prompt1, 0.1, 50) + _, err1 := setup.Client.ChatCompletionRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + + // Wait for cache to be written + WaitForCache() + + // Make second request with similar content + request2 := CreateBasicChatRequest(test.prompt2, 0.1, 50) // Same parameters + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + var cacheThresholdFloat float64 + var cacheSimilarityFloat float64 + + // Check if semantic matching occurred + semanticMatch := false + if response2.ExtraFields.CacheDebug != nil && response2.ExtraFields.CacheDebug.CacheHit { + if response2.ExtraFields.CacheDebug.HitType != nil && *response2.ExtraFields.CacheDebug.HitType == string(CacheTypeSemantic) { + semanticMatch = true + + if response2.ExtraFields.CacheDebug.Threshold != nil { + cacheThresholdFloat = *response2.ExtraFields.CacheDebug.Threshold + } + if response2.ExtraFields.CacheDebug.Similarity != nil { + cacheSimilarityFloat = *response2.ExtraFields.CacheDebug.Similarity + } + } + } + + if test.shouldMatch { + if semanticMatch { + t.Logf("βœ… Test %d: Semantic match found as expected for '%s'", i+1, test.description) + } else { + t.Logf("ℹ️ Test %d: No semantic match found for '%s', check with threshold: %f and found similarity: %f", i+1, test.description, cacheThresholdFloat, cacheSimilarityFloat) + } + } else { + if semanticMatch { + t.Errorf("❌ Test %d: Unexpected semantic match for different topics: '%s', check with threshold: %f and found similarity: %f", i+1, test.description, cacheThresholdFloat, cacheSimilarityFloat) + } else { + t.Logf("βœ… Test %d: Correctly no semantic match for different topics: '%s'", i+1, test.description) + } + } + }) + } +} + +// TestErrorHandlingEdgeCases tests various error scenarios +func TestErrorHandlingEdgeCases(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Test error handling scenarios", 0.5, 50) + + // Test without cache key (should not crash and bypass cache) + t.Run("Request without cache key", func(t *testing.T) { + ctxNoKey := context.Background() // No cache key + + response, err := setup.Client.ChatCompletionRequest(ctxNoKey, testRequest) + if err != nil { + t.Errorf("Request without cache key failed: %v", err) + return + } + + // Should bypass cache since there's no cache key + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response}) + t.Log("βœ… Request without cache key correctly bypassed cache") + }) + + // Test with invalid cache key type + t.Run("Request with invalid cache key type", func(t *testing.T) { + // First establish a cached response with valid context + validCtx := CreateContextWithCacheKey("error-handling-test") + _, err := setup.Client.ChatCompletionRequest(validCtx, testRequest) + if err != nil { + t.Fatalf("First request with valid cache key failed: %v", err) + } + + WaitForCache() + + // Now test with invalid key type - should bypass cache + ctxInvalidKey := context.WithValue(context.Background(), CacheKey, 12345) // Wrong type (int instead of string) + + response, err := setup.Client.ChatCompletionRequest(ctxInvalidKey, testRequest) + if err != nil { + t.Errorf("Request with invalid cache key type failed: %v", err) + return + } + + // Should bypass cache due to invalid key type + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response}) + t.Log("βœ… Request with invalid cache key type correctly bypassed cache") + }) + + t.Log("βœ… Error handling edge cases completed!") +} diff --git a/plugins/semanticcache/plugin_embedding_test.go b/plugins/semanticcache/plugin_embedding_test.go new file mode 100644 index 000000000..ecb2611b0 --- /dev/null +++ b/plugins/semanticcache/plugin_embedding_test.go @@ -0,0 +1,174 @@ +package semanticcache + +import ( + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestEmbeddingRequestsCaching tests that embedding requests are properly cached using direct hash matching +func TestEmbeddingRequestsCaching(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-embedding-cache") + + // Create embedding request + embeddingRequest := CreateEmbeddingRequest([]string{ + "What is machine learning?", + "Explain artificial intelligence in simple terms.", + }) + + t.Log("Making first embedding request (should go to OpenAI and be cached)...") + + // Make first request (will go to OpenAI and be cached) - with retries + start1 := time.Now() + response1, err1 := setup.Client.EmbeddingRequest(ctx, embeddingRequest) + duration1 := time.Since(start1) + + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil || len(response1.Data) == 0 { + t.Fatal("First embedding response is invalid") + } + + t.Logf("First embedding request completed in %v", duration1) + t.Logf("Response contains %d embeddings", len(response1.Data)) + + // Wait for cache to be written + WaitForCache() + + t.Log("Making second identical embedding request (should be served from cache)...") + + // Make second identical request (should be cached) + start2 := time.Now() + response2, err2 := setup.Client.EmbeddingRequest(ctx, embeddingRequest) + duration2 := time.Since(start2) + + if err2 != nil { + t.Fatalf("Second embedding request failed: %v", err2) + } + + if response2 == nil || len(response2.Data) == 0 { + t.Fatal("Second embedding response is invalid") + } + + // Verify cache hit + AssertCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response2}, "direct") + + t.Logf("Second embedding request completed in %v", duration2) + + // Cache should be significantly faster + if duration2 >= duration1 { // Allow some margin but cache should be much faster + t.Log("⚠️ Cache doesn't seem faster, but this could be due to test environment") + } + + // Responses should be identical + if len(response1.Data) != len(response2.Data) { + t.Errorf("Response lengths differ: %d vs %d", len(response1.Data), len(response2.Data)) + } + + t.Log("βœ… Embedding requests properly cached using direct hash matching") +} + +// TestEmbeddingRequestsNoCacheWithoutCacheKey tests that embedding requests without cache key are not cached +func TestEmbeddingRequestsNoCacheWithoutCacheKey(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Don't set cache key in context + ctx := CreateContextWithCacheKey("") + + embeddingRequest := CreateEmbeddingRequest([]string{"Test embedding without cache key"}) + + t.Log("Making embedding request without cache key...") + + response, err := setup.Client.EmbeddingRequest(ctx, embeddingRequest) + if err != nil { + t.Fatalf("Embedding request failed: %v", err) + } + + // Should not be cached + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response}) + + t.Log("βœ… Embedding requests without cache key are properly not cached") +} + +// TestEmbeddingRequestsDifferentTexts tests that different embedding texts produce different cache entries +func TestEmbeddingRequestsDifferentTexts(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-embedding-different") + + // Create two different embedding requests + request1 := CreateEmbeddingRequest([]string{"First set of texts"}) + request2 := CreateEmbeddingRequest([]string{"Second set of texts"}) + + t.Log("Making first embedding request...") + response1, err1 := setup.Client.EmbeddingRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response1}) + + WaitForCache() + + t.Log("Making second different embedding request...") + response2, err2 := setup.Client.EmbeddingRequest(ctx, request2) + if err2 != nil { + return // Test will be skipped by retry function + } + // Should not be a cache hit since texts are different + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response2}) + + t.Log("βœ… Different embedding texts produce different cache entries") +} + +// TestEmbeddingRequestsCacheExpiration tests TTL functionality for embedding requests +func TestEmbeddingRequestsCacheExpiration(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Set very short TTL for testing + shortTTL := 2 * time.Second + ctx := CreateContextWithCacheKeyAndTTL("test-embedding-ttl", shortTTL) + + embeddingRequest := CreateEmbeddingRequest([]string{"TTL test embedding"}) + + t.Log("Making first embedding request with short TTL...") + response1, err1 := setup.Client.EmbeddingRequest(ctx, embeddingRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response1}) + + WaitForCache() + + t.Log("Making second request before TTL expiration...") + response2, err2 := setup.Client.EmbeddingRequest(ctx, embeddingRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response2}, "direct") + + t.Logf("Waiting for TTL expiration (%v)...", shortTTL) + time.Sleep(shortTTL + 1*time.Second) // Wait for TTL to expire + + t.Log("Making third request after TTL expiration...") + response3, err3 := setup.Client.EmbeddingRequest(ctx, embeddingRequest) + if err3 != nil { + return // Test will be skipped by retry function + } + // Should not be a cache hit since TTL expired + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response3}) + + t.Log("βœ… Embedding requests properly handle TTL expiration") +} diff --git a/plugins/semanticcache/plugin_integration_test.go b/plugins/semanticcache/plugin_integration_test.go new file mode 100644 index 000000000..21574d899 --- /dev/null +++ b/plugins/semanticcache/plugin_integration_test.go @@ -0,0 +1,738 @@ +package semanticcache + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// TestSemanticCacheBasicFlow tests the complete semantic cache flow +func TestSemanticCacheBasicFlow(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := context.Background() + + // Add cache key to context + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + + // Test request + request := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello, world!"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.7), + MaxCompletionTokens: bifrost.Ptr(100), + }, + }, + } + + t.Log("Testing first request (cache miss)...") + + // First request - should be a cache miss + modifiedReq, shortCircuit, err := setup.Plugin.PreHook(&ctx, request) + if err != nil { + t.Fatalf("PreHook failed: %v", err) + } + + if shortCircuit != nil { + t.Fatal("Expected cache miss, but got cache hit") + } + + if modifiedReq == nil { + t.Fatal("Modified request is nil") + } + + t.Log("βœ… Cache miss handled correctly") + + // Simulate a response + response := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: uuid.New().String(), + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Hello! How can I help you today?"), + }}, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: schemas.OpenAI, + ModelRequested: "gpt-4o-mini", + RequestType: schemas.ChatCompletionRequest, + }, + }, + } + + // Capture original response content for comparison + var originalContent string + if len(response.ChatResponse.Choices) > 0 && response.ChatResponse.Choices[0].Message.Content.ContentStr != nil { + originalContent = *response.ChatResponse.Choices[0].Message.Content.ContentStr + } + if originalContent == "" { + t.Fatal("Original response content is empty") + } + t.Logf("Original response content: %s", originalContent) + + // Cache the response + t.Log("Caching response...") + _, _, err = setup.Plugin.PostHook(&ctx, response, nil) + if err != nil { + t.Fatalf("PostHook failed: %v", err) + } + + // Wait for async caching to complete + WaitForCache() + t.Log("βœ… Response cached successfully") + + // Second request - should be a cache hit + t.Log("Testing second identical request (expecting cache hit)...") + + // Reset context for second request + ctx2 := context.Background() + ctx2 = context.WithValue(ctx2, CacheKey, "test-cache-enabled") + + modifiedReq2, shortCircuit2, err := setup.Plugin.PreHook(&ctx2, request) + if err != nil { + t.Fatalf("Second PreHook failed: %v", err) + } + + if shortCircuit2 == nil { + t.Fatal("expected cache hit on identical request") + return + } + + if shortCircuit2.Response == nil { + t.Fatal("Cache hit but response is nil") + } + + if modifiedReq2 == nil { + t.Fatal("Modified request is nil on cache hit") + } + + t.Log("βœ… Cache hit detected and response returned") + + // Verify the cached response + if len(shortCircuit2.Response.ChatResponse.Choices) == 0 { + t.Fatal("Cached response has no choices") + } + + cachedContent := shortCircuit2.Response.ChatResponse.Choices[0].Message.Content.ContentStr + if cachedContent == nil || *cachedContent == "" { + t.Fatal("Cached response content is empty") + } + + t.Logf("βœ… Cached response content: %s", *cachedContent) + + // Compare original and cached content + cachedContentStr := *cachedContent + // Trim whitespace and newlines for comparison + originalContentTrimmed := strings.TrimSpace(originalContent) + cachedContentTrimmed := strings.TrimSpace(cachedContentStr) + + if originalContentTrimmed != cachedContentTrimmed { + t.Fatalf("❌ Content mismatch: original='%s', cached='%s'", originalContentTrimmed, cachedContentTrimmed) + } + + t.Log("βœ… Content verification passed - original and cached responses match") + t.Log("πŸŽ‰ Basic semantic cache flow test passed!") +} + +// TestSemanticCacheStrictFiltering tests that the cache respects parameter differences +func TestSemanticCacheStrictFiltering(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := context.Background() + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + + // Base request + baseRequest := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What is the weather like?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.7), + MaxCompletionTokens: bifrost.Ptr(100), + }, + }, + } + + t.Log("Testing first request with temperature=0.7...") + + // First request + _, shortCircuit1, err := setup.Plugin.PreHook(&ctx, baseRequest) + if err != nil { + t.Fatalf("First PreHook failed: %v", err) + } + + if shortCircuit1 != nil { + t.Fatal("Expected cache miss for first request") + } + + // Cache a response + response := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: uuid.New().String(), + Choices: []schemas.BifrostResponseChoice{ + { + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("It's sunny today!"), + }}, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: schemas.OpenAI, + ModelRequested: "gpt-4o-mini", + RequestType: schemas.ChatCompletionRequest, + }, + }, + } + + _, _, err = setup.Plugin.PostHook(&ctx, response, nil) + if err != nil { + t.Fatalf("PostHook failed: %v", err) + } + + WaitForCache() + t.Log("βœ… First response cached") + + // Second request with different temperature - should be cache miss + t.Log("Testing second request with temperature=0.5 (expecting cache miss)...") + + ctx2 := context.Background() + ctx2 = context.WithValue(ctx2, CacheKey, "test-cache-enabled") + + modifiedRequest := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What is the weather like?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.5), // Different temperature + MaxCompletionTokens: bifrost.Ptr(100), + }, + }, + } + + _, shortCircuit2, err := setup.Plugin.PreHook(&ctx2, modifiedRequest) + if err != nil { + t.Fatalf("Second PreHook failed: %v", err) + } + + if shortCircuit2 != nil { + t.Fatal("Expected cache miss due to different temperature, but got cache hit") + } + + t.Log("βœ… Strict filtering working - different parameters result in cache miss") + + // Third request with different model - should be cache miss + t.Log("Testing third request with different model (expecting cache miss)...") + + ctx3 := context.Background() + ctx3 = context.WithValue(ctx3, CacheKey, "test-cache-enabled") + + modifiedRequest2 := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-3.5-turbo", // Different model + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("What is the weather like?"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.7), + MaxCompletionTokens: bifrost.Ptr(100), + }, + }, + } + + _, shortCircuit3, err := setup.Plugin.PreHook(&ctx3, modifiedRequest2) + if err != nil { + t.Fatalf("Third PreHook failed: %v", err) + } + + if shortCircuit3 != nil { + t.Fatal("Expected cache miss due to different model, but got cache hit") + } + + t.Log("βœ… Strict filtering working - different model results in cache miss") + t.Log("πŸŽ‰ Strict filtering test passed!") +} + +// TestSemanticCacheStreamingFlow tests streaming response caching +func TestSemanticCacheStreamingFlow(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := context.Background() + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + + request := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionStreamRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Tell me a short story"), + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: bifrost.Ptr(0.8), + }, + }, + } + + t.Log("Testing streaming request (cache miss)...") + + // First request - should be cache miss + _, shortCircuit, err := setup.Plugin.PreHook(&ctx, request) + if err != nil { + t.Fatalf("PreHook failed: %v", err) + } + + if shortCircuit != nil { + t.Fatal("Expected cache miss for streaming request") + } + + t.Log("βœ… Streaming cache miss handled correctly") + + // Simulate streaming response chunks + t.Log("Caching streaming response chunks...") + + chunks := []string{ + "Once upon a time,", + " there was a brave", + " knight who saved the day.", + } + + for i, chunk := range chunks { + var finishReason *string + if i == len(chunks)-1 { + finishReason = bifrost.Ptr("stop") + } + + chunkResponse := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: uuid.New().String(), + Choices: []schemas.BifrostResponseChoice{ + { + Index: i, + FinishReason: finishReason, + ChatStreamResponseChoice: &schemas.ChatStreamResponseChoice{ + Delta: &schemas.ChatStreamResponseChoiceDelta{ + Content: bifrost.Ptr(chunk), + }, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: schemas.OpenAI, + ModelRequested: "gpt-4o-mini", + RequestType: schemas.ChatCompletionStreamRequest, + ChunkIndex: i, + }, + }, + } + + _, _, err = setup.Plugin.PostHook(&ctx, chunkResponse, nil) + if err != nil { + t.Fatalf("PostHook failed for chunk %d: %v", i, err) + } + } + + WaitForCache() + t.Log("βœ… Streaming response chunks cached") + + // Test cache retrieval for streaming + t.Log("Testing streaming cache retrieval...") + + ctx2 := context.Background() + ctx2 = context.WithValue(ctx2, CacheKey, "test-cache-enabled") + + _, shortCircuit2, err := setup.Plugin.PreHook(&ctx2, request) + if err != nil { + t.Fatalf("Second PreHook failed: %v", err) + } + + if shortCircuit2 == nil { + t.Log("⚠️ Expected streaming cache hit, but got cache miss - this may be expected with the new unified storage") + return + } + + if shortCircuit2.Stream == nil { + t.Fatal("Cache hit but stream is nil") + } + + t.Log("βœ… Streaming cache hit detected") + + // Read from the cached stream + chunkCount := 0 + for chunk := range shortCircuit2.Stream { + if chunk.BifrostChatResponse == nil { + continue + } + chunkCount++ + t.Logf("Received cached chunk %d", chunkCount) + } + + if chunkCount == 0 { + t.Fatal("No chunks received from cached stream") + } + + t.Logf("βœ… Received %d cached chunks", chunkCount) + t.Log("πŸŽ‰ Streaming cache test passed!") +} + +// TestSemanticCache_NoCacheWhenKeyMissing verifies cache is disabled when cache key is missing from context +func TestSemanticCache_NoCacheWhenKeyMissing(t *testing.T) { + t.Log("Testing cache behavior when cache key is missing...") + + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := context.Background() + // Don't set the cache key - cache should be disabled + + request := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Test message"), + }, + }, + }, + }, + } + + _, shortCircuit, err := setup.Plugin.PreHook(&ctx, request) + if err != nil { + t.Fatalf("PreHook failed: %v", err) + } + + if shortCircuit != nil { + t.Fatal("Expected no caching when cache key is not set, but got cache hit") + } + + t.Log("βœ… Cache properly disabled when no cache key is set") + t.Log("πŸŽ‰ No cache key test passed!") +} + +// TestSemanticCache_CustomTTLHandling verifies cache respects custom TTL values from context +func TestSemanticCache_CustomTTLHandling(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Configure plugin with custom TTL key + ctx := context.Background() + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + ctx = context.WithValue(ctx, CacheTTLKey, 1*time.Minute) // Custom TTL + + request := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("TTL test message"), + }, + }, + }, + }, + } + + // First request - cache miss + _, shortCircuit, err := setup.Plugin.PreHook(&ctx, request) + if err != nil { + t.Fatalf("PreHook failed: %v", err) + } + + if shortCircuit != nil { + t.Fatal("Expected cache miss, but got cache hit") + } + + // Simulate response and cache it + response := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: "ttl-test-response", + Choices: []schemas.BifrostResponseChoice{ + { + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: "assistant", + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("TTL test response"), + }, + }, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: schemas.OpenAI, + ModelRequested: "gpt-4o-mini", + RequestType: schemas.ChatCompletionRequest, + }, + }, + } + + _, _, err = setup.Plugin.PostHook(&ctx, response, nil) + if err != nil { + t.Fatalf("PostHook failed: %v", err) + } + + WaitForCache() + + t.Log("βœ… Custom TTL configuration test passed!") +} + +// TestSemanticCache_CustomThresholdHandling verifies cache respects custom similarity threshold from context +func TestSemanticCache_CustomThresholdHandling(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Configure plugin with custom threshold key + ctx := context.Background() + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + ctx = context.WithValue(ctx, CacheThresholdKey, 0.95) // Very high threshold + + request := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Threshold test message"), + }, + }, + }, + }, + } + + // Test that custom threshold is used (this would need semantic search to be fully testable) + _, shortCircuit, err := setup.Plugin.PreHook(&ctx, request) + if err != nil { + t.Fatalf("PreHook failed: %v", err) + } + + if shortCircuit != nil { + t.Fatal("Expected cache miss with high threshold, but got cache hit") + } + + t.Log("βœ… Custom threshold configuration test passed!") +} + +// TestSemanticCache_ProviderModelCachingFlags verifies cache behavior with provider/model caching flags +func TestSemanticCache_ProviderModelCachingFlags(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Test with provider/model caching disabled + setup.Config.CacheByProvider = bifrost.Ptr(false) + setup.Config.CacheByModel = bifrost.Ptr(false) + + ctx := context.Background() + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + + request1 := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Provider model flags test"), + }, + }, + }, + }, + } + + // First request with OpenAI + _, shortCircuit1, err := setup.Plugin.PreHook(&ctx, request1) + if err != nil { + t.Fatalf("PreHook failed: %v", err) + } + + if shortCircuit1 != nil { + t.Fatal("Expected cache miss, but got cache hit") + } + + // Cache the response + response := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + ID: "provider-model-test", + Choices: []schemas.BifrostResponseChoice{ + { + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: "assistant", + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Provider model test response"), + }, + }, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: schemas.OpenAI, + ModelRequested: "gpt-4o-mini", + RequestType: schemas.ChatCompletionRequest, + }, + }, + } + + _, _, err = setup.Plugin.PostHook(&ctx, response, nil) + if err != nil { + t.Fatalf("PostHook failed: %v", err) + } + + WaitForCache() + + // Second request with different provider - should potentially hit cache since provider is not considered + request2 := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, // Different provider + Model: "claude-3-haiku", // Different model + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Provider model flags test"), // Same content + }, + }, + }, + }, + } + + ctx2 := context.Background() + ctx2 = context.WithValue(ctx2, CacheKey, "test-cache-enabled") + + _, shortCircuit2, err := setup.Plugin.PreHook(&ctx2, request2) + if err != nil { + t.Fatalf("Second PreHook failed: %v", err) + } + + // With provider/model caching disabled, we might get cache hits across different providers/models + // This behavior depends on the exact implementation of hash generation + t.Logf("Cache behavior with disabled provider/model flags: hit=%v", shortCircuit2 != nil) + + t.Log("βœ… Provider/model caching flags test passed!") +} + +// TestSemanticCache_ConfigurationEdgeCases verifies edge cases in configuration handling +func TestSemanticCache_ConfigurationEdgeCases(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Test with invalid TTL type in context + ctx := context.Background() + ctx = context.WithValue(ctx, CacheKey, "test-cache-enabled") + ctx = context.WithValue(ctx, CacheTTLKey, "not-a-duration") // Invalid TTL type + + request := &schemas.BifrostRequest{ + RequestType: schemas.ChatCompletionRequest, + ChatRequest: &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr("Edge case test"), + }, + }, + }, + }, + } + + // Should handle invalid TTL gracefully + _, shortCircuit, err := setup.Plugin.PreHook(&ctx, request) + if err != nil { + t.Fatalf("PreHook failed with invalid TTL: %v", err) + } + + if shortCircuit != nil { + t.Fatal("Unexpected cache hit with invalid TTL") + } + + // Test with invalid threshold type + ctx2 := context.Background() + ctx2 = context.WithValue(ctx2, CacheKey, "test-cache-enabled") + ctx2 = context.WithValue(ctx2, CacheThresholdKey, "not-a-float") // Invalid threshold type + + // Should handle invalid threshold gracefully + _, shortCircuit2, err := setup.Plugin.PreHook(&ctx2, request) + if err != nil { + t.Fatalf("PreHook failed with invalid threshold: %v", err) + } + + if shortCircuit2 != nil { + t.Fatal("Unexpected cache hit with invalid threshold") + } + + t.Log("βœ… Configuration edge cases test passed!") +} diff --git a/plugins/semanticcache/plugin_no_store_test.go b/plugins/semanticcache/plugin_no_store_test.go new file mode 100644 index 000000000..d48791986 --- /dev/null +++ b/plugins/semanticcache/plugin_no_store_test.go @@ -0,0 +1,327 @@ +package semanticcache + +import ( + "context" + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestCacheNoStoreBasicFunctionality tests that CacheNoStoreKey prevents caching +func TestCacheNoStoreBasicFunctionality(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("What is artificial intelligence?", 0.7, 100) + + // Test 1: Normal caching (control test) + ctx1 := CreateContextWithCacheKey("test-no-store-control") + t.Log("Making normal request (should be cached)...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) // Fresh request + + WaitForCache() + + // Verify it got cached + t.Log("Verifying normal caching worked...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should be cached + + // Test 2: NoStore = true (should not cache) + ctx2 := CreateContextWithCacheKeyAndNoStore("test-no-store-disabled", true) + t.Log("Making request with CacheNoStoreKey=true (should not be cached)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) // Fresh request + + WaitForCache() + + // Verify it was NOT cached + t.Log("Verifying no-store request was not cached...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err4 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}) // Should still be fresh (not cached) + + // Test 3: NoStore = false (should cache normally) + ctx3 := CreateContextWithCacheKeyAndNoStore("test-no-store-enabled", false) + t.Log("Making request with CacheNoStoreKey=false (should be cached)...") + response5, err5 := setup.Client.ChatCompletionRequest(ctx3, testRequest) + if err5 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response5}) // Fresh request + + WaitForCache() + + // Verify it got cached + t.Log("Verifying no-store=false request was cached...") + response6, err6 := setup.Client.ChatCompletionRequest(ctx3, testRequest) + if err6 != nil { + t.Fatalf("Sixth request failed: %v", err6) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response6}, "direct") // Should be cached + + t.Log("βœ… CacheNoStoreKey basic functionality works correctly") +} + +// TestCacheNoStoreWithDifferentRequestTypes tests NoStore with various request types +func TestCacheNoStoreWithDifferentRequestTypes(t *testing.T) { + t.Skip("Skipping Embedding Tests") + + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Test with chat completion + chatRequest := CreateBasicChatRequest("Test no-store with chat", 0.7, 50) + ctx1 := CreateContextWithCacheKeyAndNoStore("test-no-store-chat", true) + + t.Log("Testing no-store with chat completion...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, chatRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Verify not cached + response2, err2 := setup.Client.ChatCompletionRequest(ctx1, chatRequest) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) // Should not be cached + + // Test with embedding request + embeddingRequest := CreateEmbeddingRequest([]string{"Test no-store with embeddings"}) + ctx2 := CreateContextWithCacheKeyAndNoStore("test-no-store-embedding", true) + + t.Log("Testing no-store with embedding request...") + response3, err3 := setup.Client.EmbeddingRequest(ctx2, embeddingRequest) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response3}) + + WaitForCache() + + // Verify not cached + response4, err4 := setup.Client.EmbeddingRequest(ctx2, embeddingRequest) + if err4 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{EmbeddingResponse: response4}) // Should not be cached + + t.Log("βœ… CacheNoStoreKey works with different request types") +} + +// TestCacheNoStoreWithConversationHistory tests NoStore with conversation context +func TestCacheNoStoreWithConversationHistory(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Create conversation context + conversation := BuildConversationHistory( + "You are a helpful assistant", + []string{"Hello", "Hi! How can I help?"}, + ) + messages := AddUserMessage(conversation, "What is machine learning?") + request := CreateConversationRequest(messages, 0.7, 100) + + // Test with no-store enabled + ctx := CreateContextWithCacheKeyAndNoStore("test-no-store-conversation", true) + + t.Log("Testing no-store with conversation history...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, request) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Verify not cached (same conversation should not hit cache) + response2, err2 := setup.Client.ChatCompletionRequest(ctx, request) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) // Should not be cached due to no-store + + t.Log("βœ… CacheNoStoreKey works with conversation history") +} + +// TestCacheNoStoreWithCacheTypes tests NoStore interaction with CacheTypeKey +func TestCacheNoStoreWithCacheTypes(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Test no-store with cache types", 0.7, 50) + + // Test no-store with direct cache type + ctx1 := CreateContextWithCacheKey("test-no-store-cache-types") + ctx1 = context.WithValue(ctx1, CacheNoStoreKey, true) + ctx1 = context.WithValue(ctx1, CacheTypeKey, CacheTypeDirect) + + t.Log("Testing no-store with CacheTypeKey=direct...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Should not be cached + response2, err2 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}) // No-store should override cache type + + // Test no-store with semantic cache type + ctx2 := CreateContextWithCacheKey("test-no-store-cache-types") + ctx2 = context.WithValue(ctx2, CacheNoStoreKey, true) + ctx2 = context.WithValue(ctx2, CacheTypeKey, CacheTypeSemantic) + + t.Log("Testing no-store with CacheTypeKey=semantic...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) + + WaitForCache() + + // Should not be cached + response4, err4 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err4 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}) // No-store should override cache type + + t.Log("βœ… CacheNoStoreKey correctly overrides cache type settings") +} + +// TestCacheNoStoreErrorHandling tests error scenarios with NoStore +func TestCacheNoStoreErrorHandling(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Test no-store error handling", 0.7, 50) + + // Test with invalid no-store value (non-boolean) + ctx1 := CreateContextWithCacheKey("test-no-store-errors") + ctx1 = context.WithValue(ctx1, CacheNoStoreKey, "invalid") + + t.Log("Testing no-store with invalid value (should cache normally)...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Should be cached (invalid value should be ignored) + response2, err2 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") // Should be cached (invalid value ignored) + + // Test with nil value (should cache normally) + ctx2 := CreateContextWithCacheKey("test-no-store-nil") + ctx2 = context.WithValue(ctx2, CacheNoStoreKey, nil) + + t.Log("Testing no-store with nil value (should cache normally)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err3 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}) + + WaitForCache() + + // Should be cached (nil should be treated as normal caching) + response4, err4 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err4 != nil { + t.Fatalf("Fourth request failed: %v", err4) + } + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}, "direct") // Should be cached (nil ignored) + + t.Log("βœ… CacheNoStoreKey error handling works correctly") +} + +// TestCacheNoStoreReadButNoWrite tests that NoStore allows reading cache but prevents writing +func TestCacheNoStoreReadButNoWrite(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + testRequest := CreateBasicChatRequest("Describe Isaac Newton's three laws of motion", 0.7, 50) + + // Step 1: Cache a response normally + ctx1 := CreateContextWithCacheKey("test-no-store-read") + t.Log("Caching response normally...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx1, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + + WaitForCache() + + // Step 2: Try to read with no-store enabled (should still read from cache) + ctx2 := CreateContextWithCacheKeyAndNoStore("test-no-store-read", true) + t.Log("Reading with no-store enabled (should still hit cache for reads)...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx2, testRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + // The current implementation should still read from cache even with no-store + // (no-store only affects writing, not reading) + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + + // Step 3: Make a semantically similar request with no-store (strong paraphrase for deterministic semantic hit) + newRequest := CreateBasicChatRequest("Describe the three laws of motion by Isaac Newton", 0.7, 50) + t.Log("Making semantically similar request with no-store (should get semantic hit, but not cache response)...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx2, newRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + // Should get semantic cache hit (no-store allows reads, just prevents writes) + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "semantic") + + WaitForCache() + + // Step 4: Repeat similar request with no-store (should still get semantic hit) + t.Log("Repeating similar request with no-store (should still get semantic hit)...") + response4, err4 := setup.Client.ChatCompletionRequest(ctx2, newRequest) + if err4 != nil { + t.Fatalf("Fourth request failed: %v", err4) + } + // Should get semantic cache hit again (consistent behavior) + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response4}, "semantic") + + t.Log("βœ… CacheNoStoreKey allows reading but prevents writing") +} diff --git a/plugins/semanticcache/plugin_normalization_test.go b/plugins/semanticcache/plugin_normalization_test.go new file mode 100644 index 000000000..3a4fb1710 --- /dev/null +++ b/plugins/semanticcache/plugin_normalization_test.go @@ -0,0 +1,332 @@ +package semanticcache + +import ( + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestTextNormalizationDirectCache tests that text normalization works correctly +// for direct cache (hash-based) matching across all input types +func TestTextNormalizationDirectCache(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + t.Run("ChatCompletion", func(t *testing.T) { + testChatCompletionNormalization(t, setup) + }) + + t.Run("Speech", func(t *testing.T) { + testSpeechNormalization(t, setup) + }) +} + +func testChatCompletionNormalization(t *testing.T, setup *TestSetup) { + ctx := CreateContextWithCacheKey("test-chat-normalization") + + // Test cases with different case and whitespace variations + testCases := []struct { + name string + userMsg string + systemMsg string + }{ + { + name: "Original", + userMsg: "Explain quantum physics", + systemMsg: "You are a helpful science teacher", + }, + { + name: "Lowercase", + userMsg: "explain quantum physics", + systemMsg: "you are a helpful science teacher", + }, + { + name: "Uppercase", + userMsg: "EXPLAIN QUANTUM PHYSICS", + systemMsg: "YOU ARE A HELPFUL SCIENCE TEACHER", + }, + { + name: "Mixed Case", + userMsg: "ExPlAiN QuAnTuM PhYsIcS", + systemMsg: "YoU aRe A hElPfUl ScIeNcE tEaChEr", + }, + { + name: "With Whitespace", + userMsg: " Explain quantum physics ", + systemMsg: " You are a helpful science teacher ", + }, + { + name: "Extra Whitespace", + userMsg: " Explain quantum physics ", + systemMsg: " You are a helpful science teacher ", + }, + } + + // Create chat completion requests for all test cases + requests := make([]*schemas.BifrostChatRequest, len(testCases)) + for i, tc := range testCases { + requests[i] = &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ + ContentStr: &tc.systemMsg, + }, + }, + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: &tc.userMsg, + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: PtrFloat64(0.5), + MaxCompletionTokens: PtrInt(50), + }, + } + } + + // Make first request (should miss cache and be stored) + t.Logf("Making first request with user: '%s', system: '%s'", testCases[0].userMsg, testCases[0].systemMsg) + response1, err1 := setup.Client.ChatCompletionRequest(ctx, requests[0]) + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil || len(response1.Choices) == 0 { + t.Fatal("First response is invalid") + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + WaitForCache() + + // Test all other variations should hit cache due to normalization + for i := 1; i < len(testCases); i++ { + tc := testCases[i] + t.Logf("Testing variation '%s' with user: '%s', system: '%s'", tc.name, tc.userMsg, tc.systemMsg) + + response, err := setup.Client.ChatCompletionRequest(ctx, requests[i]) + if err != nil { + t.Fatalf("Request for case '%s' failed: %v", tc.name, err) + } + + if response == nil || len(response.Choices) == 0 { + t.Fatalf("Response for case '%s' is invalid", tc.name) + } + + // Should be cache hit due to normalization + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response}, "direct") + t.Logf("βœ“ Cache hit for '%s' variation", tc.name) + } +} + +func testSpeechNormalization(t *testing.T, setup *TestSetup) { + ctx := CreateContextWithCacheKey("test-speech-normalization") + + // Test cases with different case and whitespace variations for speech input + testCases := []struct { + name string + input string + }{ + {"Original", "Hello, this is a test speech synthesis"}, + {"Lowercase", "hello, this is a test speech synthesis"}, + {"Uppercase", "HELLO, THIS IS A TEST SPEECH SYNTHESIS"}, + {"Mixed Case", "HeLLo, ThIs Is A tEsT sPeEcH sYnThEsIs"}, + {"Leading Whitespace", " Hello, this is a test speech synthesis"}, + {"Trailing Whitespace", "Hello, this is a test speech synthesis "}, + {"Both Whitespace", " Hello, this is a test speech synthesis "}, + {"Extra Spaces", " Hello, this is a test speech synthesis "}, + } + + // Create speech requests for all test cases + requests := make([]*schemas.BifrostSpeechRequest, len(testCases)) + for i, tc := range testCases { + requests[i] = CreateSpeechRequest(tc.input, "alloy") + } + + // Make first request (should miss cache and be stored) + t.Logf("Making first speech request with: '%s'", testCases[0].input) + response1, err1 := setup.Client.SpeechRequest(ctx, requests[0]) + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil { + t.Fatal("First response is invalid") + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{SpeechResponse: response1}) + WaitForCache() + + // Test all other variations should hit cache due to normalization + for i := 1; i < len(testCases); i++ { + tc := testCases[i] + t.Logf("Testing variation '%s' with input: '%s'", tc.name, tc.input) + + response, err := setup.Client.SpeechRequest(ctx, requests[i]) + if err != nil { + t.Fatalf("Request for case '%s' failed: %v", tc.name, err) + } + + if response == nil { + t.Fatalf("Response for case '%s' is invalid", tc.name) + } + + // Should be cache hit due to normalization + AssertCacheHit(t, &schemas.BifrostResponse{SpeechResponse: response}, "direct") + t.Logf("βœ“ Cache hit for '%s' variation", tc.name) + } +} + +// TestChatCompletionContentBlocksNormalization tests normalization for content blocks +func TestChatCompletionContentBlocksNormalization(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-content-blocks-normalization") + + // Test cases with content blocks having different text normalization + testCases := []struct { + name string + textBlocks []string + }{ + { + name: "Original", + textBlocks: []string{"Hello World", "How are you today?"}, + }, + { + name: "Lowercase", + textBlocks: []string{"hello world", "how are you today?"}, + }, + { + name: "With Whitespace", + textBlocks: []string{" Hello World ", " How are you today? "}, + }, + { + name: "Mixed Case", + textBlocks: []string{"HeLLo WoRLd", "HoW aRe YoU tOdAy?"}, + }, + } + + // Create chat completion requests with content blocks + requests := make([]*schemas.BifrostChatRequest, len(testCases)) + for i, tc := range testCases { + // Create content blocks + contentBlocks := make([]schemas.ChatContentBlock, len(tc.textBlocks)) + for j, text := range tc.textBlocks { + contentBlocks[j] = schemas.ChatContentBlock{ + Type: schemas.ChatContentBlockTypeText, + Text: &text, + } + } + + requests[i] = &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: contentBlocks, + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: PtrFloat64(0.5), + MaxCompletionTokens: PtrInt(50), + }, + } + } + + // Make first request (should miss cache and be stored) + t.Logf("Making first request with content blocks: %v", testCases[0].textBlocks) + response1, err1 := setup.Client.ChatCompletionRequest(ctx, requests[0]) + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil || len(response1.Choices) == 0 { + t.Fatal("First response is invalid") + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + WaitForCache() + + // Test all other variations should hit cache due to normalization + for i := 1; i < len(testCases); i++ { + tc := testCases[i] + t.Logf("Testing variation '%s' with content blocks: %v", tc.name, tc.textBlocks) + + response, err := setup.Client.ChatCompletionRequest(ctx, requests[i]) + if err != nil { + t.Fatalf("Request for case '%s' failed: %v", tc.name, err) + } + + if response == nil || len(response.Choices) == 0 { + t.Fatalf("Response for case '%s' is invalid", tc.name) + } + + // Should be cache hit due to normalization + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response}, "direct") + t.Logf("βœ“ Cache hit for '%s' variation", tc.name) + } +} + +// TestNormalizationWithSemanticCache tests that normalization works with semantic cache as well +func TestNormalizationWithSemanticCache(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-normalization-semantic") + + // Make first request with original text + originalRequest := CreateBasicChatRequest("What is Machine Learning?", 0.5, 50) + t.Log("Making first request with original text...") + response1, err1 := setup.Client.ChatCompletionRequest(ctx, originalRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: response1}) + WaitForCache() + + // Test semantic match with different case (should hit semantic cache after normalization) + normalizedRequest := CreateBasicChatRequest("what is machine learning?", 0.5, 50) + t.Log("Making semantic request with normalized case...") + response2, err2 := setup.Client.ChatCompletionRequest(ctx, normalizedRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // This should be a direct cache hit since the normalized text is identical + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response2}, "direct") + t.Log("βœ“ Direct cache hit with normalized text") + + // Test with semantically similar but different text + semanticRequest := CreateBasicChatRequest("can you explain machine learning concepts?", 0.5, 50) + t.Log("Making semantically similar request...") + response3, err3 := setup.Client.ChatCompletionRequest(ctx, semanticRequest) + if err3 != nil { + t.Fatalf("Third request failed: %v", err3) + } + + // This should be a semantic cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: response3}, "semantic") + t.Log("βœ“ Semantic cache hit with similar content") +} + +// Helper functions for pointer creation +func PtrFloat64(f float64) *float64 { + return &f +} + +func PtrInt(i int) *int { + return &i +} diff --git a/plugins/semanticcache/plugin_responses_test.go b/plugins/semanticcache/plugin_responses_test.go new file mode 100644 index 000000000..e885b830c --- /dev/null +++ b/plugins/semanticcache/plugin_responses_test.go @@ -0,0 +1,451 @@ +package semanticcache + +import ( + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestResponsesAPIBasicFunctionality tests the core caching functionality with Responses API +func TestResponsesAPIBasicFunctionality(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-responses-basic") + + // Create test request + testRequest := CreateBasicResponsesRequest( + "What is Bifrost? Answer in one short sentence.", + 0.7, + 500, + ) + + t.Log("Making first Responses API request (should go to OpenAI and be cached)...") + + // Make first request (will go to OpenAI and be cached) - with retries + start1 := time.Now() + response1, err1 := setup.Client.ResponsesRequest(ctx, testRequest) + duration1 := time.Since(start1) + + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil || len(response1.Output) == 0 { + t.Fatal("First Responses response is invalid") + } + + t.Logf("First request completed in %v", duration1) + t.Logf("Response contains %d output messages", len(response1.Output)) + if c := response1.Output[0].Content; c != nil && c.ContentStr != nil { + t.Logf("Response: %s", *c.ContentStr) + } else if c != nil && len(c.ContentBlocks) > 0 && c.ContentBlocks[0].Text != nil { + t.Logf("Response: %s", *c.ContentBlocks[0].Text) + } else { + t.Log("Response: ") + } + + // Wait for cache to be written + WaitForCache() + + t.Log("Making second identical Responses API request (should be served from cache)...") + + // Make second identical request (should be cached) + start2 := time.Now() + response2, err2 := setup.Client.ResponsesRequest(ctx, testRequest) + duration2 := time.Since(start2) + + if err2 != nil { + t.Fatalf("Second Responses request failed: %v", err2) + } + + if response2 == nil || len(response2.Output) == 0 { + t.Fatal("Second Responses response is invalid") + } + if response2.Output[0].Content.ContentStr != nil { + t.Logf("Response: %s", *response2.Output[0].Content.ContentStr) + } else { + t.Logf("Response: %v", *response2.Output[0].Content.ContentBlocks[0].Text) + } + + t.Logf("Second request completed in %v", duration2) + + // Verify cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}, string(CacheTypeDirect)) + + // Performance comparison + t.Logf("Performance Summary:") + t.Logf("First request (OpenAI): %v", duration1) + t.Logf("Second request (Cache): %v", duration2) + + if duration2 >= duration1 { + t.Log("⚠️ Cache doesn't seem faster, but this could be due to test environment") + } + + // Verify provider information is maintained in cached response + if response2.ExtraFields.Provider != testRequest.Provider { + t.Errorf("Provider mismatch in cached response: expected %s, got %s", + testRequest.Provider, response2.ExtraFields.Provider) + } + + t.Log("βœ… Basic Responses API semantic caching test completed successfully!") +} + +// TestResponsesAPIDifferentParameters tests that different parameters produce different cache entries +func TestResponsesAPIDifferentParameters(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-responses-params") + basePrompt := "Explain quantum computing" + + tests := []struct { + name string + request1 *schemas.BifrostResponsesRequest + request2 *schemas.BifrostResponsesRequest + shouldCache bool + }{ + { + name: "Identical Requests", + request1: CreateBasicResponsesRequest(basePrompt, 0.5, 500), + request2: CreateBasicResponsesRequest(basePrompt, 0.5, 500), + shouldCache: true, + }, + { + name: "Different Temperature", + request1: CreateBasicResponsesRequest(basePrompt, 0.1, 500), + request2: CreateBasicResponsesRequest(basePrompt, 0.9, 500), + shouldCache: false, + }, + { + name: "Different MaxOutputTokens", + request1: CreateBasicResponsesRequest(basePrompt, 0.5, 500), + request2: CreateBasicResponsesRequest(basePrompt, 0.5, 200), + shouldCache: false, + }, + { + name: "Different Instructions", + request1: CreateResponsesRequestWithInstructions(basePrompt, "Be concise", 0.5, 500), + request2: CreateResponsesRequestWithInstructions(basePrompt, "Be detailed", 0.5, 500), + shouldCache: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear cache for this subtest + clearTestKeysWithStore(t, setup.Store) + + // Make first request + _, err1 := setup.Client.ResponsesRequest(ctx, tt.request1) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + // Make second request + response2, err2 := setup.Client.ResponsesRequest(ctx, tt.request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + if tt.shouldCache { + AssertCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}, "direct") + t.Log("βœ“ Parameters match: cache hit as expected") + } else { + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}) + t.Log("βœ“ Parameters differ: no cache hit as expected") + } + }) + } +} + +// TestResponsesAPISemanticMatching tests semantic similarity matching with Responses API +func TestResponsesAPISemanticMatching(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKeyAndType("test-responses-semantic", CacheTypeSemantic) + + // First request + originalRequest := CreateBasicResponsesRequest("What is machine learning?", 0.5, 500) + t.Log("Making first Responses request with original text...") + response1, err1 := setup.Client.ResponsesRequest(ctx, originalRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response1}) + WaitForCache() + + // Test semantic match with similar but different text + semanticRequest := CreateBasicResponsesRequest("Can you explain machine learning concepts?", 0.5, 500) + t.Log("Making semantically similar Responses request...") + response2, err2 := setup.Client.ResponsesRequest(ctx, semanticRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // This should be a semantic cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}, "semantic") + t.Log("βœ“ Semantic cache hit with similar content") +} + +// TestResponsesAPIWithInstructions tests caching with system instructions +func TestResponsesAPIWithInstructions(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-responses-instructions") + + // Create request with instructions + request1 := CreateResponsesRequestWithInstructions( + "Explain artificial intelligence", + "You are a helpful assistant. Be concise and accurate.", + 0.7, + 500, + ) + + t.Log("Making first Responses request with instructions...") + response1, err1 := setup.Client.ResponsesRequest(ctx, request1) + if err1 != nil { + return // Test will be skipped by retry function + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response1}) + WaitForCache() + + // Make identical request + request2 := CreateResponsesRequestWithInstructions( + "Explain artificial intelligence", + "You are a helpful assistant. Be concise and accurate.", + 0.7, + 500, + ) + + t.Log("Making second identical Responses request with instructions...") + response2, err2 := setup.Client.ResponsesRequest(ctx, request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // Should be a cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}, "direct") + t.Log("βœ“ Responses API with instructions cached correctly") +} + +// TestResponsesAPICacheExpiration tests TTL functionality for Responses API requests +func TestResponsesAPICacheExpiration(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Set very short TTL for testing + shortTTL := 1 * time.Second + ctx := CreateContextWithCacheKeyAndTTL("test-responses-ttl", shortTTL) + + responsesRequest := CreateBasicResponsesRequest("TTL test for Responses API", 0.5, 500) + + t.Log("Making first Responses request with short TTL...") + response1, err1 := setup.Client.ResponsesRequest(ctx, responsesRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response1}) + + WaitForCache() + + t.Log("Making second Responses request before TTL expiration...") + response2, err2 := setup.Client.ResponsesRequest(ctx, responsesRequest) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + AssertCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}, "direct") + + t.Logf("Waiting for TTL expiration (%v)...", shortTTL) + time.Sleep(shortTTL + 2*time.Second) // Wait for TTL to expire + + t.Log("Making third Responses request after TTL expiration...") + response3, err3 := setup.Client.ResponsesRequest(ctx, responsesRequest) + if err3 != nil { + return // Test will be skipped by retry function + } + // Should not be a cache hit since TTL expired + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response3}) + + t.Log("βœ… Responses API requests properly handle TTL expiration") +} + +// TestResponsesAPIWithoutCacheKey tests that Responses requests without cache key are not cached +func TestResponsesAPIWithoutCacheKey(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + // Don't set cache key in context + ctx := CreateContextWithCacheKey("") + + responsesRequest := CreateBasicResponsesRequest("Test Responses without cache key", 0.5, 500) + + t.Log("Making Responses request without cache key...") + + response, err := setup.Client.ResponsesRequest(ctx, responsesRequest) + if err != nil { + return // Test will be skipped by retry function + } + + // Should not be cached + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response}) + + t.Log("βœ… Responses requests without cache key are properly not cached") +} + +// TestResponsesAPINoStoreFlag tests that Responses requests with no-store flag are not cached +func TestResponsesAPINoStoreFlag(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + responsesRequest := CreateBasicResponsesRequest("Test no-store with Responses API", 0.7, 500) + ctx := CreateContextWithCacheKeyAndNoStore("test-no-store-responses", true) + + t.Log("Testing no-store with Responses API...") + response1, err1 := setup.Client.ResponsesRequest(ctx, responsesRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response1}) + + WaitForCache() + + // Verify not cached + response2, err2 := setup.Client.ResponsesRequest(ctx, responsesRequest) + if err2 != nil { + return // Test will be skipped by retry function + } + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}) // Should not be cached + + t.Log("βœ… Responses API no-store flag working correctly") +} + +// TestResponsesAPIStreaming tests streaming Responses API requests +func TestResponsesAPIStreaming(t *testing.T) { + t.Log("Responses streaming not supported yet") + + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-responses-streaming") + prompt := "Explain the basics of quantum computing in simple terms" + + // Make non-streaming request first + t.Log("Making non-streaming Responses request...") + nonStreamRequest := CreateBasicResponsesRequest(prompt, 0.5, 500) + _, err1 := setup.Client.ResponsesRequest(ctx, nonStreamRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + // Make streaming request with same prompt and parameters + t.Log("Making streaming Responses request with same prompt...") + streamRequest := CreateStreamingResponsesRequest(prompt, 0.5, 500) + stream, err2 := setup.Client.ResponsesStreamRequest(ctx, streamRequest) + if err2 != nil { + t.Fatalf("Streaming Responses request failed: %v", err2) + } + + var streamResponses []schemas.BifrostResponsesStreamResponse + for streamMsg := range stream { + if streamMsg.BifrostError != nil { + t.Fatalf("Error in Responses stream: %v", streamMsg.BifrostError) + } + if streamMsg.BifrostResponsesStreamResponse != nil { + streamResponses = append(streamResponses, *streamMsg.BifrostResponsesStreamResponse) + } + } + + if len(streamResponses) == 0 { + t.Fatal("No streaming responses received") + } + + // Check if any of the streaming responses was served from cache + cacheHitFound := false + for _, resp := range streamResponses { + if resp.ExtraFields.CacheDebug != nil && resp.ExtraFields.CacheDebug.CacheHit { + cacheHitFound = true + break + } + } + + if !cacheHitFound { + t.Log("⚠️ No cache hit detected in streaming responses - this could be expected behavior") + } else { + t.Log("βœ“ Cache hit detected in streaming Responses API") + } + + t.Log("βœ… Streaming Responses API test completed") +} + +// TestResponsesAPIComplexParameters tests complex parameter handling +func TestResponsesAPIComplexParameters(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-responses-complex-params") + + // Create request with various complex parameters + request := CreateBasicResponsesRequest("Test complex parameters", 0.8, 500) + request.Params.TopP = PtrFloat64(0.9) + request.Params.Background = &[]bool{true}[0] + request.Params.ParallelToolCalls = &[]bool{false}[0] + request.Params.ServiceTier = &[]string{"default"}[0] + request.Params.Store = &[]bool{true}[0] + + t.Log("Making first Responses request with complex parameters...") + response1, err1 := setup.Client.ResponsesRequest(ctx, request) + if err1 != nil { + return // Test will be skipped by retry function + } + + AssertNoCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response1}) + WaitForCache() + + // Create identical request + request2 := CreateBasicResponsesRequest("Test complex parameters", 0.8, 500) + request2.Params.TopP = PtrFloat64(0.9) + request2.Params.Background = &[]bool{true}[0] + request2.Params.ParallelToolCalls = &[]bool{false}[0] + request2.Params.ServiceTier = &[]string{"default"}[0] + request2.Params.Store = &[]bool{true}[0] + + t.Log("Making second identical Responses request with complex parameters...") + response2, err2 := setup.Client.ResponsesRequest(ctx, request2) + if err2 != nil { + if err2.Error != nil { + t.Fatalf("Second request failed: %v", err2.Error.Message) + } else { + t.Fatalf("Second request failed: %v", err2) + } + } + + // Should be a cache hit + AssertCacheHit(t, &schemas.BifrostResponse{ResponsesResponse: response2}, "direct") + t.Log("βœ“ Responses API with complex parameters cached correctly") +} diff --git a/plugins/semanticcache/plugin_streaming_test.go b/plugins/semanticcache/plugin_streaming_test.go new file mode 100644 index 000000000..ef851e9a0 --- /dev/null +++ b/plugins/semanticcache/plugin_streaming_test.go @@ -0,0 +1,333 @@ +package semanticcache + +import ( + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestStreamingCacheBasicFunctionality tests streaming response caching +func TestStreamingCacheBasicFunctionality(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("test-stream-value") + + // Create a test streaming request + testRequest := CreateStreamingChatRequest( + "Count from 1 to 3, each number on a new line.", + 0.0, // Use 0 temperature for more predictable responses + 20, + ) + + t.Log("Making first streaming request (should go to OpenAI and be cached)...") + + // Make first streaming request + start1 := time.Now() + stream1, err1 := setup.Client.ChatCompletionStreamRequest(ctx, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + var responses1 []schemas.BifrostChatResponse + for streamMsg := range stream1 { + if streamMsg.BifrostError != nil { + t.Fatalf("Error in first stream: %v", streamMsg.BifrostError) + } + if streamMsg.BifrostChatResponse != nil { + responses1 = append(responses1, *streamMsg.BifrostChatResponse) + } + } + duration1 := time.Since(start1) + + if len(responses1) == 0 { + t.Fatal("First streaming request returned no responses") + } + + t.Logf("First streaming request completed in %v with %d chunks", duration1, len(responses1)) + + // Wait for cache to be written + WaitForCache() + + t.Log("Making second identical streaming request (should be served from cache)...") + + // Make second identical streaming request + start2 := time.Now() + stream2, err2 := setup.Client.ChatCompletionStreamRequest(ctx, testRequest) + if err2 != nil { + t.Fatalf("Second streaming request failed: %v", err2) + } + + var responses2 []schemas.BifrostChatResponse + for streamMsg := range stream2 { + if streamMsg.BifrostError != nil { + t.Fatalf("Error in second stream: %v", streamMsg.BifrostError) + } + if streamMsg.BifrostChatResponse != nil { + responses2 = append(responses2, *streamMsg.BifrostChatResponse) + } + } + duration2 := time.Since(start2) + + if len(responses2) == 0 { + t.Fatal("Second streaming request returned no responses") + } + + t.Logf("Second streaming request completed in %v with %d chunks", duration2, len(responses2)) + + // Validate that both streams have the same number of chunks + if len(responses1) != len(responses2) { + t.Errorf("Stream chunk count mismatch: original=%d, cached=%d", len(responses1), len(responses2)) + } + + // Validate that the second stream was cached + cached := false + for _, response := range responses2 { + if response.ExtraFields.CacheDebug != nil && response.ExtraFields.CacheDebug.CacheHit { + cached = true + break + } + } + + if !cached { + t.Fatal("Second streaming request was not served from cache") + } + + // Validate performance improvement + if duration2 >= duration1 { + t.Errorf("Cached stream took longer than original: cache=%v, original=%v", duration2, duration1) + } else { + speedup := float64(duration1) / float64(duration2) + t.Logf("Streaming cache speedup: %.2fx faster", speedup) + } + + // Validate chunk ordering is maintained + for i := range responses2 { + if responses2[i].ExtraFields.ChunkIndex != responses1[i].ExtraFields.ChunkIndex { + t.Errorf("Chunk index mismatch at position %d: original=%d, cached=%d", + i, responses1[i].ExtraFields.ChunkIndex, responses2[i].ExtraFields.ChunkIndex) + } + } + + t.Log("βœ… Streaming cache test completed successfully!") +} + +// TestStreamingVsNonStreaming tests that streaming and non-streaming requests are cached separately +func TestStreamingVsNonStreaming(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("stream-vs-non-test") + + prompt := "What is the meaning of life?" + + // Make non-streaming request first + t.Log("Making non-streaming request...") + nonStreamRequest := CreateBasicChatRequest(prompt, 0.5, 50) + nonStreamResponse, err1 := setup.Client.ChatCompletionRequest(ctx, nonStreamRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + WaitForCache() + + // Make streaming request with same prompt and parameters + t.Log("Making streaming request with same prompt...") + streamRequest := CreateStreamingChatRequest(prompt, 0.5, 50) + stream, err2 := setup.Client.ChatCompletionStreamRequest(ctx, streamRequest) + if err2 != nil { + t.Fatalf("Streaming request failed: %v", err2) + } + + var streamResponses []schemas.BifrostChatResponse + for streamMsg := range stream { + if streamMsg.BifrostError != nil { + t.Fatalf("Error in stream: %v", streamMsg.BifrostError) + } + if streamMsg.BifrostChatResponse != nil { + streamResponses = append(streamResponses, *streamMsg.BifrostChatResponse) + } + } + + if len(streamResponses) == 0 { + t.Fatal("Streaming request returned no responses") + } + + // Verify that the streaming request was NOT served from the non-streaming cache + // (They should be cached separately) + streamCached := false + for _, response := range streamResponses { + if response.ExtraFields.RawResponse != nil { + if rawMap, ok := response.ExtraFields.RawResponse.(map[string]interface{}); ok { + if cachedFlag, exists := rawMap["bifrost_cached"]; exists { + if cachedBool, ok := cachedFlag.(bool); ok && cachedBool { + streamCached = true + break + } + } + } + } + } + + if streamCached { + t.Error("Streaming request should not be cached from non-streaming cache") + } else { + t.Log("βœ… Streaming request correctly not cached from non-streaming cache") + } + + // Verify non-streaming response was not affected + AssertNoCacheHit(t, &schemas.BifrostResponse{ChatResponse: nonStreamResponse}) + + t.Log("βœ… Streaming vs non-streaming test completed!") +} + +// TestStreamingChunkOrdering tests that cached streaming responses maintain proper chunk ordering +func TestStreamingChunkOrdering(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("chunk-order-test") + + // Request that should generate multiple chunks + testRequest := CreateStreamingChatRequest( + "List the first 5 prime numbers, one per line with explanation.", + 0.0, + 100, + ) + + t.Log("Making first streaming request to establish cache...") + stream1, err1 := setup.Client.ChatCompletionStreamRequest(ctx, testRequest) + if err1 != nil { + return // Test will be skipped by retry function + } + + var originalChunks []schemas.BifrostChatResponse + for streamMsg := range stream1 { + if streamMsg.BifrostError != nil { + t.Fatalf("Error in first stream: %v", streamMsg.BifrostError) + } + if streamMsg.BifrostChatResponse != nil { + originalChunks = append(originalChunks, *streamMsg.BifrostChatResponse) + } + } + + if len(originalChunks) < 2 { + t.Skipf("Need at least 2 chunks to test ordering, got %d", len(originalChunks)) + } + + t.Logf("Original stream had %d chunks", len(originalChunks)) + + WaitForCache() + + t.Log("Making second streaming request to test cached chunk ordering...") + stream2, err2 := setup.Client.ChatCompletionStreamRequest(ctx, testRequest) + if err2 != nil { + t.Fatalf("Second streaming request failed: %v", err2) + } + + var cachedChunks []schemas.BifrostChatResponse + for streamMsg := range stream2 { + if streamMsg.BifrostError != nil { + t.Fatalf("Error in second stream: %v", streamMsg.BifrostError) + } + if streamMsg.BifrostChatResponse != nil { + cachedChunks = append(cachedChunks, *streamMsg.BifrostChatResponse) + } + } + + if len(cachedChunks) != len(originalChunks) { + t.Errorf("Cached stream chunk count mismatch: original=%d, cached=%d", + len(originalChunks), len(cachedChunks)) + } + + // Verify chunk ordering + for i := 0; i < len(cachedChunks) && i < len(originalChunks); i++ { + originalIndex := originalChunks[i].ExtraFields.ChunkIndex + cachedIndex := cachedChunks[i].ExtraFields.ChunkIndex + + if originalIndex != cachedIndex { + t.Errorf("Chunk index mismatch at position %d: original=%d, cached=%d", + i, originalIndex, cachedIndex) + } + + // Only verify cache hit on the last chunk (where CacheDebug is set) + if i == len(cachedChunks)-1 { + AssertCacheHit(t, &schemas.BifrostResponse{ChatResponse: &cachedChunks[i]}, string(CacheTypeDirect)) + } + } + + // Verify chunks are in sequential order + for i := 1; i < len(cachedChunks); i++ { + prevIndex := cachedChunks[i-1].ExtraFields.ChunkIndex + currIndex := cachedChunks[i].ExtraFields.ChunkIndex + + if currIndex <= prevIndex { + t.Errorf("Chunks not in sequential order: chunk %d has index %d, chunk %d has index %d", + i-1, prevIndex, i, currIndex) + } + } + + t.Log("βœ… Streaming chunk ordering test completed successfully!") +} + +// TestSpeechSynthesisStreaming tests speech synthesis streaming caching +func TestSpeechSynthesisStreaming(t *testing.T) { + setup := NewTestSetup(t) + defer setup.Cleanup() + + ctx := CreateContextWithCacheKey("speech-stream-test") + + // Create speech synthesis request + speechRequest := CreateSpeechRequest( + "This is a test of speech synthesis streaming cache.", + "alloy", + ) + + t.Log("Making first speech synthesis request...") + start1 := time.Now() + response1, err1 := setup.Client.SpeechRequest(ctx, speechRequest) + duration1 := time.Since(start1) + + if err1 != nil { + return // Test will be skipped by retry function + } + + if response1 == nil { + t.Fatal("First speech response is nil") + } + + t.Logf("First speech request completed in %v", duration1) + + WaitForCache() + + t.Log("Making second identical speech synthesis request...") + start2 := time.Now() + response2, err2 := setup.Client.SpeechRequest(ctx, speechRequest) + duration2 := time.Since(start2) + + if err2 != nil { + t.Fatalf("Second speech request failed: %v", err2) + } + + if response2 == nil { + t.Fatal("Second speech response is nil") + } + + t.Logf("Second speech request completed in %v", duration2) + + // Check if second request was cached + AssertCacheHit(t, &schemas.BifrostResponse{SpeechResponse: response2}, string(CacheTypeDirect)) + + // Performance comparison + t.Logf("Speech Synthesis Performance:") + t.Logf("First request: %v", duration1) + t.Logf("Second request: %v", duration2) + + if duration2 < duration1 { + speedup := float64(duration1) / float64(duration2) + t.Logf("Speech cache speedup: %.2fx faster", speedup) + } + + t.Log("βœ… Speech synthesis streaming test completed successfully!") +} diff --git a/plugins/semanticcache/search.go b/plugins/semanticcache/search.go new file mode 100644 index 000000000..5a7dead8c --- /dev/null +++ b/plugins/semanticcache/search.go @@ -0,0 +1,403 @@ +package semanticcache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/vectorstore" +) + +func (plugin *Plugin) performDirectSearch(ctx *context.Context, req *schemas.BifrostRequest, cacheKey string) (*schemas.PluginShortCircuit, error) { + // Generate hash for the request + hash, err := plugin.generateRequestHash(req) + if err != nil { + return nil, fmt.Errorf("failed to generate request hash: %w", err) + } + + plugin.logger.Debug(PluginLoggerPrefix + " Generated Hash for Request: " + hash) + + // Extract metadata for strict filtering + _, paramsHash, err := plugin.extractTextForEmbedding(req) + if err != nil { + return nil, fmt.Errorf("failed to extract metadata for filtering: %w", err) + } + + // Store has and metadata in context + *ctx = context.WithValue(*ctx, requestHashKey, hash) + *ctx = context.WithValue(*ctx, requestParamsHashKey, paramsHash) + + provider, model, _ := req.GetRequestFields() + + // Build strict filters for direct hash search + filters := []vectorstore.Query{ + {Field: "request_hash", Operator: vectorstore.QueryOperatorEqual, Value: hash}, + {Field: "cache_key", Operator: vectorstore.QueryOperatorEqual, Value: cacheKey}, + {Field: "params_hash", Operator: vectorstore.QueryOperatorEqual, Value: paramsHash}, + {Field: "from_bifrost_semantic_cache_plugin", Operator: vectorstore.QueryOperatorEqual, Value: true}, + } + + if plugin.config.CacheByProvider != nil && *plugin.config.CacheByProvider { + filters = append(filters, vectorstore.Query{Field: "provider", Operator: vectorstore.QueryOperatorEqual, Value: string(provider)}) + } + if plugin.config.CacheByModel != nil && *plugin.config.CacheByModel { + filters = append(filters, vectorstore.Query{Field: "model", Operator: vectorstore.QueryOperatorEqual, Value: model}) + } + + plugin.logger.Debug(fmt.Sprintf("%s Searching for direct hash match with %d filters", PluginLoggerPrefix, len(filters))) + + // Make a full copy so we don't mutate the original backing array + selectFields := append([]string(nil), SelectFields...) + if bifrost.IsStreamRequestType(req.RequestType) { + selectFields = removeField(selectFields, "response") + } else { + selectFields = removeField(selectFields, "stream_chunks") + } + + // Search for entries with matching hash and all params + var cursor *string + results, _, err := plugin.store.GetAll(*ctx, plugin.config.VectorStoreNamespace, filters, selectFields, cursor, 1) + if err != nil { + if errors.Is(err, vectorstore.ErrNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to search for direct hash match: %w", err) + } + + if len(results) == 0 { + plugin.logger.Debug(PluginLoggerPrefix + " No direct hash match found") + return nil, nil + } + + // Found a matching entry - extract the response + result := results[0] + plugin.logger.Debug(fmt.Sprintf("%s Found direct hash match with ID: %s", PluginLoggerPrefix, result.ID)) + + // Build response from cached result + return plugin.buildResponseFromResult(ctx, req, result, CacheTypeDirect, 1.0, 0) +} + +// performSemanticSearch performs semantic similarity search and returns matching response if found. +func (plugin *Plugin) performSemanticSearch(ctx *context.Context, req *schemas.BifrostRequest, cacheKey string) (*schemas.PluginShortCircuit, error) { + // Extract text and metadata for embedding + text, paramsHash, err := plugin.extractTextForEmbedding(req) + if err != nil { + return nil, fmt.Errorf("failed to extract text for embedding: %w", err) + } + + // Generate embedding + embedding, inputTokens, err := plugin.generateEmbedding(*ctx, text) + if err != nil { + return nil, fmt.Errorf("failed to generate embedding: %w", err) + } + + // Store embedding and metadata in context for PostHook + *ctx = context.WithValue(*ctx, requestEmbeddingKey, embedding) + *ctx = context.WithValue(*ctx, requestEmbeddingTokensKey, inputTokens) + *ctx = context.WithValue(*ctx, requestParamsHashKey, paramsHash) + + cacheThreshold := plugin.config.Threshold + + thresholdValue := (*ctx).Value(CacheThresholdKey) + if thresholdValue != nil { + threshold, ok := thresholdValue.(float64) + if !ok { + plugin.logger.Warn(PluginLoggerPrefix + " Threshold is not a float64, using default threshold") + } else { + cacheThreshold = threshold + } + } + + provider, model, _ := req.GetRequestFields() + + // Build strict metadata filters as Query slices (provider, model, and all params) + strictFilters := []vectorstore.Query{ + {Field: "cache_key", Operator: vectorstore.QueryOperatorEqual, Value: cacheKey}, + {Field: "params_hash", Operator: vectorstore.QueryOperatorEqual, Value: paramsHash}, + {Field: "from_bifrost_semantic_cache_plugin", Operator: vectorstore.QueryOperatorEqual, Value: true}, + } + + if plugin.config.CacheByProvider != nil && *plugin.config.CacheByProvider { + strictFilters = append(strictFilters, vectorstore.Query{Field: "provider", Operator: vectorstore.QueryOperatorEqual, Value: string(provider)}) + } + if plugin.config.CacheByModel != nil && *plugin.config.CacheByModel { + strictFilters = append(strictFilters, vectorstore.Query{Field: "model", Operator: vectorstore.QueryOperatorEqual, Value: model}) + } + + plugin.logger.Debug(fmt.Sprintf("%s Performing semantic search with %d metadata filters", PluginLoggerPrefix, len(strictFilters))) + + // Make a full copy so we don't mutate the original backing array + selectFields := append([]string(nil), SelectFields...) + if bifrost.IsStreamRequestType(req.RequestType) { + selectFields = removeField(selectFields, "response") + } else { + selectFields = removeField(selectFields, "stream_chunks") + } + + // For semantic search, we want semantic similarity in content but exact parameter matching + results, err := plugin.store.GetNearest(*ctx, plugin.config.VectorStoreNamespace, embedding, strictFilters, selectFields, cacheThreshold, 1) + if err != nil { + return nil, fmt.Errorf("failed to search semantic cache: %w", err) + } + + if len(results) == 0 { + plugin.logger.Debug(PluginLoggerPrefix + " No semantic match found") + return nil, nil + } + + // Found a semantically similar entry + result := results[0] + plugin.logger.Debug(fmt.Sprintf("%s Found semantic match with ID: %s, Score: %f", PluginLoggerPrefix, result.ID, *result.Score)) + + // Build response from cached result + return plugin.buildResponseFromResult(ctx, req, result, CacheTypeSemantic, cacheThreshold, inputTokens) +} + +// buildResponseFromResult constructs a PluginShortCircuit response from a cached VectorEntry result +func (plugin *Plugin) buildResponseFromResult(ctx *context.Context, req *schemas.BifrostRequest, result vectorstore.SearchResult, cacheType CacheType, threshold float64, inputTokens int) (*schemas.PluginShortCircuit, error) { + // Extract response data from the result properties + properties := result.Properties + if properties == nil { + return nil, fmt.Errorf("no properties found in cached result") + } + + // Check TTL - if entry has expired, delete it and return cache miss + if expiresAtRaw, exists := properties["expires_at"]; exists && expiresAtRaw != nil { + var expiresAt int64 + var validType bool + switch v := expiresAtRaw.(type) { + case string: + var err error + expiresAt, err = strconv.ParseInt(v, 10, 64) + if err != nil { + validType = false + } else { + validType = true + } + case float64: + expiresAt = int64(v) + validType = true + case int64: + expiresAt = v + validType = true + case int: + expiresAt = int64(v) + validType = true + } + if validType { + currentTime := time.Now().Unix() + if expiresAt < currentTime { + // Entry has expired, delete it asynchronously + go func() { + deleteCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := plugin.store.Delete(deleteCtx, plugin.config.VectorStoreNamespace, result.ID) + if err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to delete expired entry %s: %v", PluginLoggerPrefix, result.ID, err)) + } + }() + // Return nil to indicate cache miss + return nil, nil + } + } + } + + // Check if this is a streaming response - need to check for non-null values + streamResponses, hasStreamingResponse := properties["stream_chunks"] + singleResponse, hasSingleResponse := properties["response"] + + // Consider fields present only if they're not null + hasValidSingleResponse := hasSingleResponse && singleResponse != nil + hasValidStreamingResponse := hasStreamingResponse && streamResponses != nil + + // Parse stream_chunks + streamChunks, err := plugin.parseStreamChunks(streamResponses) + if err != nil || len(streamChunks) == 0 { + hasValidStreamingResponse = false + } + + similarity := 0.0 + if result.Score != nil { + similarity = *result.Score + } + + if hasValidStreamingResponse && !hasValidSingleResponse { + // Handle streaming response + return plugin.buildStreamingResponseFromResult(ctx, req, result, streamResponses, cacheType, threshold, similarity, inputTokens) + } else if hasValidSingleResponse && !hasValidStreamingResponse { + // Handle single response + return plugin.buildSingleResponseFromResult(ctx, req, result, singleResponse, cacheType, threshold, similarity, inputTokens) + } else { + return nil, fmt.Errorf("cached result has invalid response data: both or neither response/stream_chunks are present (response: %v, stream_chunks: %v)", singleResponse, streamResponses) + } +} + +// buildSingleResponseFromResult constructs a single response from cached data +func (plugin *Plugin) buildSingleResponseFromResult(ctx *context.Context, req *schemas.BifrostRequest, result vectorstore.SearchResult, responseData interface{}, cacheType CacheType, threshold float64, similarity float64, inputTokens int) (*schemas.PluginShortCircuit, error) { + provider, _, _ := req.GetRequestFields() + + responseStr, ok := responseData.(string) + if !ok { + return nil, fmt.Errorf("cached response is not a string") + } + + // Unmarshal the cached response + var cachedResponse schemas.BifrostResponse + if err := json.Unmarshal([]byte(responseStr), &cachedResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal cached response: %w", err) + } + + extraFields := cachedResponse.GetExtraFields() + + if extraFields.CacheDebug == nil { + extraFields.CacheDebug = &schemas.BifrostCacheDebug{} + } + extraFields.CacheDebug.CacheHit = true + extraFields.CacheDebug.HitType = bifrost.Ptr(string(cacheType)) + extraFields.CacheDebug.CacheID = bifrost.Ptr(result.ID) + if cacheType == CacheTypeSemantic { + extraFields.CacheDebug.ProviderUsed = bifrost.Ptr(string(plugin.config.Provider)) + extraFields.CacheDebug.ModelUsed = bifrost.Ptr(plugin.config.EmbeddingModel) + extraFields.CacheDebug.Threshold = &threshold + extraFields.CacheDebug.Similarity = &similarity + extraFields.CacheDebug.InputTokens = &inputTokens + } else { + extraFields.CacheDebug.ProviderUsed = nil + extraFields.CacheDebug.ModelUsed = nil + extraFields.CacheDebug.Threshold = nil + extraFields.CacheDebug.Similarity = nil + extraFields.CacheDebug.InputTokens = nil + } + + extraFields.Provider = provider + + *ctx = context.WithValue(*ctx, isCacheHitKey, true) + *ctx = context.WithValue(*ctx, cacheHitTypeKey, cacheType) + + return &schemas.PluginShortCircuit{ + Response: &cachedResponse, + }, nil +} + +// buildStreamingResponseFromResult constructs a streaming response from cached data +func (plugin *Plugin) buildStreamingResponseFromResult(ctx *context.Context, req *schemas.BifrostRequest, result vectorstore.SearchResult, streamData interface{}, cacheType CacheType, threshold float64, similarity float64, inputTokens int) (*schemas.PluginShortCircuit, error) { + provider, _, _ := req.GetRequestFields() + + // Parse stream_chunks + streamArray, err := plugin.parseStreamChunks(streamData) + if err != nil { + return nil, fmt.Errorf("failed to parse stream_chunks: %w", err) + } + + // Mark cache-hit once to avoid concurrent ctx writes + *ctx = context.WithValue(*ctx, isCacheHitKey, true) + *ctx = context.WithValue(*ctx, cacheHitTypeKey, cacheType) + + // Create stream channel + streamChan := make(chan *schemas.BifrostStream) + + go func() { + defer close(streamChan) + + // Set cache-hit markers inside the streaming goroutine to avoid races + *ctx = context.WithValue(*ctx, isCacheHitKey, true) + *ctx = context.WithValue(*ctx, cacheHitTypeKey, cacheType) + + // Process each stream chunk + for i, chunkData := range streamArray { + chunkStr, ok := chunkData.(string) + if !ok { + plugin.logger.Warn(fmt.Sprintf("%s Stream chunk %d is not a string, skipping", PluginLoggerPrefix, i)) + continue + } + + // Unmarshal the chunk as BifrostResponse + var cachedResponse schemas.BifrostResponse + if err := json.Unmarshal([]byte(chunkStr), &cachedResponse); err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to unmarshal stream chunk %d, skipping: %v", PluginLoggerPrefix, i, err)) + continue + } + + extraFields := cachedResponse.GetExtraFields() + + // Add cache debug to only the last chunk and set stream end indicator + if i == len(streamArray)-1 { + *ctx = context.WithValue(*ctx, schemas.BifrostContextKeyStreamEndIndicator, true) + cacheDebug := schemas.BifrostCacheDebug{ + CacheHit: true, + HitType: bifrost.Ptr(string(cacheType)), + CacheID: bifrost.Ptr(result.ID), + } + if cacheType == CacheTypeSemantic { + cacheDebug.ProviderUsed = bifrost.Ptr(string(plugin.config.Provider)) + cacheDebug.ModelUsed = bifrost.Ptr(plugin.config.EmbeddingModel) + cacheDebug.Threshold = &threshold + cacheDebug.Similarity = &similarity + cacheDebug.InputTokens = &inputTokens + } else { + cacheDebug.ProviderUsed = nil + cacheDebug.ModelUsed = nil + cacheDebug.Threshold = nil + cacheDebug.Similarity = nil + cacheDebug.InputTokens = nil + } + extraFields.CacheDebug = &cacheDebug + } + + // extraField is a pointer so it'll automatically reflect on the parent struct + extraFields.Provider = provider + + // Send chunk to stream + streamChan <- &schemas.BifrostStream{ + BifrostTextCompletionResponse: cachedResponse.TextCompletionResponse, + BifrostChatResponse: cachedResponse.ChatResponse, + BifrostResponsesStreamResponse: cachedResponse.ResponsesStreamResponse, + BifrostSpeechStreamResponse: cachedResponse.SpeechStreamResponse, + BifrostTranscriptionStreamResponse: cachedResponse.TranscriptionStreamResponse, + } + } + }() + + return &schemas.PluginShortCircuit{ + Stream: streamChan, + }, nil +} + +// parseStreamChunks parses stream_chunks data from various formats into []interface{} +// Handles []interface{}, []string, and JSON string formats +func (plugin *Plugin) parseStreamChunks(streamData interface{}) ([]interface{}, error) { + if streamData == nil { + return nil, fmt.Errorf("stream data is nil") + } + + switch v := streamData.(type) { + case []interface{}: + return v, nil + case []string: + // Convert []string to []interface{} + result := make([]interface{}, len(v)) + for i, s := range v { + result[i] = s + } + return result, nil + case string: + // Parse JSON string from Redis + var stringArray []string + if err := json.Unmarshal([]byte(v), &stringArray); err != nil { + return nil, fmt.Errorf("failed to parse JSON string: %w", err) + } + // Convert to []interface{} + result := make([]interface{}, len(stringArray)) + for i, s := range stringArray { + result[i] = s + } + return result, nil + default: + return nil, fmt.Errorf("unsupported stream data type: %T", streamData) + } +} diff --git a/plugins/semanticcache/stream.go b/plugins/semanticcache/stream.go new file mode 100644 index 000000000..1f429e148 --- /dev/null +++ b/plugins/semanticcache/stream.go @@ -0,0 +1,192 @@ +package semanticcache + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "time" +) + +// Streaming State Management Methods + +// createStreamAccumulator creates a new stream accumulator for a request +func (plugin *Plugin) createStreamAccumulator(requestID string, embedding []float32, metadata map[string]interface{}, ttl time.Duration) *StreamAccumulator { + accumulator := &StreamAccumulator{ + RequestID: requestID, + Chunks: make([]*StreamChunk, 0), + IsComplete: false, + Embedding: embedding, + Metadata: metadata, + TTL: ttl, + } + + plugin.streamAccumulators.Store(requestID, accumulator) + return accumulator +} + +// getOrCreateStreamAccumulator gets or creates a stream accumulator for a request +func (plugin *Plugin) getOrCreateStreamAccumulator(requestID string, embedding []float32, metadata map[string]interface{}, ttl time.Duration) *StreamAccumulator { + if accumulator, exists := plugin.streamAccumulators.Load(requestID); exists { + return accumulator.(*StreamAccumulator) + } + + // Create new accumulator if it doesn't exist + return plugin.createStreamAccumulator(requestID, embedding, metadata, ttl) +} + +// addStreamChunk adds a chunk to the stream accumulator +func (plugin *Plugin) addStreamChunk(requestID string, chunk *StreamChunk, isFinalChunk bool) error { + // Get accumulator (should exist if properly initialized) + accumulatorInterface, exists := plugin.streamAccumulators.Load(requestID) + if !exists { + return fmt.Errorf("stream accumulator not found for request %s", requestID) + } + + accumulator := accumulatorInterface.(*StreamAccumulator) + accumulator.mu.Lock() + defer accumulator.mu.Unlock() + + // Add chunk to the list (chunks arrive in order) + accumulator.Chunks = append(accumulator.Chunks, chunk) + + // Set FinalTimestamp when FinishReason is present + // This handles both normal completion chunks and usage-only last chunks + if isFinalChunk { + accumulator.FinalTimestamp = chunk.Timestamp + } + + plugin.logger.Debug(fmt.Sprintf("%s Added chunk to stream accumulator for request %s", PluginLoggerPrefix, requestID)) + + return nil +} + +// processAccumulatedStream processes all accumulated chunks and caches the complete stream +// Flow: Collect everything β†’ Check for ANY errors β†’ If no errors, order and send to .Add() β†’ If any errors, drop operation +func (plugin *Plugin) processAccumulatedStream(ctx context.Context, requestID string) error { + accumulatorInterface, exists := plugin.streamAccumulators.Load(requestID) + if !exists { + return fmt.Errorf("stream accumulator not found for request %s", requestID) + } + + accumulator := accumulatorInterface.(*StreamAccumulator) + accumulator.mu.Lock() + + // Ensure cleanup happens + defer plugin.cleanupStreamAccumulator(requestID) + defer accumulator.mu.Unlock() + + // STEP 1: Check if any chunk in the entire stream had an error + if accumulator.HasError { + plugin.logger.Debug(fmt.Sprintf("%s Stream for request %s had errors, dropping entire operation (not caching)", PluginLoggerPrefix, requestID)) + return nil + } + + // STEP 2: All chunks are clean, now sort and build ordered stream for caching + plugin.logger.Debug(fmt.Sprintf("%s Stream for request %s completed successfully, processing %d chunks for caching", PluginLoggerPrefix, requestID, len(accumulator.Chunks))) + + // Sort chunks by their ChunkIndex to ensure proper order (stable + nil-safe) + sort.SliceStable(accumulator.Chunks, func(i, j int) bool { + if accumulator.Chunks[i].Response == nil || accumulator.Chunks[j].Response == nil { + // Push nils to the end deterministically + return accumulator.Chunks[j].Response != nil + } + if accumulator.Chunks[i].Response.TextCompletionResponse != nil { + return accumulator.Chunks[i].Response.TextCompletionResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.TextCompletionResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.ChatResponse != nil { + return accumulator.Chunks[i].Response.ChatResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.ChatResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.ResponsesResponse != nil { + return accumulator.Chunks[i].Response.ResponsesResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.ResponsesResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.ResponsesStreamResponse != nil { + return accumulator.Chunks[i].Response.ResponsesStreamResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.ResponsesStreamResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.SpeechResponse != nil { + return accumulator.Chunks[i].Response.SpeechResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.SpeechResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.SpeechStreamResponse != nil { + return accumulator.Chunks[i].Response.SpeechStreamResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.SpeechStreamResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.TranscriptionResponse != nil { + return accumulator.Chunks[i].Response.TranscriptionResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.TranscriptionResponse.ExtraFields.ChunkIndex + } + if accumulator.Chunks[i].Response.TranscriptionStreamResponse != nil { + return accumulator.Chunks[i].Response.TranscriptionStreamResponse.ExtraFields.ChunkIndex < accumulator.Chunks[j].Response.TranscriptionStreamResponse.ExtraFields.ChunkIndex + } + return false + }) + + var streamResponses []string + for i, chunk := range accumulator.Chunks { + if chunk.Response != nil { + chunkData, err := json.Marshal(chunk.Response) + if err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to marshal stream chunk %d: %v", PluginLoggerPrefix, i, err)) + continue + } + streamResponses = append(streamResponses, string(chunkData)) + } + } + + // STEP 3: Validate we have valid chunks to cache + if len(streamResponses) == 0 { + plugin.logger.Warn(fmt.Sprintf("%s Stream for request %s has no valid response chunks, skipping cache storage", PluginLoggerPrefix, requestID)) + return nil + } + + // STEP 4: Build final metadata and submit to .Add() method + finalMetadata := make(map[string]interface{}) + for k, v := range accumulator.Metadata { + finalMetadata[k] = v + } + finalMetadata["stream_chunks"] = streamResponses + + // Store complete unified entry using original requestID - this is the final .Add() call + if err := plugin.store.Add(ctx, plugin.config.VectorStoreNamespace, requestID, accumulator.Embedding, finalMetadata); err != nil { + return fmt.Errorf("failed to store complete streaming cache entry: %w", err) + } + + plugin.logger.Debug(fmt.Sprintf("%s Successfully cached complete stream with %d ordered chunks, ID: %s", PluginLoggerPrefix, len(streamResponses), requestID)) + return nil +} + +// cleanupStreamAccumulator removes the stream accumulator for a request +func (plugin *Plugin) cleanupStreamAccumulator(requestID string) { + plugin.streamAccumulators.Delete(requestID) +} + +// cleanupOldStreamAccumulators removes stream accumulators older than 5 minutes +func (plugin *Plugin) cleanupOldStreamAccumulators() { + fiveMinutesAgo := time.Now().Add(-5 * time.Minute) + cleanedCount := 0 + toDelete := make([]string, 0) + + plugin.streamAccumulators.Range(func(key, value interface{}) bool { + requestID := key.(string) + accumulator := value.(*StreamAccumulator) + + // Check if this accumulator is old (no activity for 5 minutes) + accumulator.mu.Lock() + if len(accumulator.Chunks) > 0 { + firstChunkTime := accumulator.Chunks[0].Timestamp + if firstChunkTime.Before(fiveMinutesAgo) { + toDelete = append(toDelete, requestID) + plugin.logger.Debug(fmt.Sprintf("%s Cleaned up old stream accumulator for request %s", PluginLoggerPrefix, requestID)) + } + } + accumulator.mu.Unlock() + return true + }) + + // Delete outside the Range loop to avoid concurrent modification + for _, requestID := range toDelete { + plugin.streamAccumulators.Delete(requestID) + cleanedCount++ + } + + if cleanedCount > 0 { + plugin.logger.Debug(fmt.Sprintf("%s Cleaned up %d old stream accumulators", PluginLoggerPrefix, cleanedCount)) + } +} diff --git a/plugins/semanticcache/test_utils.go b/plugins/semanticcache/test_utils.go new file mode 100644 index 000000000..a83f4162f --- /dev/null +++ b/plugins/semanticcache/test_utils.go @@ -0,0 +1,709 @@ +package semanticcache + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/vectorstore" + mocker "github.com/maximhq/bifrost/plugins/mocker" +) + +// getWeaviateConfigFromEnv retrieves Weaviate configuration from environment variables +func getWeaviateConfigFromEnv() vectorstore.WeaviateConfig { + scheme := os.Getenv("WEAVIATE_SCHEME") + if scheme == "" { + scheme = "http" + } + + host := os.Getenv("WEAVIATE_HOST") + if host == "" { + host = "localhost:9000" + } + + apiKey := os.Getenv("WEAVIATE_API_KEY") + + timeoutStr := os.Getenv("WEAVIATE_TIMEOUT") + timeout := 30 // default + if timeoutStr != "" { + if t, err := strconv.Atoi(timeoutStr); err == nil { + timeout = t + } + } + + return vectorstore.WeaviateConfig{ + Scheme: scheme, + Host: host, + APIKey: apiKey, + Timeout: time.Duration(timeout) * time.Second, + } +} + +// getRedisConfigFromEnv retrieves Redis configuration from environment variables +func getRedisConfigFromEnv() vectorstore.RedisConfig { + addr := os.Getenv("REDIS_ADDR") + if addr == "" { + addr = "localhost:6379" + } + username := os.Getenv("REDIS_USERNAME") + password := os.Getenv("REDIS_PASSWORD") + db := os.Getenv("REDIS_DB") + if db == "" { + db = "0" + } + dbInt, err := strconv.Atoi(db) + if err != nil { + dbInt = 0 + } + + timeoutStr := os.Getenv("REDIS_TIMEOUT") + if timeoutStr == "" { + timeoutStr = "10s" + } + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + timeout = 10 * time.Second + } + + return vectorstore.RedisConfig{ + Addr: addr, + Username: username, + Password: password, + DB: dbInt, + ContextTimeout: timeout, + } +} + +// BaseAccount implements the schemas.Account interface for testing purposes. +type BaseAccount struct{} + +func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{schemas.OpenAI}, nil +} + +func (baseAccount *BaseAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, // Empty models array means it supports ALL models + Weight: 1.0, + }, + }, nil +} + +func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 60, + MaxRetries: 5, + RetryBackoffInitial: 100 * time.Millisecond, + RetryBackoffMax: 30 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 10, + BufferSize: 10, + }, + }, nil +} + +// getMockRules returns a list of mock rules for the semantic cache tests +func getMockRules() []mocker.MockRule { + return []mocker.MockRule{ + // Core test prompts + { + Name: "bifrost-definition", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)What is Bifrost.*")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Bifrost is a unified API for interacting with multiple AI providers."}}, + }, + }, + { + Name: "machine-learning-explanation", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)what is machine learning\\?|explain machine learning|machine learning concepts|can you explain machine learning|explain the basics of machine learning")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Machine learning is a field of AI that uses statistical techniques to give computer systems the ability to learn from data."}}, + }, + }, + { + Name: "ai-explanation", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)what is artificial intelligence\\?|can you explain what ai is\\?|define artificial intelligence")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Artificial intelligence is the simulation of human intelligence in machines."}}, + }, + }, + { + Name: "capital-of-france", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("What is the capital of France\\?")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "The capital of France is Paris."}}, + }, + }, + { + Name: "newton-laws", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)describe.*newton.*three laws|describe.*three laws.*newton")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Newton's three laws of motion are: 1. An object at rest stays at rest and an object in motion stays in motion with the same speed and in the same direction unless acted upon by an unbalanced force. 2. The acceleration of an object as produced by a net force is directly proportional to the magnitude of the net force, in the same direction as the net force, and inversely proportional to the mass of the object. 3. For every action, there is an equal and opposite reaction."}}, + }, + }, + // Weather-related prompts + { + Name: "weather-question", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)what.*weather|weather.*like")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "It's sunny today with a temperature of 72Β°F."}}, + }, + }, + // Blockchain and deep learning + { + Name: "blockchain-definition", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)define blockchain|blockchain technology")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Blockchain is a distributed ledger technology that maintains a continuously growing list of records."}}, + }, + }, + { + Name: "deep-learning", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)what is deep learning")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Deep learning is a subset of machine learning that uses neural networks with multiple layers."}}, + }, + }, + // Quantum computing + { + Name: "quantum-computing", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)quantum computing|explain quantum")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Quantum computing uses quantum mechanical phenomena to process information in ways that classical computers cannot."}}, + }, + }, + // Conversation prompts + { + Name: "hello-greeting", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)^hello$|^hi$|hello.*world")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Hello! How can I help you today?"}}, + }, + }, + { + Name: "how-are-you", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)how are you")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "I'm doing well, thank you for asking!"}}, + }, + }, + { + Name: "meaning-of-life", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)meaning of life")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "The meaning of life is a philosophical question that has been pondered for centuries. Some say it's 42!"}}, + }, + }, + { + Name: "short-story", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)tell me.*short story")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Once upon a time, there was a brave knight who saved the day."}}, + }, + }, + // Test-specific prompts + { + Name: "test-configuration", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)test configuration")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "This is a test configuration response."}}, + }, + }, + { + Name: "test-messages", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)test.*message|test.*no-store|test.*cache|test.*error|ttl test|threshold test|provider.*test|edge case test")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "This is a test response for various test scenarios."}}, + }, + }, + { + Name: "long-prompt", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)very long prompt")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "This is a response to a very long prompt."}}, + }, + }, + { + Name: "parameter-tests", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)test.*parameters|performance test")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Parameter test response with various settings."}}, + }, + }, + // Dynamic message patterns (for conversation tests) + { + Name: "message-pattern", + Enabled: true, + Conditions: mocker.Conditions{MessageRegex: bifrost.Ptr("(?i)message \\d+")}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "Response to numbered message."}}, + }, + }, + // Default catch-all rule (lowest priority) + { + Name: "default-mock", + Enabled: true, + Priority: -1, // Lower priority + Conditions: mocker.Conditions{}, + Probability: 1.0, + Responses: []mocker.Response{ + {Type: mocker.ResponseTypeSuccess, Content: &mocker.SuccessResponse{Message: "This is a generic mocked response."}}, + }, + }, + } +} + +// getMockedBifrostClient creates a Bifrost client with a mocker plugin for testing +func getMockedBifrostClient(t *testing.T, ctx context.Context, logger schemas.Logger, semanticCachePlugin schemas.Plugin) *bifrost.Bifrost { + mockerCfg := mocker.MockerConfig{ + Enabled: true, + Rules: getMockRules(), + } + + mockerPlugin, err := mocker.Init(mockerCfg) + if err != nil { + t.Fatalf("Failed to initialize mocker plugin: %v", err) + } + + account := &BaseAccount{} + client, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: account, + Plugins: []schemas.Plugin{semanticCachePlugin, mockerPlugin}, + Logger: logger, + }) + if err != nil { + t.Fatalf("Error initializing Bifrost with mocker: %v", err) + } + + return client +} + +// TestSetup contains common test setup components +type TestSetup struct { + Logger schemas.Logger + Store vectorstore.VectorStore + Plugin schemas.Plugin + Client *bifrost.Bifrost + Config *Config +} + +// NewTestSetup creates a new test setup with default configuration +func NewTestSetup(t *testing.T) *TestSetup { + return NewTestSetupWithConfig(t, &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + Threshold: 0.8, + CleanUpOnShutdown: true, + Keys: []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, + }) +} + +// NewTestSetupWithConfig creates a new test setup with custom configuration +func NewTestSetupWithConfig(t *testing.T, config *Config) *TestSetup { + ctx := context.Background() + logger := bifrost.NewDefaultLogger(schemas.LogLevelDebug) + + // Keep Weaviate for embeddings, as mocker only affects chat completions + store, err := vectorstore.NewVectorStore(context.Background(), &vectorstore.Config{ + Type: vectorstore.VectorStoreTypeWeaviate, + Config: getWeaviateConfigFromEnv(), + Enabled: true, + }, logger) + if err != nil { + t.Fatalf("Vector store not available or failed to connect: %v", err) + } + + plugin, err := Init(context.Background(), config, logger, store) + if err != nil { + t.Fatalf("Failed to initialize plugin: %v", err) + } + + // Clear test keys + pluginImpl := plugin.(*Plugin) + clearTestKeysWithStore(t, pluginImpl.store) + + // Get a mocked Bifrost client + client := getMockedBifrostClient(t, ctx, logger, plugin) + + return &TestSetup{ + Logger: logger, + Store: store, + Plugin: plugin, + Client: client, + Config: config, + } +} + +// Cleanup cleans up test resources +func (ts *TestSetup) Cleanup() { + if ts.Client != nil { + ts.Client.Shutdown() + } +} + +// clearTestKeysWithStore removes all keys matching the test prefix using the store interface +func clearTestKeysWithStore(t *testing.T, store vectorstore.VectorStore) { + // With the new unified VectorStore interface, cleanup is typically handled + // by the vector store implementation (e.g., dropping entire classes) + t.Logf("Test cleanup delegated to vector store implementation") +} + +// CreateBasicChatRequest creates a basic chat completion request for testing +func CreateBasicChatRequest(content string, temperature float64, maxTokens int) *schemas.BifrostChatRequest { + return &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: []schemas.ChatMessage{ + { + Role: "user", + Content: &schemas.ChatMessageContent{ + ContentStr: &content, + }, + }, + }, + Params: &schemas.ChatParameters{ + Temperature: &temperature, + MaxCompletionTokens: &maxTokens, + }, + } +} + +// CreateStreamingChatRequest creates a streaming chat completion request for testing +func CreateStreamingChatRequest(content string, temperature float64, maxTokens int) *schemas.BifrostChatRequest { + return CreateBasicChatRequest(content, temperature, maxTokens) +} + +// CreateSpeechRequest creates a speech synthesis request for testing +func CreateSpeechRequest(input string, voice string) *schemas.BifrostSpeechRequest { + return &schemas.BifrostSpeechRequest{ + Provider: schemas.OpenAI, + Model: "tts-1", + Input: &schemas.SpeechInput{ + Input: input, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: "mp3", + }, + } +} + +// AssertCacheHit verifies that a response was served from cache +func AssertCacheHit(t *testing.T, response *schemas.BifrostResponse, expectedCacheType string) { + extraFields := response.GetExtraFields() + + if extraFields.CacheDebug == nil { + t.Error("Cache metadata missing 'cache_debug'") + return + } + + // Check that it's actually a cache hit + if !extraFields.CacheDebug.CacheHit { + t.Error("❌ Expected cache hit but response was not cached") + return + } + + if expectedCacheType != "" { + cacheType := extraFields.CacheDebug.HitType + if cacheType != nil && *cacheType != expectedCacheType { + t.Errorf("Expected cache type '%s', got '%s'", expectedCacheType, *cacheType) + return + } + + t.Log("βœ… Response correctly served from cache") + } + + t.Log("βœ… Response correctly served from cache") +} + +// AssertNoCacheHit verifies that a response was NOT served from cache +func AssertNoCacheHit(t *testing.T, response *schemas.BifrostResponse) { + extraFields := response.GetExtraFields() + + if extraFields.CacheDebug == nil { + t.Log("βœ… Response correctly not served from cache (no 'cache_debug' flag)") + return + } + + // Check the actual CacheHit field instead of just checking if CacheDebug exists + if extraFields.CacheDebug.CacheHit { + t.Error("❌ Response was cached when it shouldn't be") + return + } + + t.Log("βœ… Response correctly not served from cache (cache_debug present but CacheHit=false)") +} + +// WaitForCache waits for async cache operations to complete +func WaitForCache() { + time.Sleep(1 * time.Second) +} + +// CreateEmbeddingRequest creates an embedding request for testing +func CreateEmbeddingRequest(texts []string) *schemas.BifrostEmbeddingRequest { + return &schemas.BifrostEmbeddingRequest{ + Provider: schemas.OpenAI, + Model: "text-embedding-3-small", + Input: &schemas.EmbeddingInput{ + Texts: texts, + }, + } +} + +// CreateBasicResponsesRequest creates a basic Responses API request for testing +func CreateBasicResponsesRequest(content string, temperature float64, maxTokens int) *schemas.BifrostResponsesRequest { + userRole := schemas.ResponsesInputMessageRoleUser + return &schemas.BifrostResponsesRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o", + Input: []schemas.ResponsesMessage{ + { + Role: &userRole, + Content: &schemas.ResponsesMessageContent{ + ContentStr: &content, + }, + }, + }, + Params: &schemas.ResponsesParameters{ + Temperature: &temperature, + MaxOutputTokens: &maxTokens, + }, + } +} + +// CreateResponsesRequestWithTools creates a Responses API request with tools for testing +func CreateResponsesRequestWithTools(content string, temperature float64, maxTokens int, tools []schemas.ResponsesTool) *schemas.BifrostResponsesRequest { + req := CreateBasicResponsesRequest(content, temperature, maxTokens) + req.Params.Tools = tools + return req +} + +// CreateResponsesRequestWithInstructions creates a Responses API request with system instructions +func CreateResponsesRequestWithInstructions(content string, instructions string, temperature float64, maxTokens int) *schemas.BifrostResponsesRequest { + req := CreateBasicResponsesRequest(content, temperature, maxTokens) + req.Params.Instructions = &instructions + return req +} + +// CreateStreamingResponsesRequest creates a streaming Responses API request for testing +func CreateStreamingResponsesRequest(content string, temperature float64, maxTokens int) *schemas.BifrostResponsesRequest { + return CreateBasicResponsesRequest(content, temperature, maxTokens) +} + +// CreateContextWithCacheKey creates a context with the test cache key +func CreateContextWithCacheKey(value string) context.Context { + return context.WithValue(context.Background(), CacheKey, value) +} + +// CreateContextWithCacheKeyAndType creates a context with cache key and cache type +func CreateContextWithCacheKeyAndType(value string, cacheType CacheType) context.Context { + ctx := context.WithValue(context.Background(), CacheKey, value) + return context.WithValue(ctx, CacheTypeKey, cacheType) +} + +// CreateContextWithCacheKeyAndTTL creates a context with cache key and custom TTL +func CreateContextWithCacheKeyAndTTL(value string, ttl time.Duration) context.Context { + ctx := context.WithValue(context.Background(), CacheKey, value) + return context.WithValue(ctx, CacheTTLKey, ttl) +} + +// CreateContextWithCacheKeyAndThreshold creates a context with cache key and custom threshold +func CreateContextWithCacheKeyAndThreshold(value string, threshold float64) context.Context { + ctx := context.WithValue(context.Background(), CacheKey, value) + return context.WithValue(ctx, CacheThresholdKey, threshold) +} + +// CreateContextWithCacheKeyAndNoStore creates a context with cache key and no-store flag +func CreateContextWithCacheKeyAndNoStore(value string, noStore bool) context.Context { + ctx := context.WithValue(context.Background(), CacheKey, value) + return context.WithValue(ctx, CacheNoStoreKey, noStore) +} + +// CreateTestSetupWithConversationThreshold creates a test setup with custom conversation history threshold +func CreateTestSetupWithConversationThreshold(t *testing.T, threshold int) *TestSetup { + config := &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + CleanUpOnShutdown: true, + Threshold: 0.8, + ConversationHistoryThreshold: threshold, + Keys: []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, + } + + return NewTestSetupWithConfig(t, config) +} + +// CreateTestSetupWithExcludeSystemPrompt creates a test setup with ExcludeSystemPrompt setting +func CreateTestSetupWithExcludeSystemPrompt(t *testing.T, excludeSystem bool) *TestSetup { + config := &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + CleanUpOnShutdown: true, + Threshold: 0.8, + ExcludeSystemPrompt: &excludeSystem, + Keys: []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, + } + + return NewTestSetupWithConfig(t, config) +} + +// CreateTestSetupWithThresholdAndExcludeSystem creates a test setup with both conversation threshold and exclude system prompt settings +func CreateTestSetupWithThresholdAndExcludeSystem(t *testing.T, threshold int, excludeSystem bool) *TestSetup { + config := &Config{ + Provider: schemas.OpenAI, + EmbeddingModel: "text-embedding-3-small", + CleanUpOnShutdown: true, + Threshold: 0.8, + ConversationHistoryThreshold: threshold, + ExcludeSystemPrompt: &excludeSystem, + Keys: []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, + } + + return NewTestSetupWithConfig(t, config) +} + +// CreateConversationRequest creates a chat request with conversation history +func CreateConversationRequest(messages []schemas.ChatMessage, temperature float64, maxTokens int) *schemas.BifrostChatRequest { + return &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + Input: messages, + Params: &schemas.ChatParameters{ + Temperature: &temperature, + MaxCompletionTokens: &maxTokens, + }, + } +} + +// BuildConversationHistory creates a conversation history from pairs of user/assistant messages +func BuildConversationHistory(systemPrompt string, userAssistantPairs ...[]string) []schemas.ChatMessage { + messages := []schemas.ChatMessage{} + + // Add system prompt if provided + if systemPrompt != "" { + messages = append(messages, schemas.ChatMessage{ + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ + ContentStr: &systemPrompt, + }, + }) + } + + // Add user/assistant pairs + for _, pair := range userAssistantPairs { + if len(pair) >= 1 && pair[0] != "" { + userMsg := pair[0] + messages = append(messages, schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: &userMsg, + }, + }) + } + if len(pair) >= 2 && pair[1] != "" { + assistantMsg := pair[1] + messages = append(messages, schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: &assistantMsg, + }, + }) + } + } + + return messages +} + +// AddUserMessage adds a user message to existing conversation +func AddUserMessage(messages []schemas.ChatMessage, userMessage string) []schemas.ChatMessage { + newMessage := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: &userMessage, + }, + } + return append(messages, newMessage) +} + +// RetryConfig defines retry configuration for API requests +type RetryConfig struct { + MaxRetries int + BaseDelay time.Duration +} + +// DefaultRetryConfig returns the default retry configuration +func DefaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxRetries: 2, + BaseDelay: 5 * time.Millisecond, + } +} diff --git a/plugins/semanticcache/utils.go b/plugins/semanticcache/utils.go new file mode 100644 index 000000000..4954a1053 --- /dev/null +++ b/plugins/semanticcache/utils.go @@ -0,0 +1,864 @@ +package semanticcache + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strings" + "time" + + "github.com/cespare/xxhash/v2" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// normalizeText applies consistent normalization to text inputs for better cache hit rates. +// It converts text to lowercase and trims whitespace to reduce cache misses due to minor variations. +func normalizeText(text string) string { + return strings.ToLower(strings.TrimSpace(text)) +} + +// generateEmbedding generates an embedding for the given text using the configured provider. +func (plugin *Plugin) generateEmbedding(ctx context.Context, text string) ([]float32, int, error) { + // Create embedding request + embeddingReq := &schemas.BifrostEmbeddingRequest{ + Provider: plugin.config.Provider, + Model: plugin.config.EmbeddingModel, + Input: &schemas.EmbeddingInput{ + Text: &text, + }, + } + + // Generate embedding using bifrost client + response, err := plugin.client.EmbeddingRequest(ctx, embeddingReq) + if err != nil { + return nil, 0, fmt.Errorf("failed to generate embedding: %v", err) + } + + // Extract the first embedding from response + if len(response.Data) == 0 { + return nil, 0, fmt.Errorf("no embeddings returned from provider") + } + + // Get the embedding from the first data item + embedding := response.Data[0].Embedding + inputTokens := 0 + if response.Usage != nil { + inputTokens = response.Usage.TotalTokens + } + + if embedding.EmbeddingStr != nil { + // decode embedding.EmbeddingStr to []float32 + var vals []float32 + if err := json.Unmarshal([]byte(*embedding.EmbeddingStr), &vals); err != nil { + return nil, 0, fmt.Errorf("failed to parse string embedding: %w", err) + } + return vals, inputTokens, nil + } else if embedding.EmbeddingArray != nil { + return embedding.EmbeddingArray, inputTokens, nil + } else if len(embedding.Embedding2DArray) > 0 { + // Flatten 2D array into single embedding + var flattened []float32 + for _, arr := range embedding.Embedding2DArray { + flattened = append(flattened, arr...) + } + return flattened, inputTokens, nil + } + + return nil, 0, fmt.Errorf("embedding data is not in expected format") +} + +// generateRequestHash creates an xxhash of the request for semantic cache key generation. +// It normalizes the request by including all relevant fields that affect the response: +// - Input (chat completion, text completion, etc.) +// - Parameters (temperature, max_tokens, tools, etc.) +// - Provider (if CacheByProvider is true) +// - Model (if CacheByModel is true) +// +// Note: Fallbacks are excluded as they only affect error handling, not the actual response. +// +// Parameters: +// - req: The Bifrost request to hash for semantic cache key generation +// +// Returns: +// - string: Hexadecimal representation of the xxhash +// - error: Any error that occurred during request normalization or hashing +func (plugin *Plugin) generateRequestHash(req *schemas.BifrostRequest) (string, error) { + // Create a hash input structure that includes both input and parameters + hashInput := struct { + Input interface{} `json:"input"` + Params interface{} `json:"params,omitempty"` + Stream bool `json:"stream,omitempty"` + }{ + Input: plugin.getInputForCaching(req), + Stream: bifrost.IsStreamRequestType(req.RequestType), + } + + switch req.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + hashInput.Params = req.TextCompletionRequest.Params + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + hashInput.Params = req.ChatRequest.Params + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + hashInput.Params = req.ResponsesRequest.Params + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + if req.SpeechRequest != nil { + hashInput.Params = req.SpeechRequest.Params + } + case schemas.EmbeddingRequest: + hashInput.Params = req.EmbeddingRequest.Params + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + hashInput.Params = req.TranscriptionRequest.Params + } + + // Marshal to JSON for consistent hashing + jsonData, err := json.Marshal(hashInput) + if err != nil { + return "", fmt.Errorf("failed to marshal request for hashing: %w", err) + } + + // Generate hash based on configured algorithm + hash := xxhash.Sum64(jsonData) + return fmt.Sprintf("%x", hash), nil +} + +// extractTextForEmbedding extracts meaningful text from different input types for embedding generation. +// Returns the text to embed and metadata for storage. +// +// Text serialization format (for cache consistency): +// - Chat API: "role: content" +// - Responses API: "role: msgType: content" (when msgType is present), "role: content" (when msgType is empty) +// +// Note: Format updated to conditionally include msgType to avoid double colons and maintain consistency. +func (plugin *Plugin) extractTextForEmbedding(req *schemas.BifrostRequest) (string, string, error) { + metadata := map[string]interface{}{} + + attachments := []string{} + + // Add parameters as metadata if present - handle segregated parameters + metadata["stream"] = bifrost.IsStreamRequestType(req.RequestType) + + // Extract parameters based on request type + switch req.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + if req.TextCompletionRequest != nil && req.TextCompletionRequest.Params != nil { + plugin.extractTextCompletionParametersToMetadata(req.TextCompletionRequest.Params, metadata) + } + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + if req.ChatRequest != nil && req.ChatRequest.Params != nil { + plugin.extractChatParametersToMetadata(req.ChatRequest.Params, metadata) + } + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + if req.ResponsesRequest != nil && req.ResponsesRequest.Params != nil { + plugin.extractResponsesParametersToMetadata(req.ResponsesRequest.Params, metadata) + } + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + if req.SpeechRequest != nil && req.SpeechRequest.Params != nil { + plugin.extractSpeechParametersToMetadata(req.SpeechRequest.Params, metadata) + } + case schemas.EmbeddingRequest: + if req.EmbeddingRequest != nil && req.EmbeddingRequest.Params != nil { + plugin.extractEmbeddingParametersToMetadata(req.EmbeddingRequest.Params, metadata) + } + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + if req.TranscriptionRequest != nil && req.TranscriptionRequest.Params != nil { + plugin.extractTranscriptionParametersToMetadata(req.TranscriptionRequest.Params, metadata) + } + } + + switch { + case req.TextCompletionRequest != nil: + metadataHash, err := getMetadataHash(metadata) + if err != nil { + return "", "", fmt.Errorf("failed to marshal metadata for metadata hash: %w", err) + } + + var textContent string + if req.TextCompletionRequest.Input.PromptStr != nil { + textContent = normalizeText(*req.TextCompletionRequest.Input.PromptStr) + } else if len(req.TextCompletionRequest.Input.PromptArray) > 0 { + textContent = normalizeText(strings.Join(req.TextCompletionRequest.Input.PromptArray, " ")) + } + return textContent, metadataHash, nil + + case req.ChatRequest != nil: + reqInput, ok := plugin.getInputForCaching(req).([]schemas.ChatMessage) + if !ok { + return "", "", fmt.Errorf("failed to cast request input to chat messages") + } + + // Serialize chat messages for embedding + var textParts []string + for _, msg := range reqInput { + // Extract content as string + var content string + if msg.Content.ContentStr != nil { + content = *msg.Content.ContentStr + } else if msg.Content.ContentBlocks != nil { + // For content blocks, extract text parts + var blockTexts []string + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + blockTexts = append(blockTexts, normalizeText(*block.Text)) + } + if block.ImageURLStruct != nil && block.ImageURLStruct.URL != "" { + attachments = append(attachments, block.ImageURLStruct.URL) + } + } + content = strings.Join(blockTexts, " ") + } + + if content != "" { + textParts = append(textParts, fmt.Sprintf("%s: %s", msg.Role, content)) + } + } + + if len(textParts) == 0 { + return "", "", fmt.Errorf("no text content found in chat messages") + } + + if len(attachments) > 0 { + metadata["attachments"] = attachments + } + + metadataHash, err := getMetadataHash(metadata) + if err != nil { + return "", "", fmt.Errorf("failed to marshal metadata for metadata hash: %w", err) + } + + return strings.Join(textParts, "\n"), metadataHash, nil + + case req.ResponsesRequest != nil: + reqInput, ok := plugin.getInputForCaching(req).([]schemas.ResponsesMessage) + if !ok { + return "", "", fmt.Errorf("failed to cast request input to responses messages") + } + + // Serialize chat messages for embedding + var textParts []string + for _, msg := range reqInput { + // Extract content as string + var content string + if msg.Content.ContentStr != nil { + content = normalizeText(*msg.Content.ContentStr) + } else if msg.Content.ContentBlocks != nil { + // For content blocks, extract text parts + var blockTexts []string + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + blockTexts = append(blockTexts, normalizeText(*block.Text)) + } + if block.ResponsesInputMessageContentBlockImage != nil && block.ResponsesInputMessageContentBlockImage.ImageURL != nil { + attachments = append(attachments, *block.ResponsesInputMessageContentBlockImage.ImageURL) + } + if block.ResponsesInputMessageContentBlockFile != nil && block.ResponsesInputMessageContentBlockFile.FileURL != nil { + attachments = append(attachments, *block.ResponsesInputMessageContentBlockFile.FileURL) + } + } + content = strings.Join(blockTexts, " ") + } + + role := "" + msgType := "" + if msg.Role != nil { + role = string(*msg.Role) + } + if msg.Type != nil { + msgType = string(*msg.Type) + } + + if content != "" { + textParts = append(textParts, fmt.Sprintf("%s: %s: %s", role, msgType, content)) + } + } + + if len(textParts) == 0 { + return "", "", fmt.Errorf("no text content found in chat messages") + } + + if len(attachments) > 0 { + metadata["attachments"] = attachments + } + + metadataHash, err := getMetadataHash(metadata) + if err != nil { + return "", "", fmt.Errorf("failed to marshal metadata for metadata hash: %w", err) + } + + return strings.Join(textParts, "\n"), metadataHash, nil + + case req.SpeechRequest != nil: + if req.SpeechRequest.Input.Input != "" { + metadataHash, err := getMetadataHash(metadata) + if err != nil { + return "", "", fmt.Errorf("failed to marshal metadata for metadata hash: %w", err) + } + + return req.SpeechRequest.Input.Input, metadataHash, nil + } + return "", "", fmt.Errorf("no input text found in speech request") + + case req.EmbeddingRequest != nil: + metadataHash, err := getMetadataHash(metadata) + if err != nil { + return "", "", fmt.Errorf("failed to marshal metadata for metadata hash: %w", err) + } + + texts := req.EmbeddingRequest.Input.Texts + + if len(texts) == 0 && req.EmbeddingRequest.Input.Text != nil { + texts = []string{*req.EmbeddingRequest.Input.Text} + } + + var text string + for _, t := range texts { + text += t + " " + } + + return strings.TrimSpace(text), metadataHash, nil + + case req.TranscriptionRequest != nil: + // Skip semantic caching for transcription requests + return "", "", fmt.Errorf("transcription requests are not supported for semantic caching") + + default: + return "", "", fmt.Errorf("unsupported input type for semantic caching") + } +} + +func getMetadataHash(metadata map[string]interface{}) (string, error) { + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("failed to marshal metadata for metadata hash: %w", err) + } + return fmt.Sprintf("%x", xxhash.Sum64(metadataJSON)), nil +} + +// buildUnifiedMetadata constructs the unified metadata structure for VectorEntry +func (plugin *Plugin) buildUnifiedMetadata(provider schemas.ModelProvider, model string, paramsHash string, requestHash string, cacheKey string, ttl time.Duration) map[string]interface{} { + unifiedMetadata := make(map[string]interface{}) + + // Top-level fields (outside params) + unifiedMetadata["provider"] = string(provider) + unifiedMetadata["model"] = model + unifiedMetadata["request_hash"] = requestHash + unifiedMetadata["cache_key"] = cacheKey + unifiedMetadata["from_bifrost_semantic_cache_plugin"] = true + + // Calculate expiration timestamp (current time + TTL) + expiresAt := time.Now().Add(ttl).Unix() + unifiedMetadata["expires_at"] = expiresAt + + // Individual param fields will be stored as params_* by the vectorstore + // We pass the params map to the vectorstore, and it handles the individual field storage + if paramsHash != "" { + unifiedMetadata["params_hash"] = paramsHash + } + + return unifiedMetadata +} + +// addSingleResponse stores a single (non-streaming) response in unified VectorEntry format +func (plugin *Plugin) addSingleResponse(ctx context.Context, responseID string, res *schemas.BifrostResponse, embedding []float32, metadata map[string]interface{}, ttl time.Duration) error { + // Marshal response as string + responseData, err := json.Marshal(res) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + + // Add response field to metadata + metadata["response"] = string(responseData) + metadata["stream_chunks"] = []string{} + + // Store unified entry using new VectorStore interface + if err := plugin.store.Add(ctx, plugin.config.VectorStoreNamespace, responseID, embedding, metadata); err != nil { + return fmt.Errorf("failed to store unified cache entry: %w", err) + } + + plugin.logger.Debug(fmt.Sprintf("%s Successfully cached single response with ID: %s", PluginLoggerPrefix, responseID)) + return nil +} + +// addStreamingResponse handles streaming response storage by accumulating chunks +func (plugin *Plugin) addStreamingResponse(ctx context.Context, responseID string, res *schemas.BifrostResponse, bifrostErr *schemas.BifrostError, embedding []float32, metadata map[string]interface{}, ttl time.Duration, isFinalChunk bool) error { + // Create accumulator if it doesn't exist + accumulator := plugin.getOrCreateStreamAccumulator(responseID, embedding, metadata, ttl) + + // Create chunk from current response + chunk := &StreamChunk{ + Timestamp: time.Now(), + Response: res, + } + + // Check for finish reason or set error finish reason + if bifrostErr != nil { + // Error case - mark as final chunk with error + chunk.FinishReason = bifrost.Ptr("error") + } else if res != nil && res.ChatResponse != nil && len(res.ChatResponse.Choices) > 0 { + choice := res.ChatResponse.Choices[0] + if choice.ChatStreamResponseChoice != nil { + chunk.FinishReason = choice.FinishReason + } + } + + // Add chunk to accumulator synchronously to maintain order + if err := plugin.addStreamChunk(responseID, chunk, isFinalChunk); err != nil { + return fmt.Errorf("failed to add stream chunk: %w", err) + } + + // Check if this is the final chunk and gate final processing to ensure single invocation + accumulator.mu.Lock() + // Check for completion: either FinishReason is present, there's an error, or token usage exists + alreadyComplete := accumulator.IsComplete + + // Track if any chunk has an error + if bifrostErr != nil { + accumulator.HasError = true + } + + if isFinalChunk && !alreadyComplete { + accumulator.IsComplete = true + accumulator.FinalTimestamp = chunk.Timestamp + } + accumulator.mu.Unlock() + + // If this is the final chunk and hasn't been processed yet, process accumulated chunks + // Note: processAccumulatedStream will check for errors and skip caching if any errors occurred + if isFinalChunk && !alreadyComplete { + if processErr := plugin.processAccumulatedStream(ctx, responseID); processErr != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to process accumulated stream for request %s: %v", PluginLoggerPrefix, responseID, processErr)) + } + } + + return nil +} + +// getInputForCaching returns a normalized and sanitized copy of req.Input for hashing/embedding. +// It applies text normalization (lowercase + trim) and optionally removes system messages. +func (plugin *Plugin) getInputForCaching(req *schemas.BifrostRequest) interface{} { + switch req.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + // Create a shallow copy of the input to avoid mutating the original request + copiedInput := req.TextCompletionRequest.Input + + if copiedInput.PromptStr != nil { + normalizedText := normalizeText(*copiedInput.PromptStr) + copiedInput.PromptStr = &normalizedText + } else if len(copiedInput.PromptArray) > 0 { + // Create a copy of the PromptArray and normalize each element + normalizedPromptArray := make([]string, len(copiedInput.PromptArray)) + copy(normalizedPromptArray, copiedInput.PromptArray) + for i, prompt := range normalizedPromptArray { + normalizedPromptArray[i] = normalizeText(prompt) + } + copiedInput.PromptArray = normalizedPromptArray + } + return copiedInput + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + originalMessages := req.ChatRequest.Input + normalizedMessages := make([]schemas.ChatMessage, 0, len(originalMessages)) + + for _, msg := range originalMessages { + // Skip system messages if configured to exclude them + if plugin.config.ExcludeSystemPrompt != nil && *plugin.config.ExcludeSystemPrompt && msg.Role == schemas.ChatMessageRoleSystem { + continue + } + + // Create a copy of the message with normalized content + normalizedMsg := msg + + // Normalize message content + if msg.Content.ContentStr != nil { + normalizedContent := normalizeText(*msg.Content.ContentStr) + normalizedMsg.Content.ContentStr = &normalizedContent + } else if msg.Content.ContentBlocks != nil { + // Create a copy of content blocks with normalized text + normalizedBlocks := make([]schemas.ChatContentBlock, len(msg.Content.ContentBlocks)) + for i, block := range msg.Content.ContentBlocks { + normalizedBlocks[i] = block + if block.Text != nil { + normalizedText := normalizeText(*block.Text) + normalizedBlocks[i].Text = &normalizedText + } + } + normalizedMsg.Content.ContentBlocks = normalizedBlocks + } + + normalizedMessages = append(normalizedMessages, normalizedMsg) + } + return normalizedMessages + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + originalMessages := req.ResponsesRequest.Input + normalizedMessages := make([]schemas.ResponsesMessage, 0, len(originalMessages)) + + for _, msg := range originalMessages { + // Skip system messages if configured to exclude them + if plugin.config.ExcludeSystemPrompt != nil && *plugin.config.ExcludeSystemPrompt && msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleSystem { + continue + } + + // Create a deep copy of the message with normalized content + normalizedMsg := msg + + // Create a deep copy of the Content to avoid modifying the original + if msg.Content != nil { + normalizedContent := &schemas.ResponsesMessageContent{} + if msg.Content.ContentStr != nil { + normalizedText := normalizeText(*msg.Content.ContentStr) + normalizedContent.ContentStr = &normalizedText + } else if msg.Content.ContentBlocks != nil { + // Create a copy of content blocks with normalized text + normalizedBlocks := make([]schemas.ResponsesMessageContentBlock, len(msg.Content.ContentBlocks)) + for i, block := range msg.Content.ContentBlocks { + normalizedBlocks[i] = block + if block.Text != nil { + normalizedText := normalizeText(*block.Text) + normalizedBlocks[i].Text = &normalizedText + } + } + normalizedContent.ContentBlocks = normalizedBlocks + } + normalizedMsg.Content = normalizedContent + } + + normalizedMessages = append(normalizedMessages, normalizedMsg) + } + return normalizedMessages + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + return normalizeText(req.SpeechRequest.Input.Input) + case schemas.EmbeddingRequest: + input := req.EmbeddingRequest.Input + if input.Text != nil { + normalizedText := normalizeText(*input.Text) + return schemas.EmbeddingInput{Text: &normalizedText} + } else if len(input.Texts) > 0 { + normalizedTexts := make([]string, len(input.Texts)) + for i, text := range input.Texts { + normalizedTexts[i] = normalizeText(text) + } + return schemas.EmbeddingInput{Texts: normalizedTexts} + } + return input + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + return req.TranscriptionRequest.Input + default: + return nil + } +} + +// removeField removes the first occurrence of target from the slice. +func removeField(arr []string, target string) []string { + for i, v := range arr { + if v == target { + // remove element at index i + return append(arr[:i], arr[i+1:]...) + } + } + return arr // unchanged if target not found +} + +// extractChatParametersToMetadata extracts Chat API parameters into metadata map +func (plugin *Plugin) extractChatParametersToMetadata(params *schemas.ChatParameters, metadata map[string]interface{}) { + if params.ToolChoice != nil { + if params.ToolChoice.ChatToolChoiceStr != nil { + metadata["tool_choice"] = *params.ToolChoice.ChatToolChoiceStr + } else if params.ToolChoice.ChatToolChoiceStruct != nil && params.ToolChoice.ChatToolChoiceStruct.Function.Name != "" { + metadata["tool_choice"] = params.ToolChoice.ChatToolChoiceStruct.Function.Name + } + } + if params.Temperature != nil { + metadata["temperature"] = *params.Temperature + } + if params.TopP != nil { + metadata["top_p"] = *params.TopP + } + if params.MaxCompletionTokens != nil { + metadata["max_tokens"] = *params.MaxCompletionTokens + } + if params.Stop != nil { + metadata["stop_sequences"] = params.Stop + } + if params.PresencePenalty != nil { + metadata["presence_penalty"] = *params.PresencePenalty + } + if params.FrequencyPenalty != nil { + metadata["frequency_penalty"] = *params.FrequencyPenalty + } + if params.ParallelToolCalls != nil { + metadata["parallel_tool_calls"] = *params.ParallelToolCalls + } + if params.User != nil { + metadata["user"] = *params.User + } + if params.LogitBias != nil { + metadata["logit_bias"] = *params.LogitBias + } + if params.LogProbs != nil { + metadata["logprobs"] = *params.LogProbs + } + if params.Modalities != nil { + metadata["modalities"] = params.Modalities + } + if params.PromptCacheKey != nil { + metadata["prompt_cache_key"] = *params.PromptCacheKey + } + if params.ReasoningEffort != nil { + metadata["reasoning_effort"] = *params.ReasoningEffort + } + if params.ResponseFormat != nil { + metadata["response_format"] = params.ResponseFormat + } + if params.SafetyIdentifier != nil { + metadata["safety_identifier"] = *params.SafetyIdentifier + } + if params.Seed != nil { + metadata["seed"] = *params.Seed + } + if params.ServiceTier != nil { + metadata["service_tier"] = *params.ServiceTier + } + if params.Store != nil { + metadata["store"] = *params.Store + } + if params.TopLogProbs != nil { + metadata["top_logprobs"] = *params.TopLogProbs + } + if params.Verbosity != nil { + metadata["verbosity"] = *params.Verbosity + } + if len(params.ExtraParams) > 0 { + maps.Copy(metadata, params.ExtraParams) + } + if len(params.Tools) > 0 { + if toolsJSON, err := json.Marshal(params.Tools); err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to marshal tools for metadata: %v", PluginLoggerPrefix, err)) + } else { + toolHash := xxhash.Sum64(toolsJSON) + metadata["tools_hash"] = fmt.Sprintf("%x", toolHash) + } + } +} + +// extractResponsesParametersToMetadata extracts Responses API parameters into metadata map +func (plugin *Plugin) extractResponsesParametersToMetadata(params *schemas.ResponsesParameters, metadata map[string]interface{}) { + if params.ToolChoice != nil { + if params.ToolChoice.ResponsesToolChoiceStr != nil { + metadata["tool_choice"] = *params.ToolChoice.ResponsesToolChoiceStr + } else if params.ToolChoice.ResponsesToolChoiceStruct != nil && params.ToolChoice.ResponsesToolChoiceStruct.Name != nil { + metadata["tool_choice"] = *params.ToolChoice.ResponsesToolChoiceStruct.Name + } + } + if params.Temperature != nil { + metadata["temperature"] = *params.Temperature + } + if params.TopP != nil { + metadata["top_p"] = *params.TopP + } + if params.MaxOutputTokens != nil { + metadata["max_tokens"] = *params.MaxOutputTokens + } + if params.ParallelToolCalls != nil { + metadata["parallel_tool_calls"] = *params.ParallelToolCalls + } + if params.Background != nil { + metadata["background"] = *params.Background + } + if params.Conversation != nil { + metadata["conversation"] = *params.Conversation + } + if params.Include != nil { + metadata["include"] = params.Include + } + if params.Instructions != nil { + metadata["instructions"] = *params.Instructions + } + if params.MaxToolCalls != nil { + metadata["max_tool_calls"] = *params.MaxToolCalls + } + if params.PreviousResponseID != nil { + metadata["previous_response_id"] = *params.PreviousResponseID + } + if params.PromptCacheKey != nil { + metadata["prompt_cache_key"] = *params.PromptCacheKey + } + if params.Reasoning != nil { + if params.Reasoning.Effort != nil { + metadata["reasoning_effort"] = *params.Reasoning.Effort + } + if params.Reasoning.Summary != nil { + metadata["reasoning_summary"] = *params.Reasoning.Summary + } + } + if params.SafetyIdentifier != nil { + metadata["safety_identifier"] = *params.SafetyIdentifier + } + if params.ServiceTier != nil { + metadata["service_tier"] = *params.ServiceTier + } + if params.Store != nil { + metadata["store"] = *params.Store + } + if params.Text != nil { + if params.Text.Verbosity != nil { + metadata["text_verbosity"] = *params.Text.Verbosity + } + if params.Text.Format != nil { + metadata["text_format_type"] = params.Text.Format.Type + } + } + if params.TopLogProbs != nil { + metadata["top_logprobs"] = *params.TopLogProbs + } + if params.Truncation != nil { + metadata["truncation"] = *params.Truncation + } + if len(params.ExtraParams) > 0 { + maps.Copy(metadata, params.ExtraParams) + } + if len(params.Tools) > 0 { + if toolsJSON, err := json.Marshal(params.Tools); err != nil { + plugin.logger.Warn(fmt.Sprintf("%s Failed to marshal tools for metadata: %v", PluginLoggerPrefix, err)) + } else { + toolHash := xxhash.Sum64(toolsJSON) + metadata["tools_hash"] = fmt.Sprintf("%x", toolHash) + } + } +} + +// extractTextCompletionParametersToMetadata extracts Text Completion parameters into metadata map +func (plugin *Plugin) extractTextCompletionParametersToMetadata(params *schemas.TextCompletionParameters, metadata map[string]interface{}) { + if params.Temperature != nil { + metadata["temperature"] = *params.Temperature + } + if params.TopP != nil { + metadata["top_p"] = *params.TopP + } + if params.MaxTokens != nil { + metadata["max_tokens"] = *params.MaxTokens + } + if params.Stop != nil { + metadata["stop_sequences"] = params.Stop + } + if params.PresencePenalty != nil { + metadata["presence_penalty"] = *params.PresencePenalty + } + if params.FrequencyPenalty != nil { + metadata["frequency_penalty"] = *params.FrequencyPenalty + } + if params.User != nil { + metadata["user"] = *params.User + } + if params.BestOf != nil { + metadata["best_of"] = *params.BestOf + } + if params.Echo != nil { + metadata["echo"] = *params.Echo + } + if params.LogitBias != nil { + metadata["logit_bias"] = *params.LogitBias + } + if params.LogProbs != nil { + metadata["logprobs"] = *params.LogProbs + } + if params.N != nil { + metadata["n"] = *params.N + } + if params.Seed != nil { + metadata["seed"] = *params.Seed + } + if params.Suffix != nil { + metadata["suffix"] = *params.Suffix + } + if len(params.ExtraParams) > 0 { + maps.Copy(metadata, params.ExtraParams) + } +} + +// extractSpeechParametersToMetadata extracts Speech parameters into metadata map +func (plugin *Plugin) extractSpeechParametersToMetadata(params *schemas.SpeechParameters, metadata map[string]interface{}) { + if params == nil { + return + } + + if params.Speed != nil { + metadata["speed"] = *params.Speed + } + if params.ResponseFormat != "" { + metadata["response_format"] = params.ResponseFormat + } + if params.Instructions != "" { + metadata["instructions"] = params.Instructions + } + // Check if VoiceConfig.Voice is non-nil before accessing it + if params.VoiceConfig.Voice != nil { + metadata["voice"] = *params.VoiceConfig.Voice + } + if len(params.VoiceConfig.MultiVoiceConfig) > 0 { + flattenedVC := make([]string, len(params.VoiceConfig.MultiVoiceConfig)) + for i, vc := range params.VoiceConfig.MultiVoiceConfig { + flattenedVC[i] = fmt.Sprintf("%s:%s", vc.Speaker, vc.Voice) + } + metadata["multi_voice_count"] = flattenedVC + } + if len(params.ExtraParams) > 0 { + maps.Copy(metadata, params.ExtraParams) + } +} + +// extractEmbeddingParametersToMetadata extracts Embedding parameters into metadata map +func (plugin *Plugin) extractEmbeddingParametersToMetadata(params *schemas.EmbeddingParameters, metadata map[string]interface{}) { + if params.EncodingFormat != nil { + metadata["encoding_format"] = *params.EncodingFormat + } + if params.Dimensions != nil { + metadata["dimensions"] = *params.Dimensions + } + if len(params.ExtraParams) > 0 { + maps.Copy(metadata, params.ExtraParams) + } +} + +// extractTranscriptionParametersToMetadata extracts Transcription parameters into metadata map +func (plugin *Plugin) extractTranscriptionParametersToMetadata(params *schemas.TranscriptionParameters, metadata map[string]interface{}) { + if params.Language != nil { + metadata["language"] = *params.Language + } + if params.ResponseFormat != nil { + metadata["response_format"] = *params.ResponseFormat + } + if params.Prompt != nil { + metadata["prompt"] = *params.Prompt + } + if params.Format != nil { + metadata["file_format"] = *params.Format + } + if len(params.ExtraParams) > 0 { + maps.Copy(metadata, params.ExtraParams) + } +} + +func (plugin *Plugin) isConversationHistoryThresholdExceeded(req *schemas.BifrostRequest) bool { + switch { + case req.ChatRequest != nil: + input, ok := plugin.getInputForCaching(req).([]schemas.ChatMessage) + if !ok { + return false + } + if len(input) > plugin.config.ConversationHistoryThreshold { + return true + } + return false + case req.ResponsesRequest != nil: + input, ok := plugin.getInputForCaching(req).([]schemas.ResponsesMessage) + if !ok { + return false + } + if len(input) > plugin.config.ConversationHistoryThreshold { + return true + } + return false + default: + return false + } +} diff --git a/plugins/semanticcache/version b/plugins/semanticcache/version new file mode 100644 index 000000000..f23616f6c --- /dev/null +++ b/plugins/semanticcache/version @@ -0,0 +1 @@ +1.3.27 \ No newline at end of file diff --git a/plugins/telemetry/changelog.md b/plugins/telemetry/changelog.md new file mode 100644 index 000000000..9f57f38b6 --- /dev/null +++ b/plugins/telemetry/changelog.md @@ -0,0 +1 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 diff --git a/plugins/telemetry/docker-compose.yml b/plugins/telemetry/docker-compose.yml new file mode 100644 index 000000000..26ebdad61 --- /dev/null +++ b/plugins/telemetry/docker-compose.yml @@ -0,0 +1,29 @@ +# Prometheus and Grafana for tracking bifrost-http service (for development and testing purposes only, don't use in production without proper setup) +services: + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" # Expose Prometheus web UI + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml # Prometheus config file + restart: always + networks: + - bifrost_tracking_network + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" # Expose Grafana web UI + depends_on: + - prometheus + environment: + GF_SECURITY_ADMIN_PASSWORD: "admin" # Default admin password for Grafana + restart: always + networks: + - bifrost_tracking_network + +networks: + bifrost_tracking_network: + driver: bridge diff --git a/plugins/telemetry/go.mod b/plugins/telemetry/go.mod new file mode 100644 index 000000000..bb3c7feb0 --- /dev/null +++ b/plugins/telemetry/go.mod @@ -0,0 +1,115 @@ +module github.com/maximhq/bifrost/plugins/telemetry + +go 1.24.0 + +toolchain go1.24.3 + +require ( + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 + github.com/prometheus/client_golang v1.23.0 + github.com/valyala/fasthttp v1.67.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/plugins/telemetry/go.sum b/plugins/telemetry/go.sum new file mode 100644 index 000000000..17ec3368c --- /dev/null +++ b/plugins/telemetry/go.sum @@ -0,0 +1,269 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/plugins/telemetry/main.go b/plugins/telemetry/main.go new file mode 100644 index 000000000..3a2bc84ea --- /dev/null +++ b/plugins/telemetry/main.go @@ -0,0 +1,488 @@ +// Package telemetry provides Prometheus metrics collection and monitoring functionality +// for the Bifrost HTTP service. It includes middleware for HTTP request tracking +// and a plugin for tracking upstream provider metrics. +package telemetry + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + bifrost "github.com/maximhq/bifrost/core" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/modelcatalog" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/valyala/fasthttp" +) + +const ( + PluginName = "telemetry" +) + +const ( + startTimeKey schemas.BifrostContextKey = "bf-prom-start-time" +) + +// PrometheusPlugin implements the schemas.Plugin interface for Prometheus metrics. +// It tracks metrics for upstream provider requests, including: +// - Total number of requests +// - Request latency +// - Error counts +type PrometheusPlugin struct { + pricingManager *modelcatalog.ModelCatalog + registry *prometheus.Registry + + // Built-in collectors registered by this plugin + GoCollector prometheus.Collector + ProcessCollector prometheus.Collector + + // Metrics are defined using promauto for automatic registration + HTTPRequestsTotal *prometheus.CounterVec + HTTPRequestDuration *prometheus.HistogramVec + HTTPRequestSizeBytes *prometheus.HistogramVec + HTTPResponseSizeBytes *prometheus.HistogramVec + UpstreamRequestsTotal *prometheus.CounterVec + UpstreamLatencySeconds *prometheus.HistogramVec + SuccessRequestsTotal *prometheus.CounterVec + ErrorRequestsTotal *prometheus.CounterVec + InputTokensTotal *prometheus.CounterVec + OutputTokensTotal *prometheus.CounterVec + CacheHitsTotal *prometheus.CounterVec + CostTotal *prometheus.CounterVec + StreamInterTokenLatencySeconds *prometheus.HistogramVec + StreamFirstTokenLatencySeconds *prometheus.HistogramVec + customLabels []string + + defaultHTTPLabels []string + defaultBifrostLabels []string +} + +type Config struct { + CustomLabels []string `json:"custom_labels"` +} + +// Init creates a new PrometheusPlugin with initialized metrics. +func Init(config *Config, pricingManager *modelcatalog.ModelCatalog, logger schemas.Logger) (*PrometheusPlugin, error) { + if config == nil { + return nil, fmt.Errorf("config is required") + } + + if pricingManager == nil { + logger.Warn("telemetry plugin requires model catalog to calculate cost, all cost calculations will be skipped.") + } + + registry := prometheus.NewRegistry() + + // Create collectors and store references for cleanup + goCollector := collectors.NewGoCollector() + if err := registry.Register(goCollector); err != nil { + return nil, fmt.Errorf("failed to register Go collector: %v", err) + } + + processCollector := collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}) + if err := registry.Register(processCollector); err != nil { + return nil, fmt.Errorf("failed to register process collector: %v", err) + } + + defaultHTTPLabels := []string{"path", "method", "status"} + defaultBifrostLabels := []string{ + "provider", + "model", + "method", + "virtual_key_id", + "virtual_key_name", + "selected_key_id", + "selected_key_name", + "number_of_retries", + "fallback_index", + } + + factory := promauto.With(registry) + + // Upstream LLM latency buckets - extended range for AI model inference times + upstreamLatencyBuckets := []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 15, 30, 45, 60, 90} // in seconds + + httpRequestsTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests.", + }, + append(defaultHTTPLabels, config.CustomLabels...), + ) + + // httpRequestDuration tracks the duration of HTTP requests + httpRequestDuration := factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "Duration of HTTP requests.", + Buckets: prometheus.DefBuckets, + }, + append(defaultHTTPLabels, config.CustomLabels...), + ) + + // httpRequestSizeBytes tracks the size of incoming HTTP requests + httpRequestSizeBytes := factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_size_bytes", + Help: "Size of HTTP requests.", + Buckets: prometheus.ExponentialBuckets(100, 10, 8), // 100B to 1GB + }, + append(defaultHTTPLabels, config.CustomLabels...), + ) + + // httpResponseSizeBytes tracks the size of outgoing HTTP responses + httpResponseSizeBytes := factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_response_size_bytes", + Help: "Size of HTTP responses.", + Buckets: prometheus.ExponentialBuckets(100, 10, 8), // 100B to 1GB + }, + append(defaultHTTPLabels, config.CustomLabels...), + ) + + // Bifrost Upstream Metrics + bifrostUpstreamRequestsTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_upstream_requests_total", + Help: "Total number of requests forwarded to upstream providers by Bifrost.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + bifrostUpstreamLatencySeconds := factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "bifrost_upstream_latency_seconds", + Help: "Latency of requests forwarded to upstream providers by Bifrost.", + Buckets: upstreamLatencyBuckets, // Extended range for AI model inference times + }, + append(append(defaultBifrostLabels, "is_success"), config.CustomLabels...), + ) + + bifrostSuccessRequestsTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_success_requests_total", + Help: "Total number of successful requests forwarded to upstream providers by Bifrost.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + bifrostErrorRequestsTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_error_requests_total", + Help: "Total number of error requests forwarded to upstream providers by Bifrost.", + }, + append(append(defaultBifrostLabels, "reason"), config.CustomLabels...), + ) + + bifrostInputTokensTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_input_tokens_total", + Help: "Total number of input tokens forwarded to upstream providers by Bifrost.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + bifrostOutputTokensTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_output_tokens_total", + Help: "Total number of output tokens forwarded to upstream providers by Bifrost.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + bifrostCacheHitsTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_cache_hits_total", + Help: "Total number of cache hits forwarded to upstream providers by Bifrost, separated by cache type (direct/semantic).", + }, + append(append(defaultBifrostLabels, "cache_type"), config.CustomLabels...), + ) + + bifrostCostTotal := factory.NewCounterVec( + prometheus.CounterOpts{ + Name: "bifrost_cost_total", + Help: "Total cost in USD for requests to upstream providers.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + bifrostStreamInterTokenLatencySeconds := factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "bifrost_stream_inter_token_latency_seconds", + Help: "Latency of the intermediate tokens of a stream response.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + bifrostStreamFirstTokenLatencySeconds := factory.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "bifrost_stream_first_token_latency_seconds", + Help: "Latency of the first token of a stream response.", + }, + append(defaultBifrostLabels, config.CustomLabels...), + ) + + return &PrometheusPlugin{ + pricingManager: pricingManager, + registry: registry, + GoCollector: goCollector, + ProcessCollector: processCollector, + HTTPRequestsTotal: httpRequestsTotal, + HTTPRequestDuration: httpRequestDuration, + HTTPRequestSizeBytes: httpRequestSizeBytes, + HTTPResponseSizeBytes: httpResponseSizeBytes, + UpstreamRequestsTotal: bifrostUpstreamRequestsTotal, + UpstreamLatencySeconds: bifrostUpstreamLatencySeconds, + SuccessRequestsTotal: bifrostSuccessRequestsTotal, + ErrorRequestsTotal: bifrostErrorRequestsTotal, + InputTokensTotal: bifrostInputTokensTotal, + OutputTokensTotal: bifrostOutputTokensTotal, + CacheHitsTotal: bifrostCacheHitsTotal, + CostTotal: bifrostCostTotal, + StreamInterTokenLatencySeconds: bifrostStreamInterTokenLatencySeconds, + StreamFirstTokenLatencySeconds: bifrostStreamFirstTokenLatencySeconds, + customLabels: config.CustomLabels, + defaultHTTPLabels: defaultHTTPLabels, + defaultBifrostLabels: defaultBifrostLabels, + }, nil +} + +func (p *PrometheusPlugin) GetRegistry() *prometheus.Registry { + return p.registry +} + +// GetName returns the name of the plugin. +func (p *PrometheusPlugin) GetName() string { + return PluginName +} + +// TransportInterceptor is not used for this plugin +func (p *PrometheusPlugin) TransportInterceptor(ctx *context.Context, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { + return headers, body, nil +} + +// PreHook records the start time of the request in the context. +// This time is used later in PostHook to calculate request duration. +func (p *PrometheusPlugin) PreHook(ctx *context.Context, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.PluginShortCircuit, error) { + *ctx = context.WithValue(*ctx, startTimeKey, time.Now()) + + return req, nil, nil +} + +// PostHook calculates duration and records upstream metrics for successful requests. +// It records: +// - Request latency +// - Total request count +func (p *PrometheusPlugin) PostHook(ctx *context.Context, result *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { + requestType, provider, model := bifrost.GetResponseFields(result, bifrostErr) + + startTime, ok := (*ctx).Value(startTimeKey).(time.Time) + if !ok { + log.Println("Warning: startTime not found in context for Prometheus PostHook") + return result, bifrostErr, nil + } + + virtualKeyID := getStringFromContext(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-id")) + virtualKeyName := getStringFromContext(*ctx, schemas.BifrostContextKey("bf-governance-virtual-key-name")) + + selectedKeyID := getStringFromContext(*ctx, schemas.BifrostContextKeySelectedKeyID) + selectedKeyName := getStringFromContext(*ctx, schemas.BifrostContextKeySelectedKeyName) + + numberOfRetries := getIntFromContext(*ctx, schemas.BifrostContextKeyNumberOfRetries) + fallbackIndex := getIntFromContext(*ctx, schemas.BifrostContextKeyFallbackIndex) + + // Calculate cost and record metrics in a separate goroutine to avoid blocking the main thread + go func() { + labelValues := map[string]string{ + "provider": string(provider), + "model": model, + "method": string(requestType), + "virtual_key_id": virtualKeyID, + "virtual_key_name": virtualKeyName, + "selected_key_id": selectedKeyID, + "selected_key_name": selectedKeyName, + "number_of_retries": strconv.Itoa(numberOfRetries), + "fallback_index": strconv.Itoa(fallbackIndex), + } + + // Get all prometheus labels from context + for _, key := range p.customLabels { + if value := (*ctx).Value(schemas.BifrostContextKey(key)); value != nil { + if strValue, ok := value.(string); ok { + labelValues[key] = strValue + } + } + } + + // Get label values in the correct order (cache_type will be handled separately for cache hits) + promLabelValues := getPrometheusLabelValues(append(p.defaultBifrostLabels, p.customLabels...), labelValues) + + // For streaming requests, handle per-token metrics for intermediate chunks + if bifrost.IsStreamRequestType(requestType) { + // Determine if this is the final chunk + streamEndIndicatorValue := (*ctx).Value(schemas.BifrostContextKeyStreamEndIndicator) + isFinalChunk, ok := streamEndIndicatorValue.(bool) + + // For intermediate chunks, record per-token metrics and exit. + // The final chunk will fall through to record full request metrics. + if !ok || !isFinalChunk { + // Record metrics for the first token + if result != nil { + extraFields := result.GetExtraFields() + if extraFields.ChunkIndex == 0 { + p.StreamFirstTokenLatencySeconds.WithLabelValues(promLabelValues...).Observe(float64(extraFields.Latency) / 1000.0) + } else { + p.StreamInterTokenLatencySeconds.WithLabelValues(promLabelValues...).Observe(float64(extraFields.Latency) / 1000.0) + } + } + return // Exit goroutine for intermediate chunks + } + } + + cost := 0.0 + if p.pricingManager != nil && result != nil { + cost = p.pricingManager.CalculateCostWithCacheDebug(result) + } + + p.UpstreamRequestsTotal.WithLabelValues(promLabelValues...).Inc() + + // Record latency + duration := time.Since(startTime).Seconds() + latencyLabelValues := make([]string, 0, len(promLabelValues)+1) + latencyLabelValues = append(latencyLabelValues, promLabelValues[:len(p.defaultBifrostLabels)]...) // all default labels + latencyLabelValues = append(latencyLabelValues, strconv.FormatBool(bifrostErr == nil)) // is_success + latencyLabelValues = append(latencyLabelValues, promLabelValues[len(p.defaultBifrostLabels):]...) // then custom labels + p.UpstreamLatencySeconds.WithLabelValues(latencyLabelValues...).Observe(duration) + + // Record cost using the dedicated cost counter + if cost > 0 { + p.CostTotal.WithLabelValues(promLabelValues...).Add(cost) + } + + // Record error and success counts + if bifrostErr != nil { + // Add reason to label values (create new slice to avoid modifying original) + errorPromLabelValues := make([]string, 0, len(promLabelValues)+1) + errorPromLabelValues = append(errorPromLabelValues, promLabelValues[:len(p.defaultBifrostLabels)]...) // all default labels + errorPromLabelValues = append(errorPromLabelValues, bifrostErr.Error.Message) // reason + errorPromLabelValues = append(errorPromLabelValues, promLabelValues[len(p.defaultBifrostLabels):]...) // then custom labels + + p.ErrorRequestsTotal.WithLabelValues(errorPromLabelValues...).Inc() + } else { + p.SuccessRequestsTotal.WithLabelValues(promLabelValues...).Inc() + } + + if result != nil { + // Record input and output tokens + var inputTokens, outputTokens int + + switch { + case result.TextCompletionResponse != nil && result.TextCompletionResponse.Usage != nil: + inputTokens = result.TextCompletionResponse.Usage.PromptTokens + outputTokens = result.TextCompletionResponse.Usage.CompletionTokens + case result.ChatResponse != nil && result.ChatResponse.Usage != nil: + inputTokens = result.ChatResponse.Usage.PromptTokens + outputTokens = result.ChatResponse.Usage.CompletionTokens + case result.ResponsesResponse != nil && result.ResponsesResponse.Usage != nil: + inputTokens = result.ResponsesResponse.Usage.InputTokens + outputTokens = result.ResponsesResponse.Usage.OutputTokens + case result.ResponsesStreamResponse != nil && result.ResponsesStreamResponse.Response != nil && result.ResponsesStreamResponse.Response.Usage != nil: + inputTokens = result.ResponsesStreamResponse.Response.Usage.InputTokens + outputTokens = result.ResponsesStreamResponse.Response.Usage.OutputTokens + case result.EmbeddingResponse != nil && result.EmbeddingResponse.Usage != nil: + inputTokens = result.EmbeddingResponse.Usage.PromptTokens + outputTokens = result.EmbeddingResponse.Usage.CompletionTokens + case result.SpeechStreamResponse != nil && result.SpeechStreamResponse.Usage != nil: + inputTokens = result.SpeechStreamResponse.Usage.InputTokens + outputTokens = result.SpeechStreamResponse.Usage.OutputTokens + case result.TranscriptionResponse != nil && result.TranscriptionResponse.Usage != nil: + if result.TranscriptionResponse.Usage.InputTokens != nil { + inputTokens = *result.TranscriptionResponse.Usage.InputTokens + } + if result.TranscriptionResponse.Usage.OutputTokens != nil { + outputTokens = *result.TranscriptionResponse.Usage.OutputTokens + } + case result.TranscriptionStreamResponse != nil && result.TranscriptionStreamResponse.Usage != nil: + if result.TranscriptionStreamResponse.Usage.InputTokens != nil { + inputTokens = *result.TranscriptionStreamResponse.Usage.InputTokens + } + if result.TranscriptionStreamResponse.Usage.OutputTokens != nil { + outputTokens = *result.TranscriptionStreamResponse.Usage.OutputTokens + } + } + + p.InputTokensTotal.WithLabelValues(promLabelValues...).Add(float64(inputTokens)) + p.OutputTokensTotal.WithLabelValues(promLabelValues...).Add(float64(outputTokens)) + + // Record cache hits with cache type + extraFields := result.GetExtraFields() + if extraFields.CacheDebug != nil && extraFields.CacheDebug.CacheHit { + cacheType := "unknown" + if extraFields.CacheDebug.HitType != nil { + cacheType = *extraFields.CacheDebug.HitType + } + + // Add cache_type to label values (create new slice to avoid modifying original) + cacheHitLabelValues := make([]string, 0, len(promLabelValues)+1) + cacheHitLabelValues = append(cacheHitLabelValues, promLabelValues[:len(p.defaultBifrostLabels)]...) // all default labels + cacheHitLabelValues = append(cacheHitLabelValues, cacheType) // cache_type + cacheHitLabelValues = append(cacheHitLabelValues, promLabelValues[len(p.defaultBifrostLabels):]...) // then custom labels + + p.CacheHitsTotal.WithLabelValues(cacheHitLabelValues...).Inc() + } + } + }() + + return result, bifrostErr, nil +} + +// PrometheusMiddleware wraps a FastHTTP handler to collect Prometheus metrics. +// It tracks: +// - Total number of requests +// - Request duration +// - Request and response sizes +// - HTTP status codes +// - Bifrost upstream requests and errors +func (p *PrometheusPlugin) HTTPMiddleware(handler fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + start := time.Now() + + // Collect request metrics and headers + promKeyValues := collectPrometheusKeyValues(ctx) + reqSize := float64(ctx.Request.Header.ContentLength()) + + // Process the request + handler(ctx) + + // Record metrics after request completion + duration := time.Since(start).Seconds() + status := strconv.Itoa(ctx.Response.StatusCode()) + respSize := float64(ctx.Response.Header.ContentLength()) + + // Add status to the label values + promKeyValues["status"] = status + + // Get label values in the correct order + promLabelValues := getPrometheusLabelValues(append([]string{"path", "method", "status"}, p.customLabels...), promKeyValues) + + // Record all metrics with prometheus labels + p.HTTPRequestsTotal.WithLabelValues(promLabelValues...).Inc() + p.HTTPRequestDuration.WithLabelValues(promLabelValues...).Observe(duration) + if reqSize >= 0 { + safeObserve(p.HTTPRequestSizeBytes, reqSize, promLabelValues...) + } + if respSize >= 0 { + safeObserve(p.HTTPResponseSizeBytes, respSize, promLabelValues...) + } + } +} + +func (p *PrometheusPlugin) Cleanup() error { + // No-op. With a local registry, there's no need to unregister metrics. + // The registry and all its metrics will be garbage collected with the plugin instance. + return nil +} diff --git a/plugins/telemetry/prometheus.yml b/plugins/telemetry/prometheus.yml new file mode 100644 index 000000000..6682b021f --- /dev/null +++ b/plugins/telemetry/prometheus.yml @@ -0,0 +1,15 @@ +# Prometheus configuration for tracking bifrost-http service (for development and testing purposes only, don't use in production without proper setup) +global: + scrape_interval: 5s # Scrape every 5 seconds + +# Note: Target configuration depends on your deployment environment: +# - For local development: Use "host.docker.internal:8080" to access the service running on your host machine +# - For Docker deployment: Use "bifrost-api:8080" to access the service within the Docker network +# Make sure to replace "bifrost-api" and "8080" with your actual docker container name and port if different +# Also check that you have the bifrost container inside "bifrost_tracking_network". + +scrape_configs: + - job_name: "bifrost-api" + static_configs: + - targets: ["host.docker.internal:8080"] # Scrape from the /metrics endpoint + diff --git a/plugins/telemetry/sample-grafana-dashboard.json b/plugins/telemetry/sample-grafana-dashboard.json new file mode 100644 index 000000000..b8ed1b63c --- /dev/null +++ b/plugins/telemetry/sample-grafana-dashboard.json @@ -0,0 +1,573 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "sum(bifrost_cost_total{provider=\"openai\"})", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Total Cost", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "sum(rate(bifrost_success_requests_total{provider=\"openai\"}[1m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Success RPM", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "sum(rate(bifrost_error_requests_total[1m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Error RPM", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "editorMode": "builder", + "expr": "sum(bifrost_input_tokens_total{provider=\"openai\"})", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Total Input Tokens", + "transparent": true, + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "sum(bifrost_output_tokens_total{provider=\"openai\"})", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Total Output Tokens", + "transparent": true, + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "stacking": { + "group": "A", + "mode": "none" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 15, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "bifrost_stream_first_token_latency_seconds_bucket{provider=\"openai\"}", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "First Token Latency", + "type": "histogram" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ef10c25mgln28c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "stacking": { + "group": "A", + "mode": "none" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 37 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "bifrost_stream_inter_token_latency_seconds_bucket{provider=\"openai\"}", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Inter Token Latency", + "type": "histogram" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Test Dashboard", + "uid": "adpbvp4", + "version": 18 + } \ No newline at end of file diff --git a/plugins/telemetry/utils.go b/plugins/telemetry/utils.go new file mode 100644 index 000000000..21b7f9735 --- /dev/null +++ b/plugins/telemetry/utils.go @@ -0,0 +1,89 @@ +// Package telemetry provides Prometheus metrics collection and monitoring functionality +// for the Bifrost HTTP service. This file contains the setup and configuration +// for Prometheus metrics collection, including HTTP middleware and metric definitions. +package telemetry + +import ( + "context" + "log" + "math" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "github.com/valyala/fasthttp" +) + +// getPrometheusLabelValues takes an array of expected label keys and a map of header values, +// and returns an array of values in the same order as the keys, using empty string for missing values. +func getPrometheusLabelValues(expectedLabels []string, headerValues map[string]string) []string { + values := make([]string, len(expectedLabels)) + for i, label := range expectedLabels { + if value, exists := headerValues[label]; exists { + values[i] = value + } else { + values[i] = "" // Default empty value for missing labels + } + } + return values +} + +// collectPrometheusKeyValues collects all metrics for a request including: +// - Default metrics (path, method, status, request size) +// - Custom prometheus headers (x-bf-prom-*) +// Returns a map of all label values +func collectPrometheusKeyValues(ctx *fasthttp.RequestCtx) map[string]string { + path := string(ctx.Path()) + method := string(ctx.Method()) + + // Initialize with default metrics + labelValues := map[string]string{ + "path": path, + "method": method, + } + + // Collect custom prometheus headers + ctx.Request.Header.All()(func(key, value []byte) bool { + keyStr := strings.ToLower(string(key)) + if strings.HasPrefix(keyStr, "x-bf-prom-") { + labelName := strings.TrimPrefix(keyStr, "x-bf-prom-") + labelValues[labelName] = string(value) + ctx.SetUserValue(keyStr, string(value)) + } + return true + }) + + return labelValues +} + +// safeObserve safely records a value in a Prometheus histogram. +// It prevents recording invalid values (negative or infinite) that could cause issues. +func safeObserve(histogram *prometheus.HistogramVec, value float64, labels ...string) { + if value > 0 && value < math.MaxFloat64 { + metric, err := histogram.GetMetricWithLabelValues(labels...) + if err != nil { + log.Printf("Error getting metric with label values: %v", err) + } else { + metric.Observe(value) + } + } +} + +// getStringFromContext safely extracts a string value from context +func getStringFromContext(ctx context.Context, key any) string { + if value := ctx.Value(key); value != nil { + if str, ok := value.(string); ok { + return str + } + } + return "" +} + +// getIntFromContext safely extracts an int value from context +func getIntFromContext(ctx context.Context, key any) int { + if value := ctx.Value(key); value != nil { + if intValue, ok := value.(int); ok { + return intValue + } + } + return 0 +} diff --git a/plugins/telemetry/version b/plugins/telemetry/version new file mode 100644 index 000000000..f23616f6c --- /dev/null +++ b/plugins/telemetry/version @@ -0,0 +1 @@ +1.3.27 \ No newline at end of file diff --git a/recipes/ecs.mk b/recipes/ecs.mk new file mode 100644 index 000000000..09081187e --- /dev/null +++ b/recipes/ecs.mk @@ -0,0 +1,651 @@ +# AWS ECS Deployment Recipe +# Include this in your main Makefile with: include recipes/ecs.mk + +# Configuration variables +ECS_CLUSTER_NAME ?= bifrost-cluster +ECS_SERVICE_NAME ?= bifrost-service +ECS_TASK_FAMILY ?= bifrost-task +IMAGE_TAG ?= latest +LAUNCH_TYPE ?= FARGATE +SECRET_BACKEND ?= secretsmanager +AWS_REGION ?= us-east-1 +VPC_ID ?= +SUBNET_IDS ?= +SECURITY_GROUP_IDS ?= +TARGET_GROUP_ARN ?= +CONTAINER_PORT ?= 8080 +SECRET_NAME ?= bifrost/config +SECRET_ARN ?= +EXECUTION_ROLE_ARN ?= +TASK_ROLE_ARN ?= + +# Configuration JSON file path (optional - provide path to your config.json file) +# Example: CONFIG_JSON_FILE=/path/to/config.json +CONFIG_JSON_FILE ?= + +.PHONY: deploy-ecs list-ecs-network-resources check-ecs-prerequisites create-ecs-secret register-ecs-task-definition create-ecs-service update-ecs-service tail-ecs-logs ecs-status get-ecs-url cleanup-ecs + +deploy-ecs: ## Deploy Bifrost to ECS (Usage: make deploy-ecs SUBNET_IDS='...' SECURITY_GROUP_IDS='...' [CONFIG_JSON_FILE='...']) + @echo "$(BLUE)Starting ECS deployment...$(NC)" + @echo "" + @$(MAKE) check-ecs-prerequisites + @if [ -n "$(CONFIG_JSON_FILE)" ]; then \ + $(MAKE) create-ecs-secret; \ + else \ + echo "$(YELLOW)No CONFIG_JSON_FILE provided, skipping secret creation$(NC)"; \ + fi + @$(MAKE) register-ecs-task-definition + @$(MAKE) create-ecs-service + @echo "" + @echo "$(GREEN)βœ“ ECS deployment complete!$(NC)" + +list-ecs-network-resources: ## List available VPCs, subnets and security groups for ECS deployment + @echo "$(BLUE)Listing available network resources in region $(AWS_REGION)...$(NC)" + @echo "" + @# Check if AWS CLI is configured + @aws sts get-caller-identity > /dev/null 2>&1 || \ + (echo "$(RED)Error: AWS CLI is not configured.$(NC)" && \ + echo "$(YELLOW)Run: aws configure$(NC)" && exit 1) + @echo "$(CYAN)Available VPCs:$(NC)" + @aws ec2 describe-vpcs \ + --region $(AWS_REGION) \ + --query 'Vpcs[*].[VpcId,CidrBlock,IsDefault,Tags[?Key==`Name`].Value|[0]]' \ + --output table + @echo "" + @echo "$(CYAN)Available Subnets:$(NC)" + @aws ec2 describe-subnets \ + --region $(AWS_REGION) \ + --query 'Subnets[*].[SubnetId,AvailabilityZone,VpcId,CidrBlock,Tags[?Key==`Name`].Value|[0]]' \ + --output table + @echo "" + @echo "$(CYAN)Available Security Groups:$(NC)" + @aws ec2 describe-security-groups \ + --region $(AWS_REGION) \ + --query 'SecurityGroups[*].[GroupId,GroupName,VpcId,Description]' \ + --output table + @echo "" + @echo "$(YELLOW)Usage (Option 1 - Recommended):$(NC)" + @echo " Use VPC ID to auto-fetch all subnets:" + @echo " $(GREEN)make deploy-ecs VPC_ID='vpc-xxx' SECURITY_GROUP_IDS='sg-xxx'$(NC)" + @echo "" + @echo "$(YELLOW)Usage (Option 2):$(NC)" + @echo " Specify subnet IDs manually:" + @echo " $(GREEN)make deploy-ecs SUBNET_IDS='subnet-xxx,subnet-yyy' SECURITY_GROUP_IDS='sg-xxx'$(NC)" + +check-ecs-prerequisites: ## Check ECS deployment prerequisites + @echo "$(YELLOW)Checking prerequisites...$(NC)" + @# Check if AWS CLI is installed + @which aws > /dev/null || (echo "$(RED)Error: AWS CLI is not installed.$(NC)" && \ + echo "$(YELLOW)Please install AWS CLI first.$(NC)" && \ + echo "$(CYAN)Documentation: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html$(NC)" && \ + exit 1) + @echo "$(GREEN)βœ“ AWS CLI is installed$(NC)" + @# Check if AWS CLI is configured + @aws sts get-caller-identity > /dev/null 2>&1 || \ + (echo "$(RED)Error: AWS CLI is not configured.$(NC)" && \ + echo "$(YELLOW)Please configure AWS CLI with your credentials.$(NC)" && \ + echo "$(CYAN)Run: aws configure$(NC)" && \ + echo "$(CYAN)Documentation: https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html$(NC)" && \ + exit 1) + @echo "$(GREEN)βœ“ AWS CLI is configured$(NC)" + @# Check if cluster exists + @aws ecs describe-clusters --clusters $(ECS_CLUSTER_NAME) --region $(AWS_REGION) > /dev/null 2>&1 || \ + (echo "$(RED)Error: ECS cluster '$(ECS_CLUSTER_NAME)' not found$(NC)" && exit 1) + @echo "$(GREEN)βœ“ ECS cluster '$(ECS_CLUSTER_NAME)' exists$(NC)" + @# Check/fetch execution role ARN if not provided and using Fargate + @if [ -z "$(EXECUTION_ROLE_ARN)" ]; then \ + echo "$(CYAN)No EXECUTION_ROLE_ARN provided, checking for default role...$(NC)"; \ + ACCOUNT_ID=$$(aws sts get-caller-identity --query Account --output text 2>/dev/null); \ + DEFAULT_ROLE_ARN="arn:aws:iam::$$ACCOUNT_ID:role/ecsTaskExecutionRole"; \ + if aws iam get-role --role-name ecsTaskExecutionRole --region $(AWS_REGION) > /dev/null 2>&1; then \ + echo "$$DEFAULT_ROLE_ARN" > /tmp/ecs-execution-role.tmp; \ + echo "$(GREEN)βœ“ Found default execution role: ecsTaskExecutionRole$(NC)"; \ + else \ + echo ""; \ + echo "$(RED)Error: No execution role found$(NC)"; \ + echo ""; \ + echo "$(YELLOW)ECS tasks require an execution role for CloudWatch logs and pulling images.$(NC)"; \ + echo ""; \ + echo "$(CYAN)Option 1 - Create the default role:$(NC)"; \ + echo " $(GREEN)aws iam create-role --role-name ecsTaskExecutionRole \\"; \ + echo " --assume-role-policy-document '{"; \ + echo " \"Version\": \"2012-10-17\","; \ + echo " \"Statement\": [{"; \ + echo " \"Effect\": \"Allow\","; \ + echo " \"Principal\": {\"Service\": \"ecs-tasks.amazonaws.com\"},"; \ + echo " \"Action\": \"sts:AssumeRole\""; \ + echo " }]"; \ + echo " }'$(NC)"; \ + echo ""; \ + echo " $(GREEN)aws iam attach-role-policy --role-name ecsTaskExecutionRole \\"; \ + echo " --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy$(NC)"; \ + echo ""; \ + echo "$(CYAN)Option 2 - Specify an existing role:$(NC)"; \ + echo " $(GREEN)make deploy-ecs EXECUTION_ROLE_ARN='arn:aws:iam::ACCOUNT:role/YOUR_ROLE' ...$(NC)"; \ + echo ""; \ + exit 1; \ + fi; \ + else \ + echo "$(GREEN)βœ“ Using provided execution role$(NC)"; \ + fi + @# Fetch subnets from VPC if VPC_ID is provided but SUBNET_IDS is not + @if [ -n "$(VPC_ID)" ] && [ -z "$(SUBNET_IDS)" ]; then \ + echo "$(CYAN)Fetching subnets from VPC $(VPC_ID)...$(NC)"; \ + FETCHED_SUBNETS=$$(aws ec2 describe-subnets \ + --region $(AWS_REGION) \ + --filters "Name=vpc-id,Values=$(VPC_ID)" \ + --query 'Subnets[*].SubnetId' \ + --output text 2>/dev/null | tr '\t' ','); \ + if [ -z "$$FETCHED_SUBNETS" ]; then \ + echo "$(RED)Error: No subnets found in VPC $(VPC_ID)$(NC)"; \ + exit 1; \ + fi; \ + echo "$$FETCHED_SUBNETS" > /tmp/ecs-subnets.tmp; \ + echo "$(GREEN)βœ“ Found subnets: $$FETCHED_SUBNETS$(NC)"; \ + fi + @# Check if required network parameters are provided + @if [ -z "$(SUBNET_IDS)" ] && [ ! -f /tmp/ecs-subnets.tmp ] && [ -z "$(VPC_ID)" ]; then \ + echo ""; \ + echo "$(RED)Error: Network configuration is required$(NC)"; \ + echo ""; \ + echo "$(YELLOW)You must provide either:$(NC)"; \ + echo " - VPC_ID (will auto-fetch all subnets in VPC)"; \ + echo " - SUBNET_IDS (specific subnet IDs)"; \ + echo ""; \ + echo "$(CYAN)To list available VPCs and network resources, run:$(NC)"; \ + echo " $(GREEN)make list-ecs-network-resources$(NC)"; \ + echo ""; \ + echo "$(CYAN)Then deploy with VPC ID (recommended):$(NC)"; \ + echo " $(GREEN)make deploy-ecs VPC_ID='vpc-xxx' SECURITY_GROUP_IDS='sg-xxx'$(NC)"; \ + echo ""; \ + echo "$(CYAN)Or deploy with specific subnet IDs:$(NC)"; \ + echo " $(GREEN)make deploy-ecs SUBNET_IDS='subnet-xxx,subnet-yyy' SECURITY_GROUP_IDS='sg-xxx'$(NC)"; \ + echo ""; \ + exit 1; \ + fi + @if [ -z "$(SECURITY_GROUP_IDS)" ]; then \ + echo ""; \ + echo "$(RED)Error: SECURITY_GROUP_IDS is required$(NC)"; \ + echo ""; \ + echo "$(CYAN)To list available security groups, run:$(NC)"; \ + echo " $(GREEN)make list-ecs-network-resources$(NC)"; \ + echo ""; \ + exit 1; \ + fi + @echo "$(GREEN)βœ“ Network configuration ready$(NC)" + +create-ecs-secret: ## Create configuration secret in AWS (Secrets Manager or SSM) + @if [ -z "$(CONFIG_JSON_FILE)" ]; then \ + echo "$(RED)Error: CONFIG_JSON_FILE is required for secret creation$(NC)"; \ + echo "$(YELLOW)Provide CONFIG_JSON_FILE as path to your Bifrost config.json file$(NC)"; \ + exit 1; \ + fi + @if [ ! -f "$(CONFIG_JSON_FILE)" ]; then \ + echo "$(RED)Error: Config file not found: $(CONFIG_JSON_FILE)$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Creating configuration secret...$(NC)" + @echo "$(CYAN)Reading config from: $(CONFIG_JSON_FILE)$(NC)" + @if [ "$(SECRET_BACKEND)" = "secretsmanager" ]; then \ + echo "$(CYAN)Using AWS Secrets Manager...$(NC)"; \ + aws secretsmanager describe-secret --secret-id $(SECRET_NAME) --region $(AWS_REGION) > /dev/null 2>&1 && \ + (echo "$(YELLOW)Secret already exists, updating...$(NC)" && \ + aws secretsmanager update-secret \ + --secret-id $(SECRET_NAME) \ + --secret-string file://$(CONFIG_JSON_FILE) \ + --region $(AWS_REGION) > /dev/null) || \ + (echo "$(YELLOW)Creating new secret...$(NC)" && \ + aws secretsmanager create-secret \ + --name $(SECRET_NAME) \ + --secret-string file://$(CONFIG_JSON_FILE) \ + --region $(AWS_REGION) > /dev/null); \ + echo "$(GREEN)βœ“ Secret created/updated in Secrets Manager: $(SECRET_NAME)$(NC)"; \ + elif [ "$(SECRET_BACKEND)" = "ssm" ]; then \ + echo "$(CYAN)Using AWS Systems Manager Parameter Store...$(NC)"; \ + aws ssm put-parameter \ + --name $(SECRET_NAME) \ + --value file://$(CONFIG_JSON_FILE) \ + --type SecureString \ + --overwrite \ + --region $(AWS_REGION) > /dev/null; \ + echo "$(GREEN)βœ“ Parameter created/updated in SSM: $(SECRET_NAME)$(NC)"; \ + else \ + echo "$(RED)Error: SECRET_BACKEND must be 'secretsmanager' or 'ssm'$(NC)"; \ + exit 1; \ + fi + +register-ecs-task-definition: ## Register ECS task definition + @echo "$(YELLOW)Registering ECS task definition...$(NC)" + @echo "$(CYAN)Launch type: $(LAUNCH_TYPE)$(NC)" + @# Create CloudWatch log group if it doesn't exist + @echo "$(CYAN)Ensuring CloudWatch log group exists...$(NC)" + @aws logs create-log-group \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --region $(AWS_REGION) 2>/dev/null || true + @echo "$(GREEN)βœ“ CloudWatch log group ready: /ecs/$(ECS_TASK_FAMILY)$(NC)" + @# Get secret ARN if CONFIG_JSON_FILE was provided + @if [ -n "$(CONFIG_JSON_FILE)" ]; then \ + echo "$(CYAN)Secret backend: $(SECRET_BACKEND)$(NC)"; \ + else \ + echo "$(YELLOW)No CONFIG_JSON_FILE provided, deploying without secret$(NC)"; \ + fi + $(eval SECRET_VALUE_ARN := $(shell \ + if [ -n "$(CONFIG_JSON_FILE)" ]; then \ + if [ "$(SECRET_BACKEND)" = "secretsmanager" ]; then \ + if [ -z "$(SECRET_ARN)" ]; then \ + aws secretsmanager describe-secret --secret-id $(SECRET_NAME) --region $(AWS_REGION) --query 'ARN' --output text 2>/dev/null; \ + else \ + echo "$(SECRET_ARN)"; \ + fi; \ + elif [ "$(SECRET_BACKEND)" = "ssm" ]; then \ + if [ -z "$(SECRET_ARN)" ]; then \ + aws ssm get-parameter --name $(SECRET_NAME) --region $(AWS_REGION) --query 'Parameter.ARN' --output text 2>/dev/null; \ + else \ + echo "$(SECRET_ARN)"; \ + fi; \ + fi; \ + fi)) + @if [ -n "$(CONFIG_JSON_FILE)" ] && [ -z "$(SECRET_VALUE_ARN)" ]; then \ + echo "$(RED)Error: Could not retrieve secret ARN$(NC)"; \ + exit 1; \ + fi + @if [ -n "$(SECRET_VALUE_ARN)" ]; then \ + echo "$(GREEN)βœ“ Secret ARN: $(SECRET_VALUE_ARN)$(NC)"; \ + fi + @# Create task definition JSON using shell script for proper JSON formatting + @TASK_CPU="256"; \ + TASK_MEMORY="512"; \ + if [ "$(LAUNCH_TYPE)" = "FARGATE" ]; then \ + TASK_CPU="512"; \ + TASK_MEMORY="1024"; \ + fi; \ + EXEC_ROLE="$(EXECUTION_ROLE_ARN)"; \ + if [ -z "$$EXEC_ROLE" ] && [ -f /tmp/ecs-execution-role.tmp ]; then \ + EXEC_ROLE=$$(cat /tmp/ecs-execution-role.tmp); \ + fi; \ + { \ + echo '{'; \ + echo ' "family": "$(ECS_TASK_FAMILY)",'; \ + echo ' "networkMode": "awsvpc",'; \ + echo ' "requiresCompatibilities": ["$(LAUNCH_TYPE)"],'; \ + if [ "$(LAUNCH_TYPE)" = "FARGATE" ]; then \ + echo ' "cpu": "'$$TASK_CPU'",'; \ + echo ' "memory": "'$$TASK_MEMORY'",'; \ + fi; \ + if [ -n "$$EXEC_ROLE" ]; then \ + echo ' "executionRoleArn": "'$$EXEC_ROLE'",'; \ + fi; \ + if [ -n "$(TASK_ROLE_ARN)" ]; then \ + echo ' "taskRoleArn": "$(TASK_ROLE_ARN)",'; \ + fi; \ + echo ' "containerDefinitions": [{'; \ + echo ' "name": "bifrost",'; \ + echo ' "image": "maximhq/bifrost:$(IMAGE_TAG)",'; \ + echo ' "essential": true,'; \ + if [ -n "$(SECRET_VALUE_ARN)" ]; then \ + echo ' "entryPoint": ["/bin/sh", "-c"],'; \ + echo ' "command": ["if [ -n \"$$BIFROST_CONFIG\" ]; then echo \"$$BIFROST_CONFIG\" > /app/data/config.json; else echo \"ERROR: BIFROST_CONFIG not set\" >&2 && exit 1; fi && exec /app/docker-entrypoint.sh /app/main"],'; \ + fi; \ + echo ' "portMappings": [{'; \ + echo ' "containerPort": $(CONTAINER_PORT),'; \ + echo ' "protocol": "tcp"'; \ + echo ' }],'; \ + echo ' "environment": [],'; \ + if [ -n "$(SECRET_VALUE_ARN)" ]; then \ + echo ' "secrets": [{'; \ + echo ' "name": "BIFROST_CONFIG",'; \ + echo ' "valueFrom": "$(SECRET_VALUE_ARN)"'; \ + echo ' }],'; \ + fi; \ + echo ' "healthCheck": {'; \ + echo ' "command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:$(CONTAINER_PORT)/health || exit 1"],'; \ + echo ' "interval": 30,'; \ + echo ' "timeout": 5,'; \ + echo ' "retries": 3,'; \ + echo ' "startPeriod": 60'; \ + echo ' },'; \ + echo ' "logConfiguration": {'; \ + echo ' "logDriver": "awslogs",'; \ + echo ' "options": {'; \ + echo ' "awslogs-group": "/ecs/$(ECS_TASK_FAMILY)",'; \ + echo ' "awslogs-region": "$(AWS_REGION)",'; \ + echo ' "awslogs-stream-prefix": "bifrost"'; \ + echo ' }'; \ + echo ' }'; \ + echo ' }]'; \ + echo '}'; \ + } > /tmp/ecs-task-def.json + @# Register task definition + @aws ecs register-task-definition \ + --cli-input-json file:///tmp/ecs-task-def.json \ + --region $(AWS_REGION) > /dev/null + @rm -f /tmp/ecs-task-def.json + @echo "$(GREEN)βœ“ Task definition registered: $(ECS_TASK_FAMILY)$(NC)" + +create-ecs-service: ## Create or update ECS service + @echo "$(YELLOW)Creating/updating ECS service...$(NC)" + @# Get subnet IDs from parameter or temp file + @SUBNETS="$(SUBNET_IDS)"; \ + if [ -z "$$SUBNETS" ] && [ -f /tmp/ecs-subnets.tmp ]; then \ + SUBNETS=$$(cat /tmp/ecs-subnets.tmp); \ + fi; \ + if aws ecs describe-services --cluster $(ECS_CLUSTER_NAME) --services $(ECS_SERVICE_NAME) --region $(AWS_REGION) 2>/dev/null | grep -q "ACTIVE"; then \ + echo "$(YELLOW)Service exists, updating...$(NC)"; \ + $(MAKE) update-ecs-service; \ + else \ + echo "$(YELLOW)Creating new service...$(NC)"; \ + if [ -z "$(TARGET_GROUP_ARN)" ]; then \ + echo "$(CYAN)Creating service without load balancer...$(NC)"; \ + aws ecs create-service \ + --cluster $(ECS_CLUSTER_NAME) \ + --service-name $(ECS_SERVICE_NAME) \ + --task-definition $(ECS_TASK_FAMILY) \ + --desired-count 1 \ + --launch-type $(LAUNCH_TYPE) \ + --network-configuration "awsvpcConfiguration={subnets=[$$SUBNETS],securityGroups=[$(SECURITY_GROUP_IDS)],assignPublicIp=ENABLED}" \ + --region $(AWS_REGION) > /dev/null; \ + else \ + echo "$(CYAN)Creating service with load balancer...$(NC)"; \ + aws ecs create-service \ + --cluster $(ECS_CLUSTER_NAME) \ + --service-name $(ECS_SERVICE_NAME) \ + --task-definition $(ECS_TASK_FAMILY) \ + --desired-count 1 \ + --launch-type $(LAUNCH_TYPE) \ + --network-configuration "awsvpcConfiguration={subnets=[$$SUBNETS],securityGroups=[$(SECURITY_GROUP_IDS)],assignPublicIp=ENABLED}" \ + --load-balancers "targetGroupArn=$(TARGET_GROUP_ARN),containerName=bifrost,containerPort=$(CONTAINER_PORT)" \ + --health-check-grace-period-seconds 60 \ + --region $(AWS_REGION) > /dev/null; \ + fi; \ + echo "$(GREEN)βœ“ Service created: $(ECS_SERVICE_NAME)$(NC)"; \ + rm -f /tmp/ecs-subnets.tmp /tmp/ecs-execution-role.tmp; \ + fi + @echo "" + @echo "$(YELLOW)Waiting for service to stabilize...$(NC)" + @echo "$(CYAN)This may take a few minutes...$(NC)" + @aws ecs wait services-stable \ + --cluster $(ECS_CLUSTER_NAME) \ + --services $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) && \ + echo "$(GREEN)βœ“ Service is stable and running!$(NC)" || \ + (echo "$(RED)βœ— Service failed to stabilize$(NC)" && exit 1) + @echo "" + @echo "$(CYAN)Deployment Status:$(NC)" + @aws ecs describe-services \ + --cluster $(ECS_CLUSTER_NAME) \ + --services $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) \ + --query 'services[0].{Status:status,Running:runningCount,Desired:desiredCount,Pending:pendingCount}' \ + --output table + @echo "" + @echo "$(CYAN)Task Details:$(NC)" + @TASK_ARN=$$(aws ecs list-tasks \ + --cluster $(ECS_CLUSTER_NAME) \ + --service-name $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) \ + --query 'taskArns[0]' \ + --output text 2>/dev/null); \ + if [ -n "$$TASK_ARN" ] && [ "$$TASK_ARN" != "None" ]; then \ + aws ecs describe-tasks \ + --cluster $(ECS_CLUSTER_NAME) \ + --tasks $$TASK_ARN \ + --region $(AWS_REGION) \ + --query 'tasks[0].{TaskARN:taskArn,LastStatus:lastStatus,HealthStatus:healthStatus,StartedAt:startedAt}' \ + --output table; \ + echo ""; \ + if [ -z "$(TARGET_GROUP_ARN)" ]; then \ + echo "$(CYAN)Public IP Address:$(NC)"; \ + ENI_ID=$$(aws ecs describe-tasks \ + --cluster $(ECS_CLUSTER_NAME) \ + --tasks $$TASK_ARN \ + --region $(AWS_REGION) \ + --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' \ + --output text 2>/dev/null); \ + if [ -n "$$ENI_ID" ] && [ "$$ENI_ID" != "None" ]; then \ + PUBLIC_IP=$$(aws ec2 describe-network-interfaces \ + --network-interface-ids $$ENI_ID \ + --region $(AWS_REGION) \ + --query 'NetworkInterfaces[0].Association.PublicIp' \ + --output text 2>/dev/null); \ + if [ -n "$$PUBLIC_IP" ] && [ "$$PUBLIC_IP" != "None" ]; then \ + echo " $$PUBLIC_IP"; \ + echo ""; \ + echo "$(GREEN)βœ“ Service is accessible at: http://$$PUBLIC_IP:$(CONTAINER_PORT)$(NC)"; \ + echo "$(CYAN) Health check: http://$$PUBLIC_IP:$(CONTAINER_PORT)/health$(NC)"; \ + else \ + echo " $(YELLOW)Public IP not yet assigned$(NC)"; \ + fi; \ + else \ + echo " $(YELLOW)Network interface not found$(NC)"; \ + fi; \ + echo ""; \ + fi; \ + echo "$(CYAN)Recent logs (last 20 events):$(NC)"; \ + LOG_STREAM=$$(aws logs describe-log-streams \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --order-by LastEventTime \ + --descending \ + --max-items 1 \ + --region $(AWS_REGION) \ + --query 'logStreams[0].logStreamName' \ + --output text 2>/dev/null); \ + if [ -n "$$LOG_STREAM" ] && [ "$$LOG_STREAM" != "None" ]; then \ + aws logs get-log-events \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --log-stream-name $$LOG_STREAM \ + --limit 20 \ + --region $(AWS_REGION) \ + --query 'events[*].message' \ + --output text 2>/dev/null || echo "$(YELLOW)No logs available yet$(NC)"; \ + else \ + echo "$(YELLOW)No log stream found yet$(NC)"; \ + fi; \ + else \ + echo "$(YELLOW)No tasks running yet$(NC)"; \ + fi + @echo "" + @echo "$(GREEN)To tail logs continuously, run:$(NC)" + @echo " $(CYAN)make tail-ecs-logs$(NC)" + +update-ecs-service: ## Force new deployment of ECS service + @echo "$(YELLOW)Forcing new deployment...$(NC)" + @aws ecs update-service \ + --cluster $(ECS_CLUSTER_NAME) \ + --service $(ECS_SERVICE_NAME) \ + --force-new-deployment \ + --region $(AWS_REGION) > /dev/null + @echo "$(GREEN)βœ“ Service updated with new deployment$(NC)" + +tail-ecs-logs: ## Tail CloudWatch logs for the ECS service (Ctrl+C to exit) + @echo "$(YELLOW)Tailing logs from /ecs/$(ECS_TASK_FAMILY)...$(NC)" + @echo "$(CYAN)Press Ctrl+C to stop$(NC)" + @echo "" + @# Use aws logs tail command (requires AWS CLI v2) + @if aws logs tail --help > /dev/null 2>&1; then \ + aws logs tail /ecs/$(ECS_TASK_FAMILY) \ + --follow \ + --format short \ + --region $(AWS_REGION); \ + else \ + echo "$(YELLOW)AWS CLI v2 'tail' command not available, falling back to polling...$(NC)"; \ + echo ""; \ + LAST_TIMESTAMP=0; \ + while true; do \ + LOG_STREAM=$$(aws logs describe-log-streams \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --order-by LastEventTime \ + --descending \ + --max-items 1 \ + --region $(AWS_REGION) \ + --query 'logStreams[0].logStreamName' \ + --output text 2>/dev/null); \ + if [ -n "$$LOG_STREAM" ] && [ "$$LOG_STREAM" != "None" ]; then \ + if [ $$LAST_TIMESTAMP -eq 0 ]; then \ + EVENTS=$$(aws logs get-log-events \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --log-stream-name $$LOG_STREAM \ + --limit 10 \ + --region $(AWS_REGION) 2>/dev/null); \ + else \ + EVENTS=$$(aws logs get-log-events \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --log-stream-name $$LOG_STREAM \ + --start-time $$LAST_TIMESTAMP \ + --region $(AWS_REGION) 2>/dev/null); \ + fi; \ + if [ -n "$$EVENTS" ]; then \ + echo "$$EVENTS" | jq -r '.events[] | "\(.timestamp | todate) \(.message)"' 2>/dev/null || \ + echo "$$EVENTS" | grep -o '"message":"[^"]*"' | sed 's/"message":"//;s/"$$//'; \ + NEW_TIMESTAMP=$$(echo "$$EVENTS" | jq -r '.events[-1].timestamp // 0' 2>/dev/null); \ + if [ -n "$$NEW_TIMESTAMP" ] && [ "$$NEW_TIMESTAMP" != "0" ] && [ "$$NEW_TIMESTAMP" != "null" ]; then \ + LAST_TIMESTAMP=$$(($$NEW_TIMESTAMP + 1)); \ + fi; \ + fi; \ + fi; \ + sleep 2; \ + done; \ + fi + +ecs-status: ## Show current ECS service status and recent logs + @echo "$(CYAN)Service Status:$(NC)" + @aws ecs describe-services \ + --cluster $(ECS_CLUSTER_NAME) \ + --services $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) \ + --query 'services[0].{Status:status,Running:runningCount,Desired:desiredCount,Pending:pendingCount,Events:events[0:3]}' \ + --output table + @echo "" + @echo "$(CYAN)Running Tasks:$(NC)" + @aws ecs list-tasks \ + --cluster $(ECS_CLUSTER_NAME) \ + --service-name $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) \ + --query 'taskArns[]' \ + --output table + @echo "" + @echo "$(CYAN)Recent Logs:$(NC)" + @LOG_STREAM=$$(aws logs describe-log-streams \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --order-by LastEventTime \ + --descending \ + --max-items 1 \ + --region $(AWS_REGION) \ + --query 'logStreams[0].logStreamName' \ + --output text 2>/dev/null); \ + if [ -n "$$LOG_STREAM" ] && [ "$$LOG_STREAM" != "None" ]; then \ + aws logs get-log-events \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --log-stream-name $$LOG_STREAM \ + --limit 20 \ + --region $(AWS_REGION) \ + --query 'events[*].message' \ + --output text 2>/dev/null; \ + else \ + echo "$(YELLOW)No log stream found$(NC)"; \ + fi + +get-ecs-url: ## Get the public URL/IP of the ECS service + @echo "$(YELLOW)Fetching service URL...$(NC)" + @echo "" + @# Check if service uses load balancer + @LB_ARN=$$(aws ecs describe-services \ + --cluster $(ECS_CLUSTER_NAME) \ + --services $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) \ + --query 'services[0].loadBalancers[0].targetGroupArn' \ + --output text 2>/dev/null); \ + if [ -n "$$LB_ARN" ] && [ "$$LB_ARN" != "None" ]; then \ + echo "$(CYAN)Service is using Application Load Balancer$(NC)"; \ + LB_ARN=$$(aws elbv2 describe-target-groups \ + --target-group-arns $$LB_ARN \ + --region $(AWS_REGION) \ + --query 'TargetGroups[0].LoadBalancerArns[0]' \ + --output text 2>/dev/null); \ + if [ -n "$$LB_ARN" ] && [ "$$LB_ARN" != "None" ]; then \ + LB_DNS=$$(aws elbv2 describe-load-balancers \ + --load-balancer-arns $$LB_ARN \ + --region $(AWS_REGION) \ + --query 'LoadBalancers[0].DNSName' \ + --output text 2>/dev/null); \ + echo ""; \ + echo "$(GREEN)βœ“ Load Balancer URL: http://$$LB_DNS$(NC)"; \ + echo "$(CYAN) Health check: http://$$LB_DNS/health$(NC)"; \ + fi; \ + else \ + echo "$(CYAN)Service is using direct public IP (no load balancer)$(NC)"; \ + TASK_ARN=$$(aws ecs list-tasks \ + --cluster $(ECS_CLUSTER_NAME) \ + --service-name $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) \ + --query 'taskArns[0]' \ + --output text 2>/dev/null); \ + if [ -n "$$TASK_ARN" ] && [ "$$TASK_ARN" != "None" ]; then \ + ENI_ID=$$(aws ecs describe-tasks \ + --cluster $(ECS_CLUSTER_NAME) \ + --tasks $$TASK_ARN \ + --region $(AWS_REGION) \ + --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' \ + --output text 2>/dev/null); \ + if [ -n "$$ENI_ID" ] && [ "$$ENI_ID" != "None" ]; then \ + PUBLIC_IP=$$(aws ec2 describe-network-interfaces \ + --network-interface-ids $$ENI_ID \ + --region $(AWS_REGION) \ + --query 'NetworkInterfaces[0].Association.PublicIp' \ + --output text 2>/dev/null); \ + if [ -n "$$PUBLIC_IP" ] && [ "$$PUBLIC_IP" != "None" ]; then \ + echo ""; \ + echo "$(GREEN)βœ“ Service URL: http://$$PUBLIC_IP:$(CONTAINER_PORT)$(NC)"; \ + echo "$(CYAN) Health check: http://$$PUBLIC_IP:$(CONTAINER_PORT)/health$(NC)"; \ + echo ""; \ + echo "$(YELLOW)Note: Public IP may change if task is restarted. Consider using a load balancer for production.$(NC)"; \ + else \ + echo ""; \ + echo "$(RED)βœ— Public IP not assigned$(NC)"; \ + echo "$(YELLOW)The task may still be starting or the service is not in a VPC with public subnets.$(NC)"; \ + fi; \ + else \ + echo ""; \ + echo "$(RED)βœ— Network interface not found$(NC)"; \ + fi; \ + else \ + echo ""; \ + echo "$(RED)βœ— No running tasks found$(NC)"; \ + echo "$(YELLOW)Check service status with: make ecs-status$(NC)"; \ + fi; \ + fi + @echo "" + +cleanup-ecs: ## Remove ECS service and task definitions + @echo "$(YELLOW)Cleaning up ECS resources...$(NC)" + @# Delete service + @if aws ecs describe-services --cluster $(ECS_CLUSTER_NAME) --services $(ECS_SERVICE_NAME) --region $(AWS_REGION) 2>/dev/null | grep -q "ACTIVE"; then \ + echo "$(YELLOW)Deleting service...$(NC)"; \ + aws ecs update-service \ + --cluster $(ECS_CLUSTER_NAME) \ + --service $(ECS_SERVICE_NAME) \ + --desired-count 0 \ + --region $(AWS_REGION) > /dev/null; \ + aws ecs delete-service \ + --cluster $(ECS_CLUSTER_NAME) \ + --service $(ECS_SERVICE_NAME) \ + --region $(AWS_REGION) > /dev/null; \ + echo "$(GREEN)βœ“ Service deleted$(NC)"; \ + else \ + echo "$(YELLOW)Service does not exist$(NC)"; \ + fi + @# Deregister all task definition revisions + @echo "$(YELLOW)Deregistering task definitions...$(NC)" + @for arn in $$(aws ecs list-task-definitions --family-prefix $(ECS_TASK_FAMILY) --region $(AWS_REGION) --query 'taskDefinitionArns[]' --output text); do \ + aws ecs deregister-task-definition --task-definition $$arn --region $(AWS_REGION) > /dev/null; \ + echo "$(GREEN)βœ“ Deregistered: $$arn$(NC)"; \ + done + @# Delete CloudWatch log group + @echo "$(YELLOW)Deleting CloudWatch log group...$(NC)" + @aws logs delete-log-group \ + --log-group-name /ecs/$(ECS_TASK_FAMILY) \ + --region $(AWS_REGION) 2>/dev/null || echo "$(YELLOW)Log group does not exist$(NC)" + @echo "$(GREEN)βœ“ Log group deleted$(NC)" + @# Clean up temp files + @rm -f /tmp/ecs-subnets.tmp /tmp/ecs-execution-role.tmp /tmp/ecs-task-def.json + @echo "$(GREEN)βœ“ Cleanup complete$(NC)" + diff --git a/recipes/fly.mk b/recipes/fly.mk new file mode 100644 index 000000000..8012f2627 --- /dev/null +++ b/recipes/fly.mk @@ -0,0 +1,96 @@ +# Fly.io Deployment Recipe +# Include this in your main Makefile with: include recipes/fly.mk + +.PHONY: deploy-to-fly-io + +deploy-to-fly-io: ## Deploy to Fly.io (Usage: make deploy-to-fly-io APP_NAME=your-app-name) + @echo "$(BLUE)Starting Fly.io deployment...$(NC)" + @echo "" + @# Check if APP_NAME is provided + @if [ -z "$(APP_NAME)" ]; then \ + echo "$(RED)Error: APP_NAME is required$(NC)"; \ + echo "$(YELLOW)Usage: make deploy-to-fly-io APP_NAME=your-app-name$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Checking prerequisites...$(NC)" + @# Check if docker is installed + @which docker > /dev/null || (echo "$(RED)Error: Docker is not installed. Please install Docker first.$(NC)" && exit 1) + @echo "$(GREEN)βœ“ Docker is installed$(NC)" + @# Check if flyctl is installed + @which flyctl > /dev/null || (echo "$(RED)Error: flyctl is not installed. Please install flyctl first.$(NC)" && exit 1) + @echo "$(GREEN)βœ“ flyctl is installed$(NC)" + @# Check if app exists on Fly.io + @flyctl status -a $(APP_NAME) > /dev/null 2>&1 || (echo "$(RED)Error: App '$(APP_NAME)' not found on Fly.io$(NC)" && echo "$(YELLOW)Create the app first with: flyctl launch --name $(APP_NAME)$(NC)" && exit 1) + @echo "$(GREEN)βœ“ App '$(APP_NAME)' exists on Fly.io$(NC)" + @echo "" + @# Check if fly.toml exists, create temp if needed + @if [ -f "fly.toml" ]; then \ + echo "$(GREEN)βœ“ Using existing fly.toml$(NC)"; \ + else \ + echo "$(YELLOW)fly.toml not found in current directory$(NC)"; \ + echo "$(CYAN)Would you like to create a temporary fly.toml with 2 vCPU configuration?$(NC)"; \ + echo "$(CYAN)(It will be removed after deployment)$(NC)"; \ + printf "Create temporary fly.toml? [y/N]: "; read response; \ + case "$$response" in \ + [yY][eE][sS]|[yY]) \ + echo "$(YELLOW)Creating temporary fly.toml with 2 vCPU configuration...$(NC)"; \ + echo "app = '$(APP_NAME)'" > fly.toml; \ + echo "primary_region = 'iad'" >> fly.toml; \ + echo "" >> fly.toml; \ + echo "[build]" >> fly.toml; \ + echo " image = 'registry.fly.io/$(APP_NAME):latest'" >> fly.toml; \ + echo "" >> fly.toml; \ + echo "[http_service]" >> fly.toml; \ + echo " internal_port = 8080" >> fly.toml; \ + echo " force_https = true" >> fly.toml; \ + echo " auto_stop_machines = true" >> fly.toml; \ + echo " auto_start_machines = true" >> fly.toml; \ + echo " min_machines_running = 0" >> fly.toml; \ + echo "" >> fly.toml; \ + echo "[[vm]]" >> fly.toml; \ + echo " memory = '2gb'" >> fly.toml; \ + echo " cpu_kind = 'shared'" >> fly.toml; \ + echo " cpus = 2" >> fly.toml; \ + echo "$(GREEN)βœ“ Created temporary fly.toml with 2 vCPU configuration$(NC)"; \ + touch .fly.toml.tmp.marker; \ + ;; \ + *) \ + echo "$(RED)Deployment cancelled. Please create a fly.toml file or run 'flyctl launch' first.$(NC)"; \ + exit 1; \ + ;; \ + esac; \ + fi + @echo "" + @echo "$(YELLOW)Building Docker image...$(NC)" + @$(MAKE) build-docker-image + @echo "" + @echo "$(YELLOW)Tagging image for Fly.io registry...$(NC)" + @docker tag bifrost:latest registry.fly.io/$(APP_NAME):latest + $(eval GIT_SHA=$(shell git rev-parse --short HEAD)) + @docker tag bifrost:$(GIT_SHA) registry.fly.io/$(APP_NAME):$(GIT_SHA) + @echo "$(GREEN)βœ“ Tagged: registry.fly.io/$(APP_NAME):latest$(NC)" + @echo "$(GREEN)βœ“ Tagged: registry.fly.io/$(APP_NAME):$(GIT_SHA)$(NC)" + @echo "" + @echo "$(YELLOW)Pushing to Fly.io registry...$(NC)" + @echo "$(YELLOW)Authenticating with Fly.io...$(NC)" + @flyctl auth docker + @echo "$(GREEN)βœ“ Authenticated with Fly.io$(NC)" + @echo "" + @echo "$(YELLOW)Pushing image to Fly.io registry...$(NC)" + @docker push registry.fly.io/$(APP_NAME):latest + @docker push registry.fly.io/$(APP_NAME):$(GIT_SHA) + @echo "$(GREEN)βœ“ Image pushed to registry$(NC)" + @echo "" + @echo "$(YELLOW)Deploying to Fly.io...$(NC)" + @flyctl deploy -a $(APP_NAME) + @echo "" + @echo "$(GREEN)βœ“ Deployment complete!$(NC)" + @echo "$(CYAN)App URL: https://$(APP_NAME).fly.dev$(NC)" + @echo "" + @# Clean up temporary fly.toml if we created it + @if [ -f ".fly.toml.tmp.marker" ]; then \ + echo "$(YELLOW)Cleaning up temporary fly.toml...$(NC)"; \ + rm -f fly.toml .fly.toml.tmp.marker; \ + echo "$(GREEN)βœ“ Temporary fly.toml removed$(NC)"; \ + fi + diff --git a/scheams/go.mod b/scheams/go.mod new file mode 100644 index 000000000..fc9545ae9 --- /dev/null +++ b/scheams/go.mod @@ -0,0 +1 @@ +module github.com/maximhq/bifrost/schemas \ No newline at end of file diff --git a/tests/configs/noconfigstorenologstore/config.json b/tests/configs/noconfigstorenologstore/config.json new file mode 100644 index 000000000..ad3d10774 --- /dev/null +++ b/tests/configs/noconfigstorenologstore/config.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://www.getbifrost.ai/schema" +} \ No newline at end of file diff --git a/tests/configs/withconfigstore/config.json b/tests/configs/withconfigstore/config.json new file mode 100644 index 000000000..77e7a346b --- /dev/null +++ b/tests/configs/withconfigstore/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "config_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../../tests/configs/withconfigstore/config.db" + } + } + +} \ No newline at end of file diff --git a/tests/configs/withconfigstorelogsstorepostgres/config.json b/tests/configs/withconfigstorelogsstorepostgres/config.json new file mode 100644 index 000000000..58da401ac --- /dev/null +++ b/tests/configs/withconfigstorelogsstorepostgres/config.json @@ -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" + } + } +} \ No newline at end of file diff --git a/tests/configs/withconfigstorelogsstoresqlite/config.json b/tests/configs/withconfigstorelogsstoresqlite/config.json new file mode 100644 index 000000000..56912ef4e --- /dev/null +++ b/tests/configs/withconfigstorelogsstoresqlite/config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "config_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../../tests/configs/withconfigstorelogsstore/config.db" + } + }, + "logs_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../../tests/configs/withconfigstorelogsstore/logs.db" + } + } +} \ No newline at end of file diff --git a/tests/configs/withdynamicplugin/config.json b/tests/configs/withdynamicplugin/config.json new file mode 100644 index 000000000..a587611e3 --- /dev/null +++ b/tests/configs/withdynamicplugin/config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "config_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../../tests/configs/withconfigstore/config.db" + } + }, + "plugins": [ + { + "enabled": true, + "name": "hello-world", + "path": "/Users/akshay/Codebase/universe/bifrost/examples/plugins/hello-world/build/hello-world.so" + } + ] +} \ No newline at end of file diff --git a/tests/configs/withobservability/config.json b/tests/configs/withobservability/config.json new file mode 100644 index 000000000..d4fe40ecb --- /dev/null +++ b/tests/configs/withobservability/config.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "config_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../../tests/configs/withobservability/config.db" + } + }, + "logs_store": { + "enabled": true, + "type": "sqlite", + "config": { + "path": "../../tests/configs/withobservability/logs.db" + } + }, + "plugins": [ + { + "enabled": true, + "name": "maxim", + "config": { + "api_key": "", + "log_repo_id": "" + } + }, + { + "enabled": true, + "name": "otel", + "config": { + "collector_url": "http://localhost:4318/v1/traces", + "trace_type": "otel", + "protocol": "http" + } + } + ] + } \ No newline at end of file diff --git a/tests/configs/withsemanticcache/config.json b/tests/configs/withsemanticcache/config.json new file mode 100644 index 000000000..f0775d490 --- /dev/null +++ b/tests/configs/withsemanticcache/config.json @@ -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": { + "ttl": 300, + "threshold": 0.8 + } + } + ] +} \ No newline at end of file diff --git a/tests/core-chatbot/go.mod b/tests/core-chatbot/go.mod new file mode 100644 index 000000000..c2636f619 --- /dev/null +++ b/tests/core-chatbot/go.mod @@ -0,0 +1,55 @@ +module github.com/maximhq/bifrost/tests/core-chatbot + +go 1.24.1 + +toolchain go1.24.3 + +require ( + github.com/maximhq/bifrost/core v1.2.22 + golang.org/x/text v0.30.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tests/core-chatbot/go.sum b/tests/core-chatbot/go.sum new file mode 100644 index 000000000..11cf9a0b8 --- /dev/null +++ b/tests/core-chatbot/go.sum @@ -0,0 +1,129 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/core-chatbot/main.go b/tests/core-chatbot/main.go new file mode 100644 index 000000000..1344c23fc --- /dev/null +++ b/tests/core-chatbot/main.go @@ -0,0 +1,936 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// ChatbotConfig holds configuration for the chatbot +type ChatbotConfig struct { + Provider schemas.ModelProvider + Model string + MCPAgenticMode bool + MCPServerPort int + Temperature *float64 + MaxTokens *int +} + +// ChatSession manages the conversation state +type ChatSession struct { + history []schemas.ChatMessage + client *bifrost.Bifrost + config ChatbotConfig + systemPrompt string + account *ComprehensiveTestAccount +} + +// ComprehensiveTestAccount provides a test implementation of the Account interface for comprehensive testing. +type ComprehensiveTestAccount struct{} + +// getEnvWithDefault returns the value of the environment variable if set, otherwise returns the default value +func getEnvWithDefault(envVar, defaultValue string) string { + if value := os.Getenv(envVar); value != "" { + return value + } + return defaultValue +} + +// GetConfiguredProviders returns the list of initially supported providers. +func (account *ComprehensiveTestAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{ + schemas.OpenAI, + schemas.Anthropic, + schemas.Bedrock, + schemas.Cohere, + schemas.Azure, + schemas.Vertex, + schemas.Ollama, + schemas.Mistral, + }, nil +} + +// GetKeysForProvider returns the API keys and associated models for a given provider. +func (account *ComprehensiveTestAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + switch providerKey { + case schemas.OpenAI: + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{"gpt-4o-mini", "gpt-4-turbo", "gpt-4o"}, + Weight: 1.0, + }, + }, nil + case schemas.Anthropic: + return []schemas.Key{ + { + Value: os.Getenv("ANTHROPIC_API_KEY"), + Models: []string{"claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20240620", "claude-2.1"}, + Weight: 1.0, + }, + }, nil + case schemas.Bedrock: + return []schemas.Key{ + { + Value: os.Getenv("BEDROCK_API_KEY"), + Models: []string{"anthropic.claude-v2:1", "mistral.mixtral-8x7b-instruct-v0:1", "mistral.mistral-large-2402-v1:0", "anthropic.claude-3-sonnet-20240229-v1:0"}, + Weight: 1.0, + }, + }, nil + case schemas.Cohere: + return []schemas.Key{ + { + Value: os.Getenv("COHERE_API_KEY"), + Models: []string{"command-a-03-2025", "c4ai-aya-vision-8b"}, + Weight: 1.0, + }, + }, nil + case schemas.Azure: + return []schemas.Key{ + { + Value: os.Getenv("AZURE_API_KEY"), + Models: []string{"gpt-4o"}, + Weight: 1.0, + }, + }, nil + case schemas.Vertex: + return []schemas.Key{ + { + Value: os.Getenv("VERTEX_API_KEY"), + Models: []string{"gemini-pro", "gemini-1.5-pro"}, + Weight: 1.0, + }, + }, nil + case schemas.Mistral: + return []schemas.Key{ + { + Value: os.Getenv("MISTRAL_API_KEY"), + Models: []string{"mistral-large-2411", "pixtral-12b-latest"}, + Weight: 1.0, + }, + }, nil + case schemas.Ollama: + return []schemas.Key{ + { + Value: "", // Ollama is keyless + Models: []string{"llama3.2", "llama3.1", "mistral", "codellama"}, + Weight: 1.0, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported provider: %s", providerKey) + } +} + +// GetConfigForProvider returns the configuration settings for a given provider. +func (account *ComprehensiveTestAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch providerKey { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 30, + MaxRetries: 1, + RetryBackoffInitial: 100 * time.Millisecond, + RetryBackoffMax: 2 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 3, + BufferSize: 10, + }, + }, nil + case schemas.Anthropic: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + case schemas.Bedrock: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 30, + MaxRetries: 1, + RetryBackoffInitial: 100 * time.Millisecond, + RetryBackoffMax: 2 * time.Second, + }, + // MetaConfig: &meta.BedrockMetaConfig{ // FIXME: meta package doesn't exist + // SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + // Region: bifrost.Ptr(getEnvWithDefault("AWS_REGION", "us-east-1")), + // }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 3, + BufferSize: 10, + }, + }, nil + case schemas.Cohere: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + case schemas.Azure: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 30, + MaxRetries: 1, + RetryBackoffInitial: 100 * time.Millisecond, + RetryBackoffMax: 2 * time.Second, + }, + // MetaConfig: &meta.AzureMetaConfig{ // FIXME: meta package doesn't exist + // Endpoint: os.Getenv("AZURE_ENDPOINT"), + // Deployments: map[string]string{ + // "gpt-4o": "gpt-4o-aug", + // }, + // APIVersion: bifrost.Ptr(getEnvWithDefault("AZURE_API_VERSION", "2024-08-01-preview")), + // }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 3, + BufferSize: 10, + }, + }, nil + case schemas.Vertex: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 30, + MaxRetries: 1, + RetryBackoffInitial: 100 * time.Millisecond, + RetryBackoffMax: 2 * time.Second, + }, + // MetaConfig: &meta.VertexMetaConfig{ // FIXME: meta package doesn't exist + // ProjectID: os.Getenv("VERTEX_PROJECT_ID"), + // Region: getEnvWithDefault("VERTEX_REGION", "us-central1"), + // AuthCredentials: os.Getenv("VERTEX_CREDENTIALS"), + // }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 3, + BufferSize: 10, + }, + }, nil + case schemas.Ollama: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + case schemas.Mistral: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.DefaultNetworkConfig, + ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize, + }, nil + default: + return nil, fmt.Errorf("unsupported provider: %s", providerKey) + } +} + +// NewChatSession creates a new chat session with the given configuration +func NewChatSession(config ChatbotConfig) (*ChatSession, error) { + // Create MCP configuration for Bifrost + mcpConfig := &schemas.MCPConfig{ + ClientConfigs: []schemas.MCPClientConfig{}, + } + + fmt.Println("πŸ”Œ Configuring Serper MCP server...") + mcpConfig.ClientConfigs = append(mcpConfig.ClientConfigs, schemas.MCPClientConfig{ + Name: "serper-web-search-mcp", + ConnectionType: schemas.MCPConnectionTypeSTDIO, + StdioConfig: &schemas.MCPStdioConfig{ + Command: "npx", + Args: []string{"-y", "serper-search-scrape-mcp-server"}, + Envs: []string{"SERPER_API_KEY"}, + }, + }, + schemas.MCPClientConfig{ + Name: "gmail-mcp", + ConnectionType: schemas.MCPConnectionTypeSSE, + ConnectionString: bifrost.Ptr("https://mcp.composio.dev/composio/server/654c1e3f-ea7d-47b6-9e31-398d00449654/sse"), + }, + ) + + fmt.Println("πŸ”Œ Configuring Context7 MCP server...") + mcpConfig.ClientConfigs = append(mcpConfig.ClientConfigs, schemas.MCPClientConfig{ + Name: "context7", + ConnectionType: schemas.MCPConnectionTypeSTDIO, + StdioConfig: &schemas.MCPStdioConfig{ + Command: "npx", + Args: []string{"-y", "@upstash/context7-mcp"}, + }, + }) + + // Initialize Bifrost with MCP configuration + account := &ComprehensiveTestAccount{} + + client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{ + Account: account, + Plugins: []schemas.Plugin{}, // No separate plugins needed - MCP is integrated + Logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo), + MCPConfig: mcpConfig, // MCP is now configured here + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize Bifrost: %w", err) + } + + session := &ChatSession{ + history: make([]schemas.ChatMessage, 0), + client: client, + config: config, + account: account, + systemPrompt: "You are a helpful AI assistant with access to various tools. " + + "Use the available tools when they can help answer the user's questions more accurately or provide additional information.", + } + + // Add system message to history + if session.systemPrompt != "" { + session.history = append(session.history, schemas.ChatMessage{ + Role: schemas.ChatMessageRoleSystem, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr(session.systemPrompt), + }, + }) + } + + return session, nil +} + +// getAvailableProviders returns a list of providers that have valid configurations +func (s *ChatSession) getAvailableProviders() []schemas.ModelProvider { + configuredProviders, err := s.account.GetConfiguredProviders() + if err != nil { + return []schemas.ModelProvider{} + } + + var availableProviders []schemas.ModelProvider + for _, provider := range configuredProviders { + // Check if provider has valid keys (except for keyless providers) + if provider == schemas.Ollama || provider == schemas.Vertex { + availableProviders = append(availableProviders, provider) + continue + } + ctx := context.Background() + keys, err := s.account.GetKeysForProvider(&ctx, provider) + if err == nil && len(keys) > 0 && keys[0].Value != "" { + availableProviders = append(availableProviders, provider) + } + } + return availableProviders +} + +// getAvailableModels returns available models for a given provider +func (s *ChatSession) getAvailableModels(provider schemas.ModelProvider) []string { + ctx := context.Background() + keys, err := s.account.GetKeysForProvider(&ctx, provider) + if err != nil || len(keys) == 0 { + return []string{} + } + return keys[0].Models +} + +// switchProvider handles switching to a different provider +func (s *ChatSession) switchProvider() error { + availableProviders := s.getAvailableProviders() + if len(availableProviders) == 0 { + fmt.Println("❌ No available providers found") + return fmt.Errorf("no available providers") + } + + fmt.Println("\nπŸ”„ Available Providers:") + fmt.Println("======================") + for i, provider := range availableProviders { + status := "" + if provider == s.config.Provider { + status = " (current)" + } + fmt.Printf("[%d] %s%s\n", i+1, provider, status) + } + + fmt.Print("\nSelect provider (number): ") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return fmt.Errorf("input cancelled") + } + + choice, err := strconv.Atoi(strings.TrimSpace(scanner.Text())) + if err != nil || choice < 1 || choice > len(availableProviders) { + return fmt.Errorf("invalid choice") + } + + newProvider := availableProviders[choice-1] + + // Get available models for the new provider + models := s.getAvailableModels(newProvider) + if len(models) == 0 { + return fmt.Errorf("no models available for provider %s", newProvider) + } + + // Auto-select first model or let user choose if multiple + var newModel string + if len(models) == 1 { + newModel = models[0] + } else { + fmt.Printf("\n🧠 Available Models for %s:\n", newProvider) + fmt.Println("================================") + for i, model := range models { + fmt.Printf("[%d] %s\n", i+1, model) + } + + fmt.Print("\nSelect model (number): ") + if !scanner.Scan() { + return fmt.Errorf("input cancelled") + } + + modelChoice, err := strconv.Atoi(strings.TrimSpace(scanner.Text())) + if err != nil || modelChoice < 1 || modelChoice > len(models) { + return fmt.Errorf("invalid model choice") + } + + newModel = models[modelChoice-1] + } + + // Update configuration + s.config.Provider = newProvider + s.config.Model = newModel + + fmt.Printf("βœ… Switched to %s with model %s\n", newProvider, newModel) + return nil +} + +// switchModel handles switching to a different model for the current provider +func (s *ChatSession) switchModel() error { + models := s.getAvailableModels(s.config.Provider) + if len(models) == 0 { + return fmt.Errorf("no models available for provider %s", s.config.Provider) + } + + if len(models) == 1 { + fmt.Printf("Only one model available for %s: %s\n", s.config.Provider, models[0]) + return nil + } + + fmt.Printf("\n🧠 Available Models for %s:\n", s.config.Provider) + fmt.Println("===============================") + for i, model := range models { + status := "" + if model == s.config.Model { + status = " (current)" + } + fmt.Printf("[%d] %s%s\n", i+1, model, status) + } + + fmt.Print("\nSelect model (number): ") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return fmt.Errorf("input cancelled") + } + + choice, err := strconv.Atoi(strings.TrimSpace(scanner.Text())) + if err != nil || choice < 1 || choice > len(models) { + return fmt.Errorf("invalid choice") + } + + newModel := models[choice-1] + s.config.Model = newModel + + fmt.Printf("βœ… Switched to model %s\n", newModel) + return nil +} + +// showCurrentConfig displays the current configuration +func (s *ChatSession) showCurrentConfig() { + fmt.Println("\nβš™οΈ Current Configuration:") + fmt.Println("=========================") + fmt.Printf("πŸ”§ Provider: %s\n", s.config.Provider) + fmt.Printf("🧠 Model: %s\n", s.config.Model) + fmt.Printf("πŸ”„ Agentic Mode: %t\n", s.config.MCPAgenticMode) + fmt.Printf("🌑️ Temperature: %.1f\n", *s.config.Temperature) + fmt.Printf("πŸ“ Max Tokens: %d\n", *s.config.MaxTokens) + fmt.Printf("πŸ”§ Tool Execution: Manual approval required\n") +} + +// AddUserMessage adds a user message to the conversation history +func (s *ChatSession) AddUserMessage(message string) { + userMessage := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr(message), + }, + } + s.history = append(s.history, userMessage) +} + +// SendMessage sends a message and returns the assistant's response +func (s *ChatSession) SendMessage(message string) (string, error) { + // Add user message to history + s.AddUserMessage(message) + + // Prepare model parameters + params := &schemas.ChatParameters{} + if s.config.Temperature != nil { + params.Temperature = s.config.Temperature + } + if s.config.MaxTokens != nil { + params.MaxCompletionTokens = s.config.MaxTokens + } + params.ToolChoice = &schemas.ChatToolChoice{ + ChatToolChoiceStr: bifrost.Ptr("auto"), + } + + // Create request + request := &schemas.BifrostChatRequest{ + Provider: s.config.Provider, + Model: s.config.Model, + Input: s.history, + Params: params, + } + + // Start loading animation + stopChan, wg := startLoader() + + // Send request + response, err := s.client.ChatCompletionRequest(context.Background(), request) + + // Stop loading animation + stopLoader(stopChan, wg) + + if err != nil { + return "", fmt.Errorf("chat completion failed: %s", err.Error.Message) + } + + if response == nil || len(response.Choices) == 0 { + return "", fmt.Errorf("no response received") + } + + // Get the assistant's response + choice := response.Choices[0] + assistantMessage := choice.Message + + // Add assistant message to history + s.history = append(s.history, *assistantMessage) + + // Check if assistant wants to use tools + if assistantMessage.ToolCalls != nil && len(assistantMessage.ToolCalls) > 0 { + return s.handleToolCalls(*assistantMessage) + } + + // Extract text content for regular responses + var responseText string + if assistantMessage.Content.ContentStr != nil { + responseText = *assistantMessage.Content.ContentStr + } else if assistantMessage.Content.ContentBlocks != nil && len(assistantMessage.Content.ContentBlocks) > 0 { + var textParts []string + for _, block := range assistantMessage.Content.ContentBlocks { + if block.Text != nil { + textParts = append(textParts, *block.Text) + } + } + responseText = strings.Join(textParts, "\n") + } + + return responseText, nil +} + +// handleToolCalls handles tool execution using the new Bifrost MCP integration +func (s *ChatSession) handleToolCalls(assistantMessage schemas.ChatMessage) (string, error) { + toolCalls := assistantMessage.ToolCalls + + // Display tools to user for approval + fmt.Println("\nπŸ”§ Assistant wants to use the following tools:") + fmt.Println("============================================") + + for i, toolCall := range toolCalls { + fmt.Printf("[%d] Tool: %s\n", i+1, *toolCall.Function.Name) + fmt.Printf(" Arguments: %s\n", toolCall.Function.Arguments) + fmt.Println() + } + + fmt.Print("Do you want to execute these tools? (y/n): ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return "❌ Tool execution cancelled by user.", nil + } + + input := strings.ToLower(strings.TrimSpace(scanner.Text())) + if input != "y" && input != "yes" { + return "❌ Tool execution cancelled by user.", nil + } + + fmt.Println("βœ… Executing tools...") + + // Execute each tool using Bifrost's ExecuteMCPTool method + toolResults := make([]schemas.ChatMessage, 0) + for _, toolCall := range toolCalls { + // Start loading animation for this tool + stopChan, wg := startLoader() + + // Execute the tool using Bifrost's integrated MCP functionality + toolResult, err := s.client.ExecuteMCPTool(context.Background(), toolCall) + + // Stop loading animation + stopLoader(stopChan, wg) + + if err != nil { + fmt.Printf("❌ Error executing tool %s: %v\n", *toolCall.Function.Name, err) + // Create error message for this tool + errorResult := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleTool, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr(fmt.Sprintf("Error executing tool: %v", err)), + }, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: toolCall.ID, + }, + } + toolResults = append(toolResults, errorResult) + } else { + fmt.Printf("βœ… Tool %s executed successfully\n", *toolCall.Function.Name) + toolResults = append(toolResults, *toolResult) + } + } + + // Add tool results to conversation history + s.history = append(s.history, toolResults...) + + // If agentic mode is enabled, send conversation back to LLM for synthesis + if s.config.MCPAgenticMode { + return s.synthesizeToolResults() + } + + // Non-agentic mode: return the results directly + var responseText strings.Builder + responseText.WriteString("πŸ”§ Tool execution completed:\n\n") + + for i, result := range toolResults { + if result.Content.ContentStr != nil { + responseText.WriteString(fmt.Sprintf("Tool %d result: %s\n", i+1, *result.Content.ContentStr)) + } + } + + return responseText.String(), nil +} + +// synthesizeToolResults sends the conversation with tool results back to LLM for synthesis +func (s *ChatSession) synthesizeToolResults() (string, error) { + // Add synthesis prompt + synthesisPrompt := schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: stringPtr("Please provide a comprehensive response based on the tool results above."), + }, + } + + // Temporarily add synthesis prompt for the request + conversationWithSynthesis := append(s.history, synthesisPrompt) + + // Create synthesis request + synthesisRequest := &schemas.BifrostChatRequest{ + Input: conversationWithSynthesis, + Params: &schemas.ChatParameters{ + Temperature: s.config.Temperature, + MaxCompletionTokens: s.config.MaxTokens, + }, + } + + fmt.Println("πŸ€– Synthesizing response...") + + // Start loading animation + stopChan, wg := startLoader() + + // Send synthesis request + synthesisResponse, err := s.client.ChatCompletionRequest(context.Background(), synthesisRequest) + + // Stop loading animation + stopLoader(stopChan, wg) + + if err != nil { + fmt.Printf("⚠️ Synthesis failed: %v. Returning tool results directly.\n", err) + // Fallback to direct tool results + var responseText strings.Builder + responseText.WriteString("πŸ”§ Tool execution completed (synthesis failed):\n\n") + + // Get tool results from history (last few messages that are tool messages) + for i := len(s.history) - 1; i >= 0; i-- { + if s.history[i].Role == schemas.ChatMessageRoleTool { + if s.history[i].Content.ContentStr != nil { + responseText.WriteString(fmt.Sprintf("Tool result: %s\n", *s.history[i].Content.ContentStr)) + } + } else { + break // Stop when we hit non-tool messages + } + } + + return responseText.String(), nil + } + + if synthesisResponse == nil || len(synthesisResponse.Choices) == 0 { + return "❌ No synthesis response received", nil + } + + // Get synthesized response + synthesizedMessage := synthesisResponse.Choices[0].Message + + // Add synthesized response to history (replace the temporary synthesis prompt effect) + s.history = append(s.history, *synthesizedMessage) + + // Extract text content + var responseText string + if synthesizedMessage.Content.ContentStr != nil { + responseText = *synthesizedMessage.Content.ContentStr + } else if synthesizedMessage.Content.ContentBlocks != nil { + var textParts []string + for _, block := range synthesizedMessage.Content.ContentBlocks { + if block.Text != nil { + textParts = append(textParts, *block.Text) + } + } + responseText = strings.Join(textParts, "\n") + } + + return responseText, nil +} + +// PrintHistory prints the conversation history +func (s *ChatSession) PrintHistory() { + fmt.Println("\nπŸ“œ Conversation History:") + fmt.Println("========================") + + for i, msg := range s.history { + if msg.Role == schemas.ChatMessageRoleSystem { + continue // Skip system messages in history display + } + + var content string + if msg.Content.ContentStr != nil { + content = *msg.Content.ContentStr + } else if msg.Content.ContentBlocks != nil { + var textParts []string + for _, block := range msg.Content.ContentBlocks { + if block.Text != nil { + textParts = append(textParts, *block.Text) + } + } + content = strings.Join(textParts, "\n") + } + + role := cases.Title(language.English).String(string(msg.Role)) + timestamp := fmt.Sprintf("[%d]", i) + + fmt.Printf("%s %s: %s\n\n", timestamp, role, content) + } +} + +// Cleanup closes the chat session and cleans up resources +func (s *ChatSession) Cleanup() { + if s.client != nil { + s.client.Shutdown() + } +} + +// printWelcome prints the welcome message and instructions +func printWelcome(config ChatbotConfig) { + fmt.Println("πŸ€– Bifrost CLI Chatbot") + fmt.Println("======================") + fmt.Printf("πŸ”§ Provider: %s\n", config.Provider) + fmt.Printf("🧠 Model: %s\n", config.Model) + fmt.Printf("πŸ”„ Agentic Mode: %t\n", config.MCPAgenticMode) + fmt.Printf("πŸ”§ Tool Execution: Manual approval required\n") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" /help - Show this help message") + fmt.Println(" /history - Show conversation history") + fmt.Println(" /clear - Clear conversation history") + fmt.Println(" /config - Show current configuration") + fmt.Println(" /provider - Switch provider") + fmt.Println(" /model - Switch model") + fmt.Println(" /quit - Exit the chatbot") + fmt.Println() + fmt.Println("Type your message and press Enter to chat!") + fmt.Println("When the assistant wants to use tools, you'll be asked to approve them.") + fmt.Println("==========================================") +} + +// printHelp prints help information +func printHelp() { + fmt.Println("\nπŸ“– Help") + fmt.Println("========") + fmt.Println("Available commands:") + fmt.Println(" /help - Show this help message") + fmt.Println(" /history - Show conversation history") + fmt.Println(" /clear - Clear conversation history (keeps system prompt)") + fmt.Println(" /config - Show current provider, model, and settings") + fmt.Println(" /provider - Switch between different AI providers") + fmt.Println(" /model - Switch between models for current provider") + fmt.Println(" /quit - Exit the chatbot") + fmt.Println() + fmt.Println("Supported providers:") + fmt.Println("β€’ OpenAI (gpt-4o-mini, gpt-4-turbo, gpt-4o)") + fmt.Println("β€’ Anthropic (claude models)") + fmt.Println("β€’ Bedrock (AWS hosted models)") + fmt.Println("β€’ Cohere (command models)") + fmt.Println("β€’ Azure (Azure OpenAI models)") + fmt.Println("β€’ Vertex (Google Cloud models)") + fmt.Println("β€’ Mistral (mistral models)") + fmt.Println("β€’ Ollama (local models)") + fmt.Println() + fmt.Println("Tool execution:") + fmt.Println("β€’ When the assistant wants to use tools, you'll be asked to approve them") + fmt.Println("β€’ You can review the tool names and arguments before approving") + fmt.Println("β€’ Available tools include web search and Context7") + fmt.Println("β€’ In agentic mode, tool results are synthesized into natural responses") + fmt.Println("β€’ In non-agentic mode, raw tool results are displayed") + fmt.Println() +} + +// stringPtr is a helper function to create string pointers +func stringPtr(s string) *string { + return &s +} + +// startLoader starts a loading spinner animation +func startLoader() (chan bool, *sync.WaitGroup) { + stopChan := make(chan bool) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + spinner := []string{"β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"} + i := 0 + + for { + select { + case <-stopChan: + // Clear the spinner + fmt.Print("\r\033[K") // Clear current line + return + default: + fmt.Printf("\rπŸ€– Assistant: %s Thinking...", spinner[i%len(spinner)]) + i++ + time.Sleep(100 * time.Millisecond) + } + } + }() + + return stopChan, &wg +} + +// stopLoader stops the loading animation +func stopLoader(stopChan chan bool, wg *sync.WaitGroup) { + close(stopChan) + wg.Wait() +} + +func main() { + // Check for required environment variables + if os.Getenv("OPENAI_API_KEY") == "" { + fmt.Println("❌ Error: OPENAI_API_KEY environment variable is required") + fmt.Println("πŸ’‘ Set additional provider API keys to access more models:") + fmt.Println(" - ANTHROPIC_API_KEY for Claude models") + fmt.Println(" - COHERE_API_KEY for Cohere models") + fmt.Println(" - MISTRAL_API_KEY for Mistral models") + fmt.Println(" - AWS credentials for Bedrock") + fmt.Println(" - AZURE_API_KEY and AZURE_ENDPOINT for Azure OpenAI") + fmt.Println(" - VERTEX_PROJECT_ID and credentials for Vertex AI") + os.Exit(1) + } + + // Default configuration + config := ChatbotConfig{ + Provider: schemas.OpenAI, + Model: "gpt-4o-mini", + MCPAgenticMode: true, + MCPServerPort: 8585, + Temperature: bifrost.Ptr(0.7), + MaxTokens: bifrost.Ptr(1000), + } + + // Create chat session + fmt.Println("πŸš€ Starting Bifrost CLI Chatbot...") + session, err := NewChatSession(config) + if err != nil { + fmt.Printf("❌ Failed to create chat session: %v\n", err) + os.Exit(1) + } + + // Setup graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("\n\nπŸ‘‹ Goodbye! Cleaning up...") + session.Cleanup() + os.Exit(0) + }() + + // Give MCP servers time to initialize + fmt.Println("⏳ Waiting for MCP servers to initialize...") + time.Sleep(3 * time.Second) + + // Print welcome message + printWelcome(config) + + // Main chat loop + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("\nπŸ’¬ You: ") + if !scanner.Scan() { + break + } + + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + + // Handle commands + switch input { + case "/help": + printHelp() + continue + case "/history": + session.PrintHistory() + continue + case "/clear": + // Keep system prompt but clear conversation history + systemPrompt := session.history[0] // Assuming first message is system + session.history = []schemas.ChatMessage{systemPrompt} + fmt.Println("🧹 Conversation history cleared!") + continue + case "/config": + session.showCurrentConfig() + continue + case "/provider": + if err := session.switchProvider(); err != nil { + fmt.Printf("❌ Error switching provider: %v\n", err) + } + continue + case "/model": + if err := session.switchModel(); err != nil { + fmt.Printf("❌ Error switching model: %v\n", err) + } + continue + case "/quit": + fmt.Println("πŸ‘‹ Goodbye!") + session.Cleanup() + return + } + + // Send message and get response + response, err := session.SendMessage(input) + if err != nil { + fmt.Printf("\rπŸ€– Assistant: ❌ Error: %v\n", err) + continue + } + + fmt.Printf("πŸ€– Assistant: %s\n", response) + } + + // Cleanup + session.Cleanup() +} diff --git a/tests/core-providers/README.md b/tests/core-providers/README.md new file mode 100644 index 000000000..ba9a786ef --- /dev/null +++ b/tests/core-providers/README.md @@ -0,0 +1,476 @@ +# Bifrost Core Providers Test Suite πŸš€ + +This directory contains comprehensive tests for all Bifrost AI providers, ensuring compatibility and functionality across different AI services. + +## πŸ“‹ Supported Providers + +- **OpenAI** - GPT models and function calling +- **Anthropic** - Claude models +- **Azure OpenAI** - Azure-hosted OpenAI models +- **AWS Bedrock** - Amazon's managed AI service +- **Cohere** - Cohere's language models +- **Google Vertex AI** - Google Cloud's AI platform +- **Mistral** - Mistral AI models with vision capabilities +- **Ollama** - Local LLM serving platform +- **Groq** - OSS models +- **SGLang** - OSS models +- **Parasail** - OSS models +- **Perplexity** - Sonar models +- **Cerebras** - Llama, Qwen and GPT-OSS models +- **Gemini** - Gemini models +- **OpenRouter** - Models supported by OpenRouter + +## πŸƒβ€β™‚οΈ Running Tests + +### Parallel Test Execution + +All provider tests are configured to run in parallel using Go's `t.Parallel()` function. This allows multiple provider tests to execute concurrently, significantly reducing total test execution time. + +**Benefits:** +- Faster test execution when testing multiple providers +- Better resource utilization +- Isolated test execution (each test creates its own client instance) + +**Usage:** +```bash +# Default: Tests run in parallel (up to GOMAXPROCS concurrent tests) +go test -v ./tests/core-providers/ + +# Explicitly set number of parallel tests +go test -v ./tests/core-providers/ -parallel 10 + +# Run tests sequentially (disable parallel execution) +go test -v ./tests/core-providers/ -parallel 1 +``` + +**Note:** Each test function creates its own isolated Bifrost client instance via `config.SetupTest()`, ensuring no shared state between parallel test executions. + +### Development with Local Bifrost Core + +To test changes with a forked or local version of bifrost-core: + +1. **Uncomment the replace directive** in `tests/core-providers/go.mod`: + + ```go + // Uncomment this line to use your local bifrost-core + replace github.com/maximhq/bifrost/core => ../../core + ``` + +2. **Update dependencies**: + + ```bash + cd tests/core-providers + go mod tidy + ``` + +3. **Run tests** with your local changes: + + ```bash + go test -v ./tests/core-providers/ + ``` + +⚠️ **Important**: Ensure your local `../../core` directory contains your bifrost-core implementation. The path should be relative to the `tests/core-providers` directory. + +### Prerequisites + +Set up environment variables for the providers you want to test: + +```bash +# OpenAI +export OPENAI_API_KEY="your-openai-key" + +# Anthropic +export ANTHROPIC_API_KEY="your-anthropic-key" + +# Azure OpenAI +export AZURE_API_KEY="your-azure-key" +export AZURE_ENDPOINT="your-azure-endpoint" + +# AWS Bedrock +export AWS_ACCESS_KEY_ID_ID="your-aws-access-key" +export AWS_SECRET_ACCESS_KEY="your-aws-secret-key" +export AWS_REGION="us-east-1" + +# Cohere +export COHERE_API_KEY="your-cohere-key" + +# Google Vertex AI +export GOOGLE_APPLICATION_CREDENTIALS="path/to/service-account.json" +export GOOGLE_PROJECT_ID="your-project-id" + +# Mistral AI +export MISTRAL_API_KEY="your-mistral-key" + +# Gemini +export GEMINI_API_KEY="your-gemini-key" + +# Ollama (local installation) +# No API key required - ensure Ollama is running locally +# Default endpoint: http://localhost:11434 +``` + +### Run All Provider Tests + +```bash +# Run all tests with verbose output (recommended) +go test -v ./tests/core-providers/ + +# Run all tests in parallel (faster execution) +# Tests are configured to run in parallel by default +go test -v ./tests/core-providers/ -parallel 10 + +# Run with debug logs +go test -v ./tests/core-providers/ -debug +``` + +**Note**: All provider tests are configured to run in parallel using `t.Parallel()`. This means multiple provider tests can execute concurrently, significantly reducing total test execution time. The number of parallel tests can be controlled using the `-parallel` flag (default is the number of CPUs). + +### Run Specific Provider Tests + +```bash +# Test only OpenAI +go test -v ./tests/core-providers/ -run TestOpenAI + +# Test only Anthropic +go test -v ./tests/core-providers/ -run TestAnthropic + +# Test only Azure +go test -v ./tests/core-providers/ -run TestAzure + +# Test only Bedrock +go test -v ./tests/core-providers/ -run TestBedrock + +# Test only Cohere +go test -v ./tests/core-providers/ -run TestCohere + +# Test only Vertex AI +go test -v ./tests/core-providers/ -run TestVertex + +# Test only Mistral +go test -v ./tests/core-providers/ -run TestMistral + +# Test only Gemini +go test -v ./tests/core-providers/ -run TestGemini + +# Test only Ollama +go test -v ./tests/core-providers/ -run TestOllama +``` + +### Run Specific Test Scenarios + +You can run specific scenarios across all providers: + +```bash +# Test only chat completion +go test -v ./tests/core-providers/ -run "Chat" + +# Test only function calling +go test -v ./tests/core-providers/ -run "Function" +``` + +### Run Specific Scenario for Specific Provider + +You can combine provider and scenario filters to test specific functionality: + +```bash +# Test only OpenAI simple chat +go test -v ./tests/core-providers/ -run "TestOpenAI/SimpleChat" + +# Test only Anthropic tool calls +go test -v ./tests/core-providers/ -run "TestAnthropic/ToolCalls" + +# Test only Azure multi-turn conversation +go test -v ./tests/core-providers/ -run "TestAzure/MultiTurnConversation" + +# Test only Bedrock text completion +go test -v ./tests/core-providers/ -run "TestBedrock/TextCompletion" + +# Test only Cohere image URL processing +go test -v ./tests/core-providers/ -run "TestCohere/ImageURL" + +# Test only Vertex automatic function calling +go test -v ./tests/core-providers/ -run "TestVertex/AutomaticFunctionCalling" + +# Test only Mistral image processing +go test -v ./tests/core-providers/ -run "TestMistral/ImageURL" + +# Test only Gemini simple chat +go test -v ./tests/core-providers/ -run "TestGemini/SimpleChat" + +# Test only Ollama simple chat +go test -v ./tests/core-providers/ -run "TestOllama/SimpleChat" + +# Test only OpenAI reasoning capabilities +go test -v ./tests/core-providers/ -run "TestOpenAI/Reasoning" +``` + +**Available Scenario Names:** + +- `SimpleChat` - Basic chat completion +- `TextCompletion` - Text completion (legacy models) +- `MultiTurnConversation` - Multi-turn chat conversations +- `ToolCalls` - Basic function/tool calling +- `MultipleToolCalls` - Multiple tool calls in one request +- `End2EndToolCalling` - Complete tool calling workflow +- `AutomaticFunctionCalling` - Automatic function selection +- `ImageURL` - Image processing from URLs +- `ImageBase64` - Image processing from base64 +- `MultipleImages` - Multiple image processing +- `CompleteEnd2End` - Full end-to-end test +- `ProviderSpecific` - Provider-specific features +- `Embedding` - Basic embedding request +- `Reasoning` - Step-by-step reasoning and thinking capabilities via Responses API + +## πŸ§ͺ Test Scenarios + +Each provider is tested against these scenarios when supported: + +βœ… **Supported by Most Providers:** + +- Simple Text Completion +- Simple Chat Completion +- Multi-turn Chat Conversation +- Chat with System Message +- Text Completion with Parameters +- Chat Completion with Parameters +- Error Handling (Invalid Model) +- Model Information Retrieval +- Simple Function Calling + +❌ **Provider-Specific Support:** + +- **Automatic Function Calling**: OpenAI, Anthropic, Bedrock, Azure, Vertex, Mistral, Ollama, Gemini +- **Vision/Image Analysis**: OpenAI, Anthropic, Bedrock, Azure, Vertex, Mistral, Gemini (limited support for Cohere and Ollama) +- **Text Completion**: Legacy models only (most providers now focus on chat completion) +- **Reasoning/Thinking**: Advanced reasoning models with step-by-step thinking capabilities via Responses API (provider support varies) + +## πŸ“Š Understanding Test Output + +The test suite provides rich visual feedback: + +- πŸš€ **Test suite starting** +- βœ… **Successful operations and supported tests** +- ❌ **Failed operations and unsupported features** +- ⏭️ **Skipped scenarios (not supported by provider)** +- πŸ“Š **Summary statistics** +- ℹ️ **Informational notes** + +Example output: + +```text +=== RUN TestOpenAI +πŸš€ Starting comprehensive test suite for OpenAI provider... +βœ… Simple Text Completion test completed successfully +βœ… Simple Chat Completion test completed successfully +⏭️ Automatic Function Calling not supported by this provider +πŸ“Š Test Summary for OpenAI: +βœ…βœ… Supported Tests: 11 +❌ Unsupported Tests: 1 +``` + +## πŸ”§ Adding New Providers + +To add a new provider to the test suite: + +### 1. Create Provider Test File + +Create a new file `{provider}_test.go`: + +```go +package tests + +import ( + "testing" + "github.com/BifrostDev/bifrost/pkg/client" +) + +func TestNewProvider(t *testing.T) { + config := client.Config{ + Provider: "newprovider", + APIKey: getEnvVar("NEW_PROVIDER_API_KEY"), + // Add other required config fields + } + + // Skip if no API key provided + if config.APIKey == "" { + t.Skip("NEW_PROVIDER_API_KEY not set, skipping NewProvider tests") + } + + runProviderTests(t, config, "NewProvider") +} +``` + +### 2. Update Provider Configuration + +Add your provider's capabilities in `tests.go`: + +```go +func getProviderCapabilities(providerName string) ProviderCapabilities { + switch providerName { + case "NewProvider": + return ProviderCapabilities{ + SupportsTextCompletion: true, + SupportsChatCompletion: true, + SupportsFunctionCalling: false, // Update based on provider + SupportsAutomaticFunctions: false, + SupportsVision: false, + SupportsSystemMessages: true, + SupportsMultiTurn: true, + SupportsParameters: true, + SupportsModelInfo: true, + SupportsErrorHandling: true, + } + // ... other cases + } +} +``` + +### 3. Add Default Models + +Add default models for your provider: + +```go +func getDefaultModel(providerName string) string { + switch providerName { + case "NewProvider": + return "newprovider-model-name" + // ... other cases + } +} +``` + +### 4. Environment Variables + +Document any required environment variables in this README and ensure they're handled in the test setup. + +### 5. Test Your Implementation + +Run your new provider tests: + +```bash +go test -v ./tests/core-providers/ -run TestNewProvider +``` + +## πŸ› οΈ Troubleshooting + +### Common Issues + +1. **Tests being skipped**: Make sure environment variables are set correctly +2. **Connection timeouts**: Check your network connection and API endpoints +3. **Authentication errors**: Verify your API keys are valid and have proper permissions +4. **Missing logs**: Use `-v` flag to see detailed test output +5. **Rate limiting**: Some providers have rate limits; tests may need delays +6. **Ollama connection issues**: Ensure Ollama is running locally (`ollama serve`) +7. **Mistral vision failures**: Check if your account has access to Pixtral models + +### Debug Mode + +Enable debug logging to see detailed API interactions: + +```bash +go test -v ./tests/core-providers/ -debug +``` + +### Provider-Specific Considerations + +#### Mistral AI + +- **Models**: Uses `pixtral-12b-latest` for vision tasks +- **Capabilities**: Full support for chat, tools, and vision +- **API Key**: Required via `MISTRAL_API_KEY` environment variable + +#### Gemini + +- **Models**: Uses `gemini-2.0-flash` for chat and `text-embedding-004` for embeddings +- **Capabilities**: Full support for chat, tools, vision (base64), speech synthesis, and transcription +- **API Key**: Required via `GEMINI_API_KEY` environment variable +- **Limitations**: No text completion support, limited image URL support (base64 preferred) + +#### Ollama + +- **Local Setup**: Requires Ollama to be running locally (default: `http://localhost:11434`) +- **Models**: Uses `llama3.2` model by default +- **No API Key**: Authentication not required for local instances +- **Limitations**: No vision/image processing support +- **Installation**: [Download from ollama.ai](https://ollama.ai/) and ensure the service is running + +### Checking Provider Status + +If a provider seems to be failing, you can check their status pages: + +- [OpenAI Status](https://status.openai.com/) +- [Anthropic Status](https://status.anthropic.com/) +- [Azure Status](https://status.azure.com/) +- [AWS Status](https://status.aws.amazon.com/) +- [Mistral Status](https://status.mistral.ai/) + +## πŸ“ Test Coverage + +The comprehensive test suite covers: + +- βœ… **Text Completion** - Legacy completion models (where supported) +- βœ… **Simple Chat** - Basic chat completion functionality +- βœ… **Multi-Turn Conversations** - Context maintenance across messages +- βœ… **Tool Calls** - Basic function/tool calling capabilities +- βœ… **Multiple Tool Calls** - Multiple tools in a single request +- βœ… **End-to-End Tool Calling** - Complete tool workflow with result integration +- βœ… **Automatic Function Calling** - Provider-managed tool execution +- βœ… **Image URL Processing** - Image analysis from URLs +- βœ… **Image Base64 Processing** - Image analysis from base64 encoded data +- βœ… **Multiple Images** - Multi-image analysis and comparison +- βœ… **Complete End-to-End** - Full multimodal workflows +- βœ… **Provider-Specific Features** - Integration-unique capabilities + +### Provider Capability Matrix + +| Provider | Chat | Tools | Vision | Text Completion | Auto Functions | +| --------- | ---- | ----- | ------ | --------------- | -------------- | +| OpenAI | βœ… | βœ… | βœ… | ❌ | βœ… | +| Anthropic | βœ… | βœ… | βœ… | βœ… | βœ… | +| Azure | βœ… | βœ… | βœ… | βœ… | βœ… | +| Bedrock | βœ… | βœ… | βœ… | βœ… | βœ… | +| Vertex | βœ… | βœ… | βœ… | ❌ | βœ… | +| Cohere | βœ… | βœ… | ❌ | ❌ | ❌ | +| Mistral | βœ… | βœ… | βœ… | ❌ | βœ… | +| Ollama | βœ… | βœ… | ❌ | ❌ | βœ… | +| Gemini | βœ… | βœ… | βœ… | ❌ | βœ… | + +## 🀝 Contributing + +When adding new providers or test scenarios: + +### Adding New Providers + +1. **Create test file**: Add `{provider}_test.go` following the existing pattern +2. **Update config**: Add provider configuration in `config/account.go`: + - Add to `GetKeysForProvider()` (if API key required) + - Add to `GetConfigForProvider()` + - Add to `GetConfiguredProviders()` list +3. **Test scenarios**: Configure supported scenarios in the test file +4. **Documentation**: Update this README with environment variables and capabilities +5. **Testing**: Test with multiple scenarios to verify integration + +### Adding New Test Scenarios + +1. **Implement scenario**: Add new test function in `scenarios/` directory +2. **Update structure**: Add scenario to `TestScenarios` struct in `config/account.go` +3. **Configure providers**: Update each provider's scenario configuration +4. **Update runner**: Add scenario call to `runAllComprehensiveTests()` in `tests.go` +5. **Documentation**: Update README with scenario description and examples + +### Testing Your Changes + +```bash +# Test specific provider +go test -v ./tests/core-providers/ -run TestYourProvider + +# Test all providers +go test -v ./tests/core-providers/ + +# Test with debug output +go test -v ./tests/core-providers/ -debug +``` + +## πŸ“„ License + +This test suite is part of the Bifrost project and follows the same license terms. diff --git a/tests/core-providers/anthropic_test.go b/tests/core-providers/anthropic_test.go new file mode 100644 index 000000000..e07acf496 --- /dev/null +++ b/tests/core-providers/anthropic_test.go @@ -0,0 +1,56 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestAnthropic(t *testing.T) { + t.Parallel() + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("Skipping Anthropic tests because ANTHROPIC_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Anthropic, + ChatModel: "claude-sonnet-4-20250514", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Anthropic, Model: "claude-3-7-sonnet-20250219"}, + {Provider: schemas.Anthropic, Model: "claude-sonnet-4-20250514"}, + }, + VisionModel: "claude-3-7-sonnet-20250219", // Same model supports vision + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + Embedding: false, + Reasoning: true, + ListModels: true, + }, + } + + t.Run("AnthropicTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/azure_test.go b/tests/core-providers/azure_test.go new file mode 100644 index 000000000..b7faa8a02 --- /dev/null +++ b/tests/core-providers/azure_test.go @@ -0,0 +1,67 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestAzure(t *testing.T) { + t.Parallel() + t.Skip("Skipping Azure tests because Azure.") + + if os.Getenv("AZURE_API_KEY") == "" { + t.Skip("Skipping Azure tests because AZURE_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Azure, + ChatModel: "gpt-4o-backup", + VisionModel: "gpt-4o", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Azure, Model: "gpt-4o-backup"}, + }, + TextModel: "", // Azure OpenAI doesn't support text completion in newer models + EmbeddingModel: "text-embedding-ada-002", + ReasoningModel: "o1", + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + Embedding: true, + ListModels: true, + Reasoning: true, + }, + } + + // Disable embedding if embeddings key is not provided + if os.Getenv("AZURE_EMB_API_KEY") == "" { + t.Logf("AZURE_EMB_API_KEY not set; disabling Azure embedding tests") + testConfig.EmbeddingModel = "" + testConfig.Scenarios.Embedding = false + } + + t.Run("AzureTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/bedrock_test.go b/tests/core-providers/bedrock_test.go new file mode 100644 index 000000000..483788d5b --- /dev/null +++ b/tests/core-providers/bedrock_test.go @@ -0,0 +1,58 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestBedrock(t *testing.T) { + t.Parallel() + if os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "" { + t.Skip("Skipping Bedrock embedding: AWS credentials not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Bedrock, + ChatModel: "anthropic.claude-3-5-sonnet-20240620-v1:0", + VisionModel: "claude-sonnet-4", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Bedrock, Model: "claude-3.7-sonnet"}, + }, + TextModel: "mistral.mistral-7b-instruct-v0:2", // Bedrock Claude doesn't support text completion + EmbeddingModel: "cohere.embed-v4:0", + ReasoningModel: "claude-sonnet-4", + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported for Claude + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, // Direct Image URL is not supported for Bedrock + ImageBase64: true, + MultipleImages: false, // Direct Image URL is not supported for Bedrock + CompleteEnd2End: true, + Embedding: true, + Reasoning: true, + ListModels: true, + }, + } + + t.Run("BedrockTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/cerebras_test.go b/tests/core-providers/cerebras_test.go new file mode 100644 index 000000000..17089ad80 --- /dev/null +++ b/tests/core-providers/cerebras_test.go @@ -0,0 +1,57 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestCerebras(t *testing.T) { + t.Parallel() + if os.Getenv("CEREBRAS_API_KEY") == "" { + t.Skip("Skipping Cerebras tests because CEREBRAS_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Cerebras, + ChatModel: "llama-3.3-70b", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Cerebras, Model: "llama3.1-8b"}, + {Provider: schemas.Cerebras, Model: "gpt-oss-120b"}, + }, + TextModel: "llama3.1-8b", + EmbeddingModel: "", // Cerebras doesn't support embedding + Scenarios: config.TestScenarios{ + TextCompletion: true, + TextCompletionStream: true, + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, + ImageBase64: false, + MultipleImages: false, + CompleteEnd2End: true, + Embedding: false, + ListModels: true, + }, + } + + t.Run("CerebrasTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/cohere_test.go b/tests/core-providers/cohere_test.go new file mode 100644 index 000000000..797da0340 --- /dev/null +++ b/tests/core-providers/cohere_test.go @@ -0,0 +1,54 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestCohere(t *testing.T) { + t.Parallel() + if os.Getenv("COHERE_API_KEY") == "" { + t.Skip("Skipping Cohere tests because COHERE_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Cohere, + ChatModel: "command-a-03-2025", + VisionModel: "command-a-vision-07-2025", // Cohere's latest vision model + TextModel: "", // Cohere focuses on chat + EmbeddingModel: "embed-v4.0", + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not typical for Cohere + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, // May not support automatic + ImageURL: false, // Supported by c4ai-aya-vision-8b model + ImageBase64: true, // Supported by c4ai-aya-vision-8b model + MultipleImages: false, // Supported by c4ai-aya-vision-8b model + CompleteEnd2End: false, + Embedding: true, + Reasoning: true, + ListModels: true, + }, + } + + t.Run("CohereTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/config/account.go b/tests/core-providers/config/account.go new file mode 100644 index 000000000..521e9801c --- /dev/null +++ b/tests/core-providers/config/account.go @@ -0,0 +1,846 @@ +// Package config provides comprehensive test account and configuration management for the Bifrost system. +// It implements account functionality for testing purposes, supporting multiple AI providers +// and comprehensive test scenarios. +package config + +import ( + "context" + "fmt" + "os" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +const Concurrency = 4 + +// ProviderOpenAICustom represents the custom OpenAI provider for testing +const ProviderOpenAICustom = schemas.ModelProvider("openai-custom") + +// TestScenarios defines the comprehensive test scenarios +type TestScenarios struct { + TextCompletion bool + TextCompletionStream bool + SimpleChat bool + CompletionStream bool + MultiTurnConversation bool + ToolCalls bool + ToolCallsStreaming bool // Streaming tool calls functionality + MultipleToolCalls bool + End2EndToolCalling bool + AutomaticFunctionCall bool + ImageURL bool + ImageBase64 bool + MultipleImages bool + CompleteEnd2End bool + SpeechSynthesis bool // Text-to-speech functionality + SpeechSynthesisStream bool // Streaming text-to-speech functionality + Transcription bool // Speech-to-text functionality + TranscriptionStream bool // Streaming speech-to-text functionality + Embedding bool // Embedding functionality + Reasoning bool // Reasoning/thinking functionality via Responses API + ListModels bool // List available models functionality +} + +// ComprehensiveTestConfig extends TestConfig with additional scenarios +type ComprehensiveTestConfig struct { + Provider schemas.ModelProvider + TextModel string + ChatModel string + VisionModel string + ReasoningModel string + EmbeddingModel string + TranscriptionModel string + SpeechSynthesisModel string + Scenarios TestScenarios + Fallbacks []schemas.Fallback // for chat, responses, image and reasoning tests + TextCompletionFallbacks []schemas.Fallback // for text completion tests + TranscriptionFallbacks []schemas.Fallback // for transcription tests + SpeechSynthesisFallbacks []schemas.Fallback // for speech synthesis tests + EmbeddingFallbacks []schemas.Fallback // for embedding tests + SkipReason string // Reason to skip certain tests +} + +// ComprehensiveTestAccount provides a test implementation of the Account interface for comprehensive testing. +type ComprehensiveTestAccount struct{} + +// getEnvWithDefault returns the value of the environment variable if set, otherwise returns the default value +func getEnvWithDefault(envVar, defaultValue string) string { + if value := os.Getenv(envVar); value != "" { + return value + } + return defaultValue +} + +// GetConfiguredProviders returns the list of initially supported providers. +func (account *ComprehensiveTestAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + return []schemas.ModelProvider{ + schemas.OpenAI, + schemas.Anthropic, + schemas.Bedrock, + schemas.Cohere, + schemas.Azure, + schemas.Vertex, + schemas.Ollama, + schemas.Mistral, + schemas.Groq, + schemas.SGL, + schemas.Parasail, + schemas.Perplexity, + schemas.Cerebras, + schemas.Gemini, + schemas.OpenRouter, + ProviderOpenAICustom, + }, nil +} + +// GetKeysForProvider returns the API keys and associated models for a given provider. +func (account *ComprehensiveTestAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + switch providerKey { + case schemas.OpenAI: + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case ProviderOpenAICustom: + return []schemas.Key{ + { + Value: os.Getenv("OPENAI_API_KEY"), // Use GROQ API key for OpenAI-compatible endpoint + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Anthropic: + return []schemas.Key{ + { + Value: os.Getenv("ANTHROPIC_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Bedrock: + return []schemas.Key{ + { + Models: []string{}, + Weight: 1.0, + BedrockKeyConfig: &schemas.BedrockKeyConfig{ + AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), + SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + SessionToken: bifrost.Ptr(os.Getenv("AWS_SESSION_TOKEN")), + Region: bifrost.Ptr(getEnvWithDefault("AWS_REGION", "us-east-1")), + ARN: bifrost.Ptr(os.Getenv("AWS_ARN")), + Deployments: map[string]string{ + "claude-sonnet-4": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "claude-3.7-sonnet": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", + }, + }, + }, + { + Models: []string{"anthropic.claude-3-5-sonnet-20240620-v1:0", "cohere.embed-v4:0"}, + Weight: 1.0, + BedrockKeyConfig: &schemas.BedrockKeyConfig{ + AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), + SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + SessionToken: bifrost.Ptr(os.Getenv("AWS_SESSION_TOKEN")), + Region: bifrost.Ptr(getEnvWithDefault("AWS_REGION", "us-east-1")), + }, + }, + }, nil + case schemas.Cohere: + return []schemas.Key{ + { + Value: os.Getenv("COHERE_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Azure: + return []schemas.Key{ + { + Value: os.Getenv("AZURE_API_KEY"), + Models: []string{}, + Weight: 1.0, + AzureKeyConfig: &schemas.AzureKeyConfig{ + Endpoint: os.Getenv("AZURE_ENDPOINT"), + Deployments: map[string]string{ + "gpt-4o": "gpt-4o", + "gpt-4o-backup": "gpt-4o-aug", + "o1": "o1", + }, + // Use environment variable for API version with fallback to current preview version + // Note: This is a preview API version that may change over time. Update as needed. + // Set AZURE_API_VERSION environment variable to override the default. + APIVersion: bifrost.Ptr(getEnvWithDefault("AZURE_API_VERSION", "2024-08-01-preview")), + }, + }, + { + Value: os.Getenv("AZURE_EMB_API_KEY"), + Models: []string{}, + Weight: 1.0, + AzureKeyConfig: &schemas.AzureKeyConfig{ + Endpoint: os.Getenv("AZURE_EMB_ENDPOINT"), + Deployments: map[string]string{ + "text-embedding-ada-002": "text-embedding-ada-002", + }, + // Use environment variable for API version with fallback to current stable version + // Set AZURE_API_VERSION environment variable to override the default. + APIVersion: bifrost.Ptr(getEnvWithDefault("AZURE_API_VERSION", "2024-10-21")), + }, + }, + }, nil + case schemas.Vertex: + return []schemas.Key{ + { + Value: os.Getenv("VERTEX_API_KEY"), + Models: []string{}, + Weight: 1.0, + VertexKeyConfig: &schemas.VertexKeyConfig{ + ProjectID: os.Getenv("VERTEX_PROJECT_ID"), + Region: getEnvWithDefault("VERTEX_REGION", "us-central1"), + AuthCredentials: os.Getenv("VERTEX_CREDENTIALS"), + }, + }, + }, nil + case schemas.Mistral: + return []schemas.Key{ + { + Value: os.Getenv("MISTRAL_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Groq: + return []schemas.Key{ + { + Value: os.Getenv("GROQ_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Parasail: + return []schemas.Key{ + { + Value: os.Getenv("PARASAIL_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Perplexity: + return []schemas.Key{ + { + Value: os.Getenv("PERPLEXITY_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Cerebras: + return []schemas.Key{ + { + Value: os.Getenv("CEREBRAS_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.Gemini: + return []schemas.Key{ + { + Value: os.Getenv("GEMINI_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + case schemas.OpenRouter: + return []schemas.Key{ + { + Value: os.Getenv("OPENROUTER_API_KEY"), + Models: []string{}, + Weight: 1.0, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported provider: %s", providerKey) + } +} + +// GetConfigForProvider returns the configuration settings for a given provider. +func (account *ComprehensiveTestAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + switch providerKey { + case schemas.OpenAI: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 3, // Higher retries for production-grade provider + RetryBackoffInitial: 500 * time.Millisecond, + RetryBackoffMax: 8 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 10, + BufferSize: 10, + }, + }, nil + case ProviderOpenAICustom: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: "https://api.openai.com", + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 4, // Higher retries for Groq (can be flaky) + RetryBackoffInitial: 1 * time.Second, + RetryBackoffMax: 10 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + CustomProviderConfig: &schemas.CustomProviderConfig{ + BaseProviderType: schemas.OpenAI, + AllowedRequests: &schemas.AllowedRequests{ + TextCompletion: false, + ChatCompletion: true, + ChatCompletionStream: true, + Embedding: false, + Speech: false, + SpeechStream: false, + Transcription: false, + TranscriptionStream: false, + }, + }, + }, nil + case schemas.Anthropic: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 3, // Claude is generally reliable + RetryBackoffInitial: 500 * time.Millisecond, + RetryBackoffMax: 8 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Bedrock: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // AWS services can have occasional issues + RetryBackoffInitial: 5 * time.Second, + RetryBackoffMax: 40 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Cohere: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // Cohere can be variable + RetryBackoffInitial: 5 * time.Second, + RetryBackoffMax: 40 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Azure: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 600, + MaxRetries: 5, + RetryBackoffInitial: 20 * time.Second, + RetryBackoffMax: 3 * time.Minute, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Vertex: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 3, // Google Cloud is generally reliable + RetryBackoffInitial: 500 * time.Millisecond, + RetryBackoffMax: 8 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Ollama: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 2, // Local service, fewer retries needed + RetryBackoffInitial: 250 * time.Millisecond, + RetryBackoffMax: 4 * time.Second, + BaseURL: os.Getenv("OLLAMA_BASE_URL"), + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Mistral: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // Mistral can be variable + RetryBackoffInitial: 5 * time.Second, + RetryBackoffMax: 3 * time.Minute, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Groq: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // Groq can be flaky at times + RetryBackoffInitial: 1 * time.Second, + RetryBackoffMax: 15 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.SGL: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: os.Getenv("SGL_BASE_URL"), + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // SGL (self-hosted) can be variable + RetryBackoffInitial: 1 * time.Second, + RetryBackoffMax: 15 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Parasail: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // Parasail can be variable + RetryBackoffInitial: 1 * time.Second, + RetryBackoffMax: 12 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Perplexity: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // Perplexity can be variable + RetryBackoffInitial: 1 * time.Second, + RetryBackoffMax: 12 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Cerebras: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 5, // Cerebras is reasonably stable + RetryBackoffInitial: 5 * time.Second, + RetryBackoffMax: 3 * time.Minute, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + case schemas.Gemini: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 4, // Gemini can be variable + RetryBackoffInitial: 750 * time.Millisecond, + RetryBackoffMax: 12 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 20, + }, + }, nil + case schemas.OpenRouter: + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 4, // OpenRouter can be variable (proxy service) + RetryBackoffInitial: 1 * time.Second, + RetryBackoffMax: 12 * time.Second, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported provider: %s", providerKey) + } +} + +// AllProviderConfigs contains test configurations for all providers +var AllProviderConfigs = []ComprehensiveTestConfig{ + { + Provider: schemas.OpenAI, + ChatModel: "gpt-4o-mini", + TextModel: "", // OpenAI doesn't support text completion in newer models + ReasoningModel: "o1-mini", // OpenAI reasoning model + TranscriptionModel: "whisper-1", + SpeechSynthesisModel: "tts-1", + Scenarios: TestScenarios{ + TextCompletion: false, // Not supported + TextCompletionStream: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: true, // OpenAI supports TTS + SpeechSynthesisStream: true, // OpenAI supports streaming TTS + Transcription: true, // OpenAI supports STT with Whisper + TranscriptionStream: true, // OpenAI supports streaming STT + Embedding: true, + Reasoning: true, // OpenAI supports reasoning via o1 models + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Anthropic, Model: "claude-3-7-sonnet-20250219"}, + }, + }, + { + Provider: schemas.Anthropic, + ChatModel: "claude-3-7-sonnet-20250219", + TextModel: "", // Anthropic doesn't support text completion + Scenarios: TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: false, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Bedrock, + ChatModel: "anthropic.claude-3-sonnet-20240229-v1:0", + TextModel: "", // Bedrock Claude doesn't support text completion + Scenarios: TestScenarios{ + TextCompletion: false, // Not supported for Claude + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: true, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Cohere, + ChatModel: "command-a-03-2025", + TextModel: "", // Cohere focuses on chat + Scenarios: TestScenarios{ + TextCompletion: false, // Not typical for Cohere + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: false, // May not support automatic + ImageURL: false, // Check if supported + ImageBase64: false, // Check if supported + MultipleImages: false, // Check if supported + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: true, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Azure, + ChatModel: "gpt-4o", + TextModel: "", // Azure OpenAI doesn't support text completion in newer models + Scenarios: TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported yet + SpeechSynthesisStream: false, // Not supported yet + Transcription: false, // Not supported yet + TranscriptionStream: false, // Not supported yet + Embedding: true, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Vertex, + ChatModel: "gemini-pro", + TextModel: "", // Vertex focuses on chat + Scenarios: TestScenarios{ + TextCompletion: false, // Not typical + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: true, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Mistral, + ChatModel: "mistral-large-2411", + TextModel: "", // Mistral focuses on chat + Scenarios: TestScenarios{ + TextCompletion: false, // Not typical + SimpleChat: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: true, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Ollama, + ChatModel: "llama3.2", + TextModel: "", // Ollama focuses on chat + Scenarios: TestScenarios{ + TextCompletion: false, // Not typical + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: false, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Groq, + ChatModel: "llama-3.3-70b-versatile", + TextModel: "", // Groq doesn't support text completion + Scenarios: TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: false, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: ProviderOpenAICustom, + ChatModel: "llama-3.3-70b-versatile", + TextModel: "", // Custom OpenAI instance doesn't support text completion + Scenarios: TestScenarios{ + TextCompletion: false, + SimpleChat: true, // Enable simple chat for testing + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, + ImageBase64: false, + MultipleImages: false, + CompleteEnd2End: true, + SpeechSynthesis: false, // Not supported + SpeechSynthesisStream: false, // Not supported + Transcription: false, // Not supported + TranscriptionStream: false, // Not supported + Embedding: false, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.Gemini, + ChatModel: "gemini-2.0-flash", + TextModel: "", // GenAI doesn't support text completion in newer models + TranscriptionModel: "gemini-2.5-flash", + SpeechSynthesisModel: "gemini-2.5-flash-preview-tts", + EmbeddingModel: "text-embedding-004", + Scenarios: TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: true, + SpeechSynthesisStream: true, + Transcription: true, + TranscriptionStream: true, + Embedding: true, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, + { + Provider: schemas.OpenRouter, + ChatModel: "openai/gpt-4o", + TextModel: "google/gemini-2.5-flash", + Scenarios: TestScenarios{ + TextCompletion: true, + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: false, + SpeechSynthesisStream: false, + Transcription: false, + TranscriptionStream: false, + Embedding: false, + ListModels: true, + }, + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o-mini"}, + }, + }, +} diff --git a/tests/core-providers/config/setup.go b/tests/core-providers/config/setup.go new file mode 100644 index 000000000..ed793615b --- /dev/null +++ b/tests/core-providers/config/setup.go @@ -0,0 +1,60 @@ +// Package config provides comprehensive test utilities and configurations for the Bifrost system. +// It includes comprehensive test implementations covering all major AI provider scenarios, +// including text completion, chat, tool calling, image processing, and end-to-end workflows. +package config + +import ( + "context" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// Constants for test configuration +const ( + // TestTimeout defines the maximum duration for comprehensive tests + // Set to 20 minutes to allow for complex multi-step operations + TestTimeout = 20 * time.Minute +) + +// getBifrost initializes and returns a Bifrost instance for comprehensive testing. +// It sets up the comprehensive test account, plugin, and logger configuration. +// +// Environment variables are expected to be set by the system or test runner before calling this function. +// The account configuration will read API keys and settings from these environment variables. +// +// Returns: +// - *bifrost.Bifrost: A configured Bifrost instance ready for comprehensive testing +// - error: Any error that occurred during Bifrost initialization +// +// The function: +// 1. Creates a comprehensive test account instance +// 2. Configures Bifrost with the account and default logger +func getBifrost(ctx context.Context) (*bifrost.Bifrost, error) { + account := ComprehensiveTestAccount{} + + // Initialize Bifrost + b, err := bifrost.Init(ctx, schemas.BifrostConfig{ + Account: &account, + Plugins: nil, + Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug), + }) + if err != nil { + return nil, err + } + + return b, nil +} + +// SetupTest initializes a test environment with timeout context +func SetupTest() (*bifrost.Bifrost, context.Context, context.CancelFunc, error) { + ctx, cancel := context.WithTimeout(context.Background(), TestTimeout) + client, err := getBifrost(ctx) + if err != nil { + cancel() + return nil, nil, nil, err + } + + return client, ctx, cancel, nil +} diff --git a/tests/core-providers/cross_provider_test.go b/tests/core-providers/cross_provider_test.go new file mode 100644 index 000000000..7351e52bc --- /dev/null +++ b/tests/core-providers/cross_provider_test.go @@ -0,0 +1,150 @@ +package tests + +import ( + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + "github.com/maximhq/bifrost/tests/core-providers/scenarios" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestCrossProviderScenarios(t *testing.T) { + t.Parallel() + t.Skip("Skipping cross provider scenarios test") + return + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + defer client.Shutdown() + + // Define available providers for cross-provider testing + providers := []scenarios.ProviderConfig{ + { + Provider: schemas.OpenAI, + ChatModel: "gpt-4o-mini", + VisionModel: "gpt-4o", + ToolsSupported: true, + VisionSupported: true, + StreamSupported: true, + Available: true, + }, + { + Provider: schemas.Anthropic, + ChatModel: "claude-3-5-sonnet-20241022", + VisionModel: "claude-3-5-sonnet-20241022", + ToolsSupported: true, + VisionSupported: true, + StreamSupported: true, + Available: true, + }, + { + Provider: schemas.Groq, + ChatModel: "llama-3.1-70b-versatile", + VisionModel: "", // No vision support + ToolsSupported: true, + VisionSupported: false, + StreamSupported: true, + Available: true, + }, + { + Provider: schemas.Gemini, + ChatModel: "gemini-1.5-pro", + VisionModel: "gemini-1.5-pro", + ToolsSupported: true, + VisionSupported: true, + StreamSupported: true, + Available: true, + }, + { + Provider: schemas.Bedrock, + ChatModel: "claude-sonnet-4", + VisionModel: "claude-sonnet-4", + ToolsSupported: true, + VisionSupported: true, + StreamSupported: false, + Available: true, + }, + { + Provider: schemas.Vertex, + ChatModel: "gemini-1.5-pro", + VisionModel: "gemini-1.5-pro", + ToolsSupported: true, + VisionSupported: true, + StreamSupported: false, + Available: true, + }, + } + + // Test configuration + testConfig := scenarios.CrossProviderTestConfig{ + Providers: providers, + ConversationSettings: scenarios.ConversationSettings{ + MaxMessages: 25, + ConversationGeneratorModel: "gpt-4o", + RequiredMessageTypes: []scenarios.MessageModality{ + scenarios.ModalityText, + scenarios.ModalityTool, + scenarios.ModalityVision, + }, + }, + TestSettings: scenarios.TestSettings{ + EnableRetries: true, + MaxRetriesPerMessage: 2, + ValidationStrength: scenarios.ValidationModerate, + }, + } + + // Get predefined scenarios + scenariosList := scenarios.GetPredefinedScenarios() + + for _, scenario := range scenariosList { + // Test each scenario with both Chat Completions and Responses API + t.Run(scenario.Name+"_ChatCompletions", func(t *testing.T) { + scenarios.RunCrossProviderScenarioTest(t, client, ctx, testConfig, scenario, false) // false = Chat Completions API + }) + + t.Run(scenario.Name+"_ResponsesAPI", func(t *testing.T) { + scenarios.RunCrossProviderScenarioTest(t, client, ctx, testConfig, scenario, true) // true = Responses API + }) + } +} + +func TestCrossProviderConsistency(t *testing.T) { + t.Parallel() + t.Skip("Skipping cross provider consistency test") + return + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + defer client.Shutdown() + + providers := []scenarios.ProviderConfig{ + {Provider: schemas.OpenAI, ChatModel: "gpt-4o-mini", Available: true}, + {Provider: schemas.Anthropic, ChatModel: "claude-3-5-sonnet-20241022", Available: true}, + {Provider: schemas.Groq, ChatModel: "llama-3.1-70b-versatile", Available: true}, + {Provider: schemas.Gemini, ChatModel: "gemini-1.5-pro", Available: true}, + } + + testConfig := scenarios.CrossProviderTestConfig{ + Providers: providers, + TestSettings: scenarios.TestSettings{ + ValidationStrength: scenarios.ValidationLenient, // More lenient for consistency testing + }, + } + + // Test same prompt across different providers + t.Run("SamePrompt_DifferentProviders_ChatCompletions", func(t *testing.T) { + scenarios.RunCrossProviderConsistencyTest(t, client, ctx, testConfig, false) // Chat Completions + }) + + t.Run("SamePrompt_DifferentProviders_ResponsesAPI", func(t *testing.T) { + scenarios.RunCrossProviderConsistencyTest(t, client, ctx, testConfig, true) // Responses API + }) +} diff --git a/tests/core-providers/gemini_test.go b/tests/core-providers/gemini_test.go new file mode 100644 index 000000000..f02231ea0 --- /dev/null +++ b/tests/core-providers/gemini_test.go @@ -0,0 +1,63 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestGemini(t *testing.T) { + t.Parallel() + if os.Getenv("GEMINI_API_KEY") == "" { + t.Skip("Skipping Gemini tests because GEMINI_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Gemini, + ChatModel: "gemini-2.0-flash", + VisionModel: "gemini-2.0-flash", + EmbeddingModel: "text-embedding-004", + TranscriptionModel: "gemini-2.5-flash", + SpeechSynthesisModel: "gemini-2.5-flash-preview-tts", + SpeechSynthesisFallbacks: []schemas.Fallback{ + {Provider: schemas.Gemini, Model: "gemini-2.5-pro-preview-tts"}, + }, + ReasoningModel: "gemini-2.5-pro", + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, + ImageBase64: true, + MultipleImages: false, + CompleteEnd2End: true, + Embedding: true, + Transcription: false, + TranscriptionStream: false, + SpeechSynthesis: true, + SpeechSynthesisStream: true, + Reasoning: false, //TODO: Supported but lost since we map Gemini's responses via chat completions, fix is a native Gemini handler or reasoning support in chat completions + ListModels: true, + }, + } + + t.Run("GeminiTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/go.mod b/tests/core-providers/go.mod new file mode 100644 index 000000000..99ee1c696 --- /dev/null +++ b/tests/core-providers/go.mod @@ -0,0 +1,60 @@ +module github.com/maximhq/bifrost/tests/core-providers + +go 1.24.0 + +toolchain go1.24.3 + +replace github.com/maximhq/bifrost/core => ../../core + +require ( + github.com/maximhq/bifrost/core v0.0.0-00010101000000-000000000000 + github.com/stretchr/testify v1.11.1 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.67.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tests/core-providers/go.sum b/tests/core-providers/go.sum new file mode 100644 index 000000000..9d54d7baa --- /dev/null +++ b/tests/core-providers/go.sum @@ -0,0 +1,127 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/core-providers/groq_test.go b/tests/core-providers/groq_test.go new file mode 100644 index 000000000..4af8f1155 --- /dev/null +++ b/tests/core-providers/groq_test.go @@ -0,0 +1,62 @@ +package tests + +import ( + "context" + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestGroq(t *testing.T) { + t.Parallel() + if os.Getenv("GROQ_API_KEY") == "" { + t.Skip("Skipping Groq tests because GROQ_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Groq, + ChatModel: "llama-3.3-70b-versatile", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Groq, Model: "openai/gpt-oss-120b"}, + }, + TextModel: "llama-3.3-70b-versatile", // Use same model for text completion (via conversion) + TextCompletionFallbacks: []schemas.Fallback{ + {Provider: schemas.Groq, Model: "openai/gpt-oss-20b"}, + }, + EmbeddingModel: "", // Groq doesn't support embedding + Scenarios: config.TestScenarios{ + TextCompletion: true, // Supported via chat completion conversion + TextCompletionStream: true, // Supported via chat completion streaming conversion + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, + ImageBase64: false, + MultipleImages: false, + CompleteEnd2End: true, + Embedding: false, + ListModels: true, + }, + } + + ctx = context.WithValue(ctx, schemas.BifrostContextKey("x-litellm-fallback"), "true") + + t.Run("GroqTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/mistral_test.go b/tests/core-providers/mistral_test.go new file mode 100644 index 000000000..070437f55 --- /dev/null +++ b/tests/core-providers/mistral_test.go @@ -0,0 +1,55 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestMistral(t *testing.T) { + t.Parallel() + if os.Getenv("MISTRAL_API_KEY") == "" { + t.Skip("Skipping Mistral tests because MISTRAL_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Mistral, + ChatModel: "mistral-medium-2508", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Mistral, Model: "mistral-small-2503"}, + }, + VisionModel: "pixtral-12b-latest", + EmbeddingModel: "codestral-embed", + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + Embedding: true, + ListModels: false, + }, + } + + t.Run("MistralTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/ollama_test.go b/tests/core-providers/ollama_test.go new file mode 100644 index 000000000..a133b293e --- /dev/null +++ b/tests/core-providers/ollama_test.go @@ -0,0 +1,52 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestOllama(t *testing.T) { + t.Parallel() + if os.Getenv("OLLAMA_BASE_URL") == "" { + t.Skip("Skipping Ollama tests because OLLAMA_BASE_URL is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Ollama, + ChatModel: "llama3.1:latest", + TextModel: "", // Ollama doesn't support text completion in newer models + EmbeddingModel: "", // Ollama doesn't support embedding + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, + ImageBase64: false, + MultipleImages: false, + CompleteEnd2End: true, + Embedding: false, + ListModels: true, + }, + } + + t.Run("OllamaTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/openai_test.go b/tests/core-providers/openai_test.go new file mode 100644 index 000000000..36a5451b4 --- /dev/null +++ b/tests/core-providers/openai_test.go @@ -0,0 +1,68 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestOpenAI(t *testing.T) { + t.Parallel() + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("Skipping OpenAI tests because OPENAI_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.OpenAI, + TextModel: "gpt-3.5-turbo-instruct", + ChatModel: "gpt-4o-mini", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "gpt-4o"}, + }, + VisionModel: "gpt-4o", + EmbeddingModel: "text-embedding-3-small", + TranscriptionModel: "gpt-4o-transcribe", + TranscriptionFallbacks: []schemas.Fallback{ + {Provider: schemas.OpenAI, Model: "whisper-1"}, + }, + SpeechSynthesisModel: "gpt-4o-mini-tts", + ReasoningModel: "gpt-5", + Scenarios: config.TestScenarios{ + TextCompletion: true, + TextCompletionStream: true, + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + SpeechSynthesis: true, + SpeechSynthesisStream: true, + Transcription: true, + TranscriptionStream: true, + Embedding: true, + Reasoning: true, + ListModels: true, + }, + } + + t.Run("OpenAITests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/openrouter_test.go b/tests/core-providers/openrouter_test.go new file mode 100644 index 000000000..b00bdc90c --- /dev/null +++ b/tests/core-providers/openrouter_test.go @@ -0,0 +1,54 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestOpenRouter(t *testing.T) { + t.Parallel() + if os.Getenv("OPENROUTER_API_KEY") == "" { + t.Skip("Skipping OpenRouter tests because OPENROUTER_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.OpenRouter, + ChatModel: "openai/gpt-4o", + VisionModel: "openai/gpt-4o", + TextModel: "google/gemini-2.5-flash", + EmbeddingModel: "", + ReasoningModel: "openai/o1", + Scenarios: config.TestScenarios{ + TextCompletion: true, + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: false, // OpenRouter's responses API is in Beta + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, // OpenRouter's responses API is in Beta + ImageBase64: false, // OpenRouter's responses API is in Beta + MultipleImages: false, // OpenRouter's responses API is in Beta + CompleteEnd2End: false, // OpenRouter's responses API is in Beta + Reasoning: true, + ListModels: true, + }, + } + + t.Run("OpenRouterTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/parasail_test.go b/tests/core-providers/parasail_test.go new file mode 100644 index 000000000..2d917d39b --- /dev/null +++ b/tests/core-providers/parasail_test.go @@ -0,0 +1,52 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestParasail(t *testing.T) { + t.Parallel() + if os.Getenv("PARASAIL_API_KEY") == "" { + t.Skip("Skipping Parasail tests because PARASAIL_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Parasail, + ChatModel: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP8", + TextModel: "", // Parasail doesn't support text completion + EmbeddingModel: "", // Parasail doesn't support embedding + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, // Not supported yet + ImageBase64: false, // Not supported yet + MultipleImages: false, // Not supported yet + CompleteEnd2End: true, + Embedding: false, // Not supported yet + ListModels: true, + }, + } + + t.Run("ParasailTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/perplexity_test.go b/tests/core-providers/perplexity_test.go new file mode 100644 index 000000000..548dfb53c --- /dev/null +++ b/tests/core-providers/perplexity_test.go @@ -0,0 +1,51 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestPerplexity(t *testing.T) { + t.Parallel() + if os.Getenv("PERPLEXITY_API_KEY") == "" { + t.Skip("Skipping Perplexity tests because PERPLEXITY_API_KEY is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Perplexity, + ChatModel: "sonar-pro", + TextModel: "", // Perplexity doesn't support text completion + EmbeddingModel: "", // Perplexity doesn't support embedding + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: false, + MultipleToolCalls: false, + End2EndToolCalling: false, + AutomaticFunctionCall: false, + ImageURL: false, // Not supported yet + ImageBase64: false, // Not supported yet + MultipleImages: false, // Not supported yet + CompleteEnd2End: false, + Embedding: false, // Not supported yet + ListModels: false, + }, + } + + t.Run("PerplexityTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/scenarios/automatic_function_calling.go b/tests/core-providers/scenarios/automatic_function_calling.go new file mode 100644 index 000000000..fcac87c4e --- /dev/null +++ b/tests/core-providers/scenarios/automatic_function_calling.go @@ -0,0 +1,185 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunAutomaticFunctionCallingTest executes the automatic function calling test scenario using dual API testing framework +func RunAutomaticFunctionCallingTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.AutomaticFunctionCall { + t.Logf("Automatic function calling not supported for provider %s", testConfig.Provider) + return + } + + t.Run("AutomaticFunctionCalling", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + chatMessages := []schemas.ChatMessage{ + CreateBasicChatMessage("Get the current time in UTC timezone"), + } + responsesMessages := []schemas.ResponsesMessage{ + CreateBasicResponsesMessage("Get the current time in UTC timezone"), + } + + // Get tools for both APIs using the new GetSampleTool function + chatTool := GetSampleChatTool(SampleToolTypeTime) // Chat Completions API + if chatTool == nil { + t.Fatalf("GetSampleChatTool returned nil for SampleToolTypeTime") + } + + responsesTool := GetSampleResponsesTool(SampleToolTypeTime) // Responses API + if responsesTool == nil { + t.Fatalf("GetSampleResponsesTool returned nil for SampleToolTypeTime") + } + + // Use specialized tool call retry configuration + retryConfig := ToolCallRetryConfig(string(SampleToolTypeTime)) + retryContext := TestRetryContext{ + ScenarioName: "AutomaticFunctionCalling", + ExpectedBehavior: map[string]interface{}{ + "expected_tool_name": string(SampleToolTypeTime), + "is_forced_call": true, + "timezone": "UTC", + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "tool_choice": "forced", + }, + } + + // Enhanced tool call validation for automatic/forced function calls (same for both APIs) + expectations := ToolCallExpectations(string(SampleToolTypeTime), []string{"timezone"}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ExpectedToolCalls[0].ArgumentTypes = map[string]string{ + "timezone": "string", + } + + // Create operations for both Chat Completions and Responses API + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: chatMessages, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{ + *chatTool, + }, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStruct: &schemas.ChatToolChoiceStruct{ + Type: schemas.ChatToolChoiceTypeFunction, + Function: schemas.ChatToolChoiceFunction{ + Name: string(SampleToolTypeTime), + }, + }, + }, + }, + Fallbacks: testConfig.Fallbacks, + } + + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: responsesMessages, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{ + *responsesTool, + }, + ToolChoice: &schemas.ResponsesToolChoice{ + ResponsesToolChoiceStruct: &schemas.ResponsesToolChoiceStruct{ + Type: schemas.ResponsesToolChoiceTypeFunction, + Name: bifrost.Ptr(string(SampleToolTypeTime)), + }, + }, + }, + Fallbacks: testConfig.Fallbacks, + } + + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test - passes only if BOTH APIs succeed + result := WithDualAPITestRetry(t, + retryConfig, + retryContext, + expectations, + "AutomaticFunctionCalling", + chatOperation, + responsesOperation) + + // Validate both APIs succeeded + if !result.BothSucceeded { + var errors []string + if result.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result.ChatCompletionsError)) + } + if result.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ AutomaticFunctionCalling dual API test failed: %v", errors) + } + + // Additional validation specific to automatic function calling using universal tool extraction + validateChatAutomaticToolCall := func(response *schemas.BifrostChatResponse, apiName string) { + toolCalls := ExtractChatToolCalls(response) + validateAutomaticToolCall(t, toolCalls, apiName) + } + + validateResponsesAutomaticToolCall := func(response *schemas.BifrostResponsesResponse, apiName string) { + toolCalls := ExtractResponsesToolCalls(response) + validateAutomaticToolCall(t, toolCalls, apiName) + } + + // Validate both API responses + if result.ChatCompletionsResponse != nil { + validateChatAutomaticToolCall(result.ChatCompletionsResponse, "Chat Completions") + } + + if result.ResponsesAPIResponse != nil { + validateResponsesAutomaticToolCall(result.ResponsesAPIResponse, "Responses") + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed AutomaticFunctionCalling test!") + }) +} + +func validateAutomaticToolCall(t *testing.T, toolCalls []ToolCallInfo, apiName string) { + foundValidToolCall := false + + for _, toolCall := range toolCalls { + if toolCall.Name == string(SampleToolTypeTime) { + foundValidToolCall = true + t.Logf("βœ… %s automatic function call: %s", apiName, toolCall.Arguments) + + // Additional validation for timezone argument + lowerArgs := strings.ToLower(toolCall.Arguments) + if strings.Contains(lowerArgs, "utc") || strings.Contains(lowerArgs, "timezone") { + t.Logf("βœ… %s tool call correctly includes timezone information", apiName) + } else { + t.Logf("⚠️ %s tool call may be missing timezone specification: %s", apiName, toolCall.Arguments) + } + break + } + } + + if !foundValidToolCall { + t.Fatalf("Expected %s API to have automatic tool call for 'time'", apiName) + } +} diff --git a/tests/core-providers/scenarios/chat_completion_stream.go b/tests/core-providers/scenarios/chat_completion_stream.go new file mode 100644 index 000000000..68f41640a --- /dev/null +++ b/tests/core-providers/scenarios/chat_completion_stream.go @@ -0,0 +1,311 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunChatCompletionStreamTest executes the chat completion stream test scenario +func RunChatCompletionStreamTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.CompletionStream { + t.Logf("Chat completion stream not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ChatCompletionStream", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + messages := []schemas.ChatMessage{ + CreateBasicChatMessage("Tell me a short story about a robot learning to paint the city which has the eiffel tower. Keep it under 200 words and include the city's name."), + } + + request := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: messages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + + // Use retry framework for stream requests + retryConfig := StreamingRetryConfig() + retryContext := TestRetryContext{ + ScenarioName: "ChatCompletionStream", + ExpectedBehavior: map[string]interface{}{ + "should_stream_content": true, + "should_tell_story": true, + "topic": "robot painting", + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + }, + } + + // Use proper streaming retry wrapper for the stream request + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.ChatCompletionStreamRequest(ctx, request) + }) + + // Enhanced error handling + RequireNoError(t, err, "Chat completion stream request failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var fullContent strings.Builder + var responseCount int + var lastResponse *schemas.BifrostStream + + // Create a timeout context for the stream reading + streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + t.Logf("πŸ“‘ Starting to read streaming response...") + + // Read streaming responses + for { + select { + case response, ok := <-responseChannel: + if !ok { + // Channel closed, streaming completed + t.Logf("βœ… Streaming completed. Total chunks received: %d", responseCount) + goto streamComplete + } + + if response == nil { + t.Fatal("Streaming response should not be nil") + } + lastResponse = DeepCopyBifrostStream(response) + + // Basic validation of streaming response structure + if response.BifrostChatResponse != nil { + if response.BifrostChatResponse.ExtraFields.Provider != testConfig.Provider { + t.Logf("⚠️ Warning: Provider mismatch - expected %s, got %s", testConfig.Provider, response.BifrostChatResponse.ExtraFields.Provider) + } + if response.BifrostChatResponse.ID == "" { + t.Logf("⚠️ Warning: Response ID is empty") + } + + // Log latency for each chunk (can be 0 for inter-chunks) + t.Logf("πŸ“Š Chunk %d latency: %d ms", responseCount+1, response.BifrostChatResponse.ExtraFields.Latency) + + // Process each choice in the response + for _, choice := range response.BifrostChatResponse.Choices { + // Validate that this is a stream response + if choice.ChatStreamResponseChoice == nil { + t.Logf("⚠️ Warning: Stream response choice is nil for choice %d", choice.Index) + continue + } + if choice.ChatNonStreamResponseChoice != nil { + t.Logf("⚠️ Warning: Non-stream response choice should be nil in streaming response") + } + + // Get content from delta + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil { + delta := choice.ChatStreamResponseChoice.Delta + if delta.Content != nil { + fullContent.WriteString(*delta.Content) + } + + // Log role if present (usually in first chunk) + if delta.Role != nil { + t.Logf("πŸ€– Role: %s", *delta.Role) + } + + // Check finish reason if present + if choice.FinishReason != nil { + t.Logf("🏁 Finish reason: %s", *choice.FinishReason) + } + } + } + } + + responseCount++ + + // Safety check to prevent infinite loops in case of issues + if responseCount > 500 { + t.Fatal("Received too many streaming chunks, something might be wrong") + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for streaming response") + } + } + + streamComplete: + // Validate final streaming response + finalContent := strings.TrimSpace(fullContent.String()) + + // Create a consolidated response for validation + consolidatedResponse := &schemas.BifrostChatResponse{ + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{ + Message: &schemas.ChatMessage{ + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: &finalContent, + }, + }, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: testConfig.Provider, + }, + } + + // Copy usage and other metadata from last response if available + if lastResponse != nil && lastResponse.BifrostChatResponse != nil { + consolidatedResponse.Usage = lastResponse.BifrostChatResponse.Usage + consolidatedResponse.Model = lastResponse.BifrostChatResponse.Model + consolidatedResponse.ID = lastResponse.BifrostChatResponse.ID + consolidatedResponse.Created = lastResponse.BifrostChatResponse.Created + + // Copy finish reason from last choice if available + if len(lastResponse.BifrostChatResponse.Choices) > 0 && lastResponse.BifrostChatResponse.Choices[0].FinishReason != nil { + consolidatedResponse.Choices[0].FinishReason = lastResponse.BifrostChatResponse.Choices[0].FinishReason + } + consolidatedResponse.ExtraFields.Latency = lastResponse.BifrostChatResponse.ExtraFields.Latency + } + + // Enhanced validation expectations for streaming + expectations := GetExpectationsForScenario("ChatCompletionStream", testConfig, map[string]interface{}{}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ShouldContainAnyOf = append(expectations.ShouldContainAnyOf, []string{"paris"}...) // Should include story elements + expectations.MinContentLength = 50 // Should be substantial story + expectations.MaxContentLength = 2000 // Reasonable upper bound + + // Validate the consolidated streaming response + validationResult := ValidateChatResponse(t, consolidatedResponse, nil, expectations, "ChatCompletionStream") + + // Basic streaming validation + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + + if finalContent == "" { + t.Fatal("Final content should not be empty") + } + + if len(finalContent) < 10 { + t.Fatal("Final content should be substantial") + } + + if !validationResult.Passed { + t.Errorf("❌ Streaming validation failed: %v", validationResult.Errors) + } + + t.Logf("πŸ“Š Streaming metrics: %d chunks, %d chars", responseCount, len(finalContent)) + + t.Logf("βœ… Streaming test completed successfully") + t.Logf("πŸ“ Final content (%d chars)", len(finalContent)) + }) + + // Test streaming with tool calls if supported + if testConfig.Scenarios.ToolCalls { + t.Run("ChatCompletionStreamWithTools", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + messages := []schemas.ChatMessage{ + CreateBasicChatMessage("What's the weather like in San Francisco in celsius? Please use the get_weather function."), + } + + tool := GetSampleChatTool(SampleToolTypeWeather) + + request := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: messages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + Tools: []schemas.ChatTool{*tool}, + }, + Fallbacks: testConfig.Fallbacks, + } + + responseChannel, err := client.ChatCompletionStreamRequest(ctx, request) + RequireNoError(t, err, "Chat completion stream with tools failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var toolCallDetected bool + var responseCount int + + streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + t.Logf("πŸ”§ Testing streaming with tool calls...") + + for { + select { + case response, ok := <-responseChannel: + if !ok { + goto toolStreamComplete + } + + if response == nil || response.BifrostChatResponse == nil { + t.Fatal("Streaming response should not be nil") + } + responseCount++ + + if response.BifrostChatResponse.Choices != nil { + for _, choice := range response.BifrostChatResponse.Choices { + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil { + delta := choice.ChatStreamResponseChoice.Delta + + // Check for tool calls in delta + if len(delta.ToolCalls) > 0 { + toolCallDetected = true + t.Logf("πŸ”§ Tool call detected in streaming response") + + for _, toolCall := range delta.ToolCalls { + if toolCall.Function.Name != nil { + t.Logf("πŸ”§ Tool: %s", *toolCall.Function.Name) + if toolCall.Function.Arguments != "" { + t.Logf("πŸ”§ Args: %s", toolCall.Function.Arguments) + } + } + } + } + } + } + } + + if responseCount > 100 { + goto toolStreamComplete + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for streaming response with tools") + } + } + + toolStreamComplete: + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + if !toolCallDetected { + t.Fatal("Should detect tool calls in streaming response") + } + t.Logf("βœ… Streaming with tools test completed successfully") + }) + } +} diff --git a/tests/core-providers/scenarios/complete_end_to_end.go b/tests/core-providers/scenarios/complete_end_to_end.go new file mode 100644 index 000000000..fc2db4acb --- /dev/null +++ b/tests/core-providers/scenarios/complete_end_to_end.go @@ -0,0 +1,423 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunCompleteEnd2EndTest executes the complete end-to-end test scenario +func RunCompleteEnd2EndTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.CompleteEnd2End { + t.Logf("Complete end-to-end not supported for provider %s", testConfig.Provider) + return + } + + t.Run("CompleteEnd2End", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // ============================================================================= + // STEP 1: Multi-step conversation with tools - Test both APIs in parallel + // ============================================================================= + + // Create messages for both APIs + chatUserMessage1 := CreateBasicChatMessage("Hi, I'm planning a trip. Can you help me get the weather in Paris?") + responsesUserMessage1 := CreateBasicResponsesMessage("Hi, I'm planning a trip. Can you help me get the weather in Paris?") + + // Get tools for both APIs + chatTool := GetSampleChatTool(SampleToolTypeWeather) + responsesTool := GetSampleResponsesTool(SampleToolTypeWeather) + + // Use retry framework for first step (tool calling) + retryConfig1 := ToolCallRetryConfig(string(SampleToolTypeWeather)) + retryContext1 := TestRetryContext{ + ScenarioName: "CompleteEnd2End_Step1", + ExpectedBehavior: map[string]interface{}{ + "expected_tool_name": string(SampleToolTypeWeather), + "location": "paris", + "travel_context": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "step": "tool_call_weather", + "scenario": "complete_end_to_end", + }, + } + + // Enhanced validation for first step + expectations1 := ToolCallExpectations(string(SampleToolTypeWeather), []string{"location"}) + expectations1 = ModifyExpectationsForProvider(expectations1, testConfig.Provider) + expectations1.ExpectedToolCalls[0].ArgumentTypes = map[string]string{ + "location": "string", + } + + // Create operations for both APIs + chatOperation1 := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: []schemas.ChatMessage{chatUserMessage1}, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{*chatTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStr: bifrost.Ptr(string(schemas.ChatToolChoiceTypeRequired)), + }, + MaxCompletionTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation1 := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: []schemas.ResponsesMessage{responsesUserMessage1}, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{*responsesTool}, + ToolChoice: &schemas.ResponsesToolChoice{ + ResponsesToolChoiceStr: bifrost.Ptr(string(schemas.ResponsesToolChoiceTypeRequired)), + }, + MaxOutputTokens: bifrost.Ptr(150), + }, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test for Step 1 + result1 := WithDualAPITestRetry(t, + retryConfig1, + retryContext1, + expectations1, + "CompleteEnd2End_Step1", + chatOperation1, + responsesOperation1) + + // Validate both APIs succeeded + if !result1.BothSucceeded { + var errors []string + if result1.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result1.ChatCompletionsError)) + } + if result1.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result1.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ CompleteEnd2End_Step1 dual API test failed: %v", errors) + } + + t.Logf("βœ… Chat Completions API first response: %s", GetChatContent(result1.ChatCompletionsResponse)) + t.Logf("βœ… Responses API first response: %s", GetResponsesContent(result1.ResponsesAPIResponse)) + + // Build conversation histories for both APIs and extract tool calls if present + chatConversationHistory := []schemas.ChatMessage{chatUserMessage1} + responsesConversationHistory := []schemas.ResponsesMessage{responsesUserMessage1} + + // Add all choice messages to Chat Completions conversation history + if result1.ChatCompletionsResponse.Choices != nil { + for _, choice := range result1.ChatCompletionsResponse.Choices { + chatConversationHistory = append(chatConversationHistory, *choice.Message) + } + } + + // Add all output messages to Responses API conversation history + if result1.ResponsesAPIResponse != nil && result1.ResponsesAPIResponse.Output != nil { + responsesConversationHistory = append(responsesConversationHistory, result1.ResponsesAPIResponse.Output...) + } + + // Extract tool calls from both APIs + chatToolCalls := ExtractChatToolCalls(result1.ChatCompletionsResponse) + responsesToolCalls := ExtractResponsesToolCalls(result1.ResponsesAPIResponse) + + // If tool calls were found, simulate the results for both APIs + if len(chatToolCalls) > 0 { + chatToolCall := chatToolCalls[0] + t.Logf("βœ… Chat Completions API weather tool call: %s with args: %s", chatToolCall.Name, chatToolCall.Arguments) + + toolResult := `{"temperature": "18", "unit": "celsius", "description": "Partly cloudy", "humidity": "70%"}` + toolMessage := CreateToolChatMessage(toolResult, chatToolCall.ID) + chatConversationHistory = append(chatConversationHistory, toolMessage) + t.Logf("βœ… Added tool result to Chat Completions conversation history") + } else { + t.Logf("⚠️ No weather tool call found in Chat Completions response, continuing without tool result") + } + + if len(responsesToolCalls) > 0 { + responsesToolCall := responsesToolCalls[0] + t.Logf("βœ… Responses API weather tool call: %s with args: %s", responsesToolCall.Name, responsesToolCall.Arguments) + + toolResult := `{"temperature": "18", "unit": "celsius", "description": "cloudy", "humidity": "70%"}` + toolMessage := CreateToolResponsesMessage(toolResult, responsesToolCall.ID) + responsesConversationHistory = append(responsesConversationHistory, toolMessage) + t.Logf("βœ… Added tool result to Responses API conversation history") + } else { + t.Logf("⚠️ No weather tool call found in Responses API response, continuing without tool result") + } + + // ============================================================================= + // STEP 2: Send this tool call result to the model again + // ============================================================================= + + // Use retry framework for step 2 (processing tool results) + retryConfig2 := GetTestRetryConfigForScenario("CompleteEnd2End_ToolResult", testConfig) + retryContext2 := TestRetryContext{ + ScenarioName: "CompleteEnd2End_Step2", + ExpectedBehavior: map[string]interface{}{ + "process_tool_result": true, + "acknowledge_weather": true, + "continue_conversation": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "step": "process_tool_result", + "scenario": "complete_end_to_end", + "chat_conversation_length": len(chatConversationHistory), + "responses_conversation_length": len(responsesConversationHistory), + }, + } + + // Enhanced validation for step 2 - should acknowledge tool results + expectations2 := ConversationExpectations([]string{"weather", "temperature"}) + expectations2 = ModifyExpectationsForProvider(expectations2, testConfig.Provider) + expectations2.MinContentLength = 15 // Should provide meaningful response to tool result + expectations2.MaxContentLength = 500 // Reasonable upper bound for tool result processing + expectations2.ShouldNotContainWords = []string{ + "cannot help", "don't understand", "no information", + "unable to process", "invalid tool result", + } // Should not indicate confusion about tool results + + // Create operations for both APIs - Step 2 (processing tool results) + chatOperation2 := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: chatConversationHistory, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation2 := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: responsesConversationHistory, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(200), + }, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test for Step 2 (processing tool results) + result2 := WithDualAPITestRetry(t, + retryConfig2, + retryContext2, + expectations2, + "CompleteEnd2End_Step2", + chatOperation2, + responsesOperation2) + + // Validate both APIs succeeded + if !result2.BothSucceeded { + var errors []string + if result2.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result2.ChatCompletionsError)) + } + if result2.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result2.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ CompleteEnd2End_Step2 dual API test failed: %v", errors) + } + + t.Logf("βœ… Chat Completions API tool result response: %s", GetChatContent(result2.ChatCompletionsResponse)) + t.Logf("βœ… Responses API tool result response: %s", GetResponsesContent(result2.ResponsesAPIResponse)) + + // Add Step 2 responses to conversation histories for Step 3 + if result2.ChatCompletionsResponse.Choices != nil { + for _, choice := range result2.ChatCompletionsResponse.Choices { + chatConversationHistory = append(chatConversationHistory, *choice.Message) + } + } + + if result2.ResponsesAPIResponse != nil && result2.ResponsesAPIResponse.Output != nil { + responsesConversationHistory = append(responsesConversationHistory, result2.ResponsesAPIResponse.Output...) + } + + // ============================================================================= + // STEP 3: Continue with follow-up (multimodal if supported) - Test both APIs + // ============================================================================= + + // Determine if we're doing a vision step + isVisionStep := testConfig.Scenarios.ImageURL + + // Create follow-up messages for both APIs + var chatFollowUpMessage schemas.ChatMessage + var responsesFollowUpMessage schemas.ResponsesMessage + + if isVisionStep { + chatFollowUpMessage = CreateImageChatMessage("Thanks! Now can you tell me what you see in this travel-related image? Please provide some travel advice about this destination.", TestImageURL2) + responsesFollowUpMessage = CreateImageResponsesMessage("Thanks! Now can you tell me what you see in this travel-related image? Please provide some travel advice about this destination.", TestImageURL2) + } else { + chatFollowUpMessage = CreateBasicChatMessage("Thanks for the weather info! Given that it's cloudy in Paris, can you tell me more about this travel location?") + responsesFollowUpMessage = CreateBasicResponsesMessage("Thanks for the weather info! Given that it's cloudy in Paris, can you tell me more about this travel location?") + } + + chatConversationHistory = append(chatConversationHistory, chatFollowUpMessage) + responsesConversationHistory = append(responsesConversationHistory, responsesFollowUpMessage) + + model := testConfig.ChatModel + if isVisionStep { + model = testConfig.VisionModel + } + + // Use appropriate retry config for final step + var retryConfig3 TestRetryConfig + var expectations3 ResponseExpectations + + if isVisionStep { + retryConfig3 = GetTestRetryConfigForScenario("CompleteEnd2End_Vision", testConfig) + expectations3 = VisionExpectations([]string{"paris", "river"}) + } else { + retryConfig3 = GetTestRetryConfigForScenario("CompleteEnd2End_Chat", testConfig) + expectations3 = ConversationExpectations([]string{"paris", "cloudy"}) + } + + // Prepare expected keywords to match expectations exactly + var expectedKeywords []string + if isVisionStep { + expectedKeywords = []string{"paris", "river"} // Must match VisionExpectations exactly + } else { + expectedKeywords = []string{"paris", "cloudy"} // Must match ConversationExpectations exactly + } + + retryContext3 := TestRetryContext{ + ScenarioName: "CompleteEnd2End_Step3", + ExpectedBehavior: map[string]interface{}{ + "continue_conversation": true, + "acknowledge_context": true, + "vision_processing": isVisionStep, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": model, + "step": "final_response", + "has_vision": isVisionStep, + "chat_conversation_length": len(chatConversationHistory), + "responses_conversation_length": len(responsesConversationHistory), + "expected_keywords": expectedKeywords, // 🎯 Must match VisionExpectations exactly + }, + } + + // Enhanced validation for final response + expectations3 = ModifyExpectationsForProvider(expectations3, testConfig.Provider) + expectations3.MinContentLength = 20 // Should provide some meaningful response + expectations3.MaxContentLength = 800 // End-to-end can be verbose + expectations3.ShouldNotContainWords = []string{ + "cannot help", "don't understand", "confused", + "start over", "reset conversation", + } // Context loss indicators + + // Create operations for both APIs - Step 3 + chatOperation3 := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: model, + Input: chatConversationHistory, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation3 := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: model, + Input: responsesConversationHistory, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(200), + }, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test for Step 3 + result3 := WithDualAPITestRetry(t, + retryConfig3, + retryContext3, + expectations3, + "CompleteEnd2End_Step3", + chatOperation3, + responsesOperation3) + + // Validate both APIs succeeded + if !result3.BothSucceeded { + var errors []string + if result3.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result3.ChatCompletionsError)) + } + if result3.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result3.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ CompleteEnd2End_Step3 dual API test failed: %v", errors) + } + + // Log and validate results from both APIs + if result3.ChatCompletionsResponse != nil { + chatFinalContent := GetChatContent(result3.ChatCompletionsResponse) + + // Additional validation for conversation context + if len(chatToolCalls) > 0 && strings.Contains(strings.ToLower(chatFinalContent), "weather") { + t.Logf("βœ… Chat Completions API maintained weather context from previous step") + } + + if isVisionStep && len(chatFinalContent) > 30 { + t.Logf("βœ… Chat Completions API processed vision request with substantial response") + } + + t.Logf("βœ… Chat Completions API final result: %s", chatFinalContent) + } + + if result3.ResponsesAPIResponse != nil { + responsesFinalContent := GetResponsesContent(result3.ResponsesAPIResponse) + + // Additional validation for conversation context + if len(responsesToolCalls) > 0 && strings.Contains(strings.ToLower(responsesFinalContent), "weather") { + t.Logf("βœ… Responses API maintained weather context from previous step") + } + + if isVisionStep && len(responsesFinalContent) > 30 { + t.Logf("βœ… Responses API processed vision request with substantial response") + } + + t.Logf("βœ… Responses API final result: %s", responsesFinalContent) + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed CompleteEnd2End test!") + }) +} diff --git a/tests/core-providers/scenarios/cross_provider_scenarios.go b/tests/core-providers/scenarios/cross_provider_scenarios.go new file mode 100644 index 000000000..70c5504c1 --- /dev/null +++ b/tests/core-providers/scenarios/cross_provider_scenarios.go @@ -0,0 +1,1088 @@ +package scenarios + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// ============================================================================= +// CORE DATA STRUCTURES +// ============================================================================= + +// MessageModality defines the type of interaction required +type MessageModality string + +const ( + ModalityText MessageModality = "text" + ModalityTool MessageModality = "tool" + ModalityVision MessageModality = "vision" + ModalityReasoning MessageModality = "reasoning" +) + +// ValidationLevel defines how strict the evaluation should be +type ValidationLevel string + +const ( + ValidationStrict ValidationLevel = "strict" + ValidationModerate ValidationLevel = "moderate" + ValidationLenient ValidationLevel = "lenient" +) + +// CrossProviderTestConfig configures the entire test +type CrossProviderTestConfig struct { + Providers []ProviderConfig + ConversationSettings ConversationSettings + TestSettings TestSettings +} + +// ProviderConfig defines a provider's capabilities +type ProviderConfig struct { + Provider schemas.ModelProvider + ChatModel string + VisionModel string + ToolsSupported bool + VisionSupported bool + StreamSupported bool + Available bool +} + +// ConversationSettings controls conversation generation +type ConversationSettings struct { + MaxMessages int + ConversationGeneratorModel string + RequiredMessageTypes []MessageModality +} + +// TestSettings controls test execution +type TestSettings struct { + EnableRetries bool + MaxRetriesPerMessage int + ValidationStrength ValidationLevel +} + +// CrossProviderScenario defines a complete test scenario +type CrossProviderScenario struct { + Name string + Description string + InitialMessage string + ExpectedFlow []ScenarioStep + MaxMessages int + RequiredModalities []MessageModality + SuccessCriteria ScenarioSuccess +} + +// ScenarioStep defines a single step in the scenario +type ScenarioStep struct { + StepNumber int + ExpectedAction string + RequiredModality MessageModality + SuccessCriteria StepSuccess +} + +// StepSuccess defines validation criteria for a step +type StepSuccess struct { + MustContainKeywords []string + MustNotContainWords []string + ExpectedToolCalls []string + RequiresDataExtraction bool + QualityThreshold float64 +} + +// ScenarioSuccess defines overall scenario success criteria +type ScenarioSuccess struct { + MinStepsCompleted int + RequiredModalities []MessageModality + OverallQualityScore float64 + MustCompleteGoal bool +} + +// ============================================================================= +// PREDEFINED SCENARIOS +// ============================================================================= + +// GetPredefinedScenarios returns all available test scenarios +func GetPredefinedScenarios() []CrossProviderScenario { + return []CrossProviderScenario{ + { + Name: "FlightBooking", + Description: "Complete flight booking from search to confirmation with tools and vision", + InitialMessage: "Hi! I need to book a flight from New York to London for next Friday. Can you help me find options and handle the booking process?", + ExpectedFlow: []ScenarioStep{ + { + StepNumber: 1, + ExpectedAction: "Search for flights and show options", + RequiredModality: ModalityTool, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"new york", "london", "friday", "flight", "search"}, + ExpectedToolCalls: []string{"weather"}, // Using available weather tool as proxy + QualityThreshold: 0.7, + }, + }, + { + StepNumber: 2, + ExpectedAction: "Analyze seat map and layout", + RequiredModality: ModalityVision, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"seat", "layout", "map", "selection"}, + QualityThreshold: 0.7, + }, + }, + { + StepNumber: 3, + ExpectedAction: "Calculate total cost and handle booking", + RequiredModality: ModalityTool, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"cost", "total", "booking", "confirmation"}, + ExpectedToolCalls: []string{"calculate"}, + QualityThreshold: 0.7, + }, + }, + }, + MaxMessages: 12, + RequiredModalities: []MessageModality{ModalityTool, ModalityVision}, + SuccessCriteria: ScenarioSuccess{ + MinStepsCompleted: 2, + RequiredModalities: []MessageModality{ModalityTool, ModalityVision}, + OverallQualityScore: 0.7, + MustCompleteGoal: true, + }, + }, + + { + Name: "RestaurantReservation", + Description: "Make restaurant reservation with dietary requirements and menu analysis", + InitialMessage: "I want to make a dinner reservation for 4 people tomorrow at 7 PM. We have dietary restrictions - one person is gluten-free and another is vegetarian.", + ExpectedFlow: []ScenarioStep{ + { + StepNumber: 1, + ExpectedAction: "Search for restaurants with dietary filters", + RequiredModality: ModalityTool, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"restaurant", "4 people", "7 pm", "gluten-free", "vegetarian"}, + ExpectedToolCalls: []string{"weather"}, // Proxy for restaurant search + QualityThreshold: 0.7, + }, + }, + { + StepNumber: 2, + ExpectedAction: "Analyze menu for dietary compatibility", + RequiredModality: ModalityVision, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"menu", "dietary", "gluten-free", "vegetarian"}, + QualityThreshold: 0.7, + }, + }, + { + StepNumber: 3, + ExpectedAction: "Complex reasoning about best restaurant choice", + RequiredModality: ModalityReasoning, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"recommendation", "choice", "suitable", "reservation"}, + QualityThreshold: 0.7, + }, + }, + }, + MaxMessages: 15, + RequiredModalities: []MessageModality{ModalityTool, ModalityVision, ModalityReasoning}, + SuccessCriteria: ScenarioSuccess{ + MinStepsCompleted: 2, + RequiredModalities: []MessageModality{ModalityTool, ModalityVision}, + OverallQualityScore: 0.7, + MustCompleteGoal: true, + }, + }, + + { + Name: "EventPlanning", + Description: "Plan a corporate event with budget analysis, venue selection, and timeline", + InitialMessage: "Help me plan a corporate team building event for 50 people with a budget of $10,000. I need venue, catering, activities, and a detailed timeline.", + ExpectedFlow: []ScenarioStep{ + { + StepNumber: 1, + ExpectedAction: "Calculate budget breakdown", + RequiredModality: ModalityTool, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"budget", "50 people", "10000", "breakdown"}, + ExpectedToolCalls: []string{"calculate"}, + QualityThreshold: 0.7, + }, + }, + { + StepNumber: 2, + ExpectedAction: "Analyze venue layouts and capacity", + RequiredModality: ModalityVision, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"venue", "layout", "capacity", "50 people"}, + QualityThreshold: 0.7, + }, + }, + { + StepNumber: 3, + ExpectedAction: "Create comprehensive timeline with dependencies", + RequiredModality: ModalityReasoning, + SuccessCriteria: StepSuccess{ + MustContainKeywords: []string{"timeline", "schedule", "dependencies", "planning"}, + QualityThreshold: 0.8, + }, + }, + }, + MaxMessages: 18, + RequiredModalities: []MessageModality{ModalityTool, ModalityVision, ModalityReasoning}, + SuccessCriteria: ScenarioSuccess{ + MinStepsCompleted: 3, + RequiredModalities: []MessageModality{ModalityTool, ModalityVision, ModalityReasoning}, + OverallQualityScore: 0.75, + MustCompleteGoal: true, + }, + }, + } +} + +// ============================================================================= +// ROUND-ROBIN PROVIDER MANAGER +// ============================================================================= + +// ProviderRoundRobin manages provider selection and tracking +type ProviderRoundRobin struct { + providers []ProviderConfig + currentIndex int + usageStats map[schemas.ModelProvider]int + skipStats map[schemas.ModelProvider]int + logger *testing.T +} + +// NewProviderRoundRobin creates a new round-robin manager +func NewProviderRoundRobin(providers []ProviderConfig, t *testing.T) *ProviderRoundRobin { + availableProviders := filterAvailableProviders(providers, t) + return &ProviderRoundRobin{ + providers: availableProviders, + currentIndex: 0, + usageStats: make(map[schemas.ModelProvider]int), + skipStats: make(map[schemas.ModelProvider]int), + logger: t, + } +} + +// GetNextProviderForModality returns the next provider that supports the required modality +func (prr *ProviderRoundRobin) GetNextProviderForModality(modality MessageModality) (ProviderConfig, error) { + if len(prr.providers) == 0 { + return ProviderConfig{}, fmt.Errorf("no available providers") + } + + startIndex := prr.currentIndex + attempts := 0 + + for { + if attempts >= len(prr.providers) { + // All providers tried, return best available + provider := prr.providers[prr.currentIndex] + prr.advanceIndex() + prr.usageStats[provider.Provider]++ + prr.logger.Logf("⚠️ No ideal provider for %s, using %s", modality, provider.Provider) + return provider, nil + } + + provider := prr.providers[prr.currentIndex] + + if prr.providerSupportsModality(provider, modality) { + prr.logger.Logf("βœ… Selected %s for %s modality", provider.Provider, modality) + prr.advanceIndex() + prr.usageStats[provider.Provider]++ + return provider, nil + } + + // Skip this provider + prr.skipStats[provider.Provider]++ + prr.logger.Logf("⏭️ Skipping %s (no %s support)", provider.Provider, modality) + prr.advanceIndex() + attempts++ + + if prr.currentIndex == startIndex && attempts > 0 { + break + } + } + + return ProviderConfig{}, fmt.Errorf("no provider supports modality %s", modality) +} + +func (prr *ProviderRoundRobin) providerSupportsModality(provider ProviderConfig, modality MessageModality) bool { + switch modality { + case ModalityVision: + return provider.VisionSupported && provider.VisionModel != "" + case ModalityTool: + return provider.ToolsSupported + case ModalityText, ModalityReasoning: + return true // All providers support text and reasoning + default: + return true + } +} + +func (prr *ProviderRoundRobin) advanceIndex() { + prr.currentIndex = (prr.currentIndex + 1) % len(prr.providers) +} + +func (prr *ProviderRoundRobin) GetUsageStats() map[schemas.ModelProvider]int { + return prr.usageStats +} + +// filterAvailableProviders checks which providers are actually available +func filterAvailableProviders(providers []ProviderConfig, t *testing.T) []ProviderConfig { + var available []ProviderConfig + for _, provider := range providers { + if provider.Available { + available = append(available, provider) + t.Logf("βœ… Provider %s available for cross-provider testing", provider.Provider) + } else { + t.Logf("⚠️ Provider %s skipped (marked unavailable)", provider.Provider) + } + } + return available +} + +// ============================================================================= +// OPENAI JUDGE SYSTEM +// ============================================================================= + +// OpenAIJudge evaluates responses using OpenAI +type OpenAIJudge struct { + client *bifrost.Bifrost + judgeModel string + logger *testing.T +} + +// EvaluationRequest contains data for evaluation +type EvaluationRequest struct { + ScenarioContext string + UserMessage string + LLMResponse string + Provider schemas.ModelProvider + Criteria StepSuccess + APIType string // "chat" or "responses" +} + +// EvaluationResult contains evaluation results +type EvaluationResult struct { + Passed bool `json:"passed"` + Score float64 `json:"score"` + KeywordCheck string `json:"keyword_check"` + ForbiddenCheck string `json:"forbidden_check"` + ToolCheck string `json:"tool_check"` + QualityAssessment string `json:"quality_assessment"` + Suggestions string `json:"suggestions"` + FatalIssues []string `json:"fatal_issues"` +} + +// NewOpenAIJudge creates a new judge instance +func NewOpenAIJudge(client *bifrost.Bifrost, judgeModel string, t *testing.T) *OpenAIJudge { + return &OpenAIJudge{ + client: client, + judgeModel: judgeModel, + logger: t, + } +} + +// EvaluateResponse judges an LLM response +func (judge *OpenAIJudge) EvaluateResponse(ctx context.Context, evaluation EvaluationRequest) (*EvaluationResult, error) { + prompt := fmt.Sprintf(`You are an expert AI system evaluator. Evaluate this LLM response. + +SCENARIO: %s +USER MESSAGE: %s +LLM RESPONSE: %s +PROVIDER: %s +API TYPE: %s + +CRITERIA: +- Must contain keywords: %v +- Must NOT contain: %v +- Expected tool calls: %v +- Quality threshold: %.2f + +Rate 0-100 points across 4 categories: +1. Keyword presence (0-30 points) +2. Avoids forbidden words (0-20 points) +3. Appropriate tool usage (0-25 points) +4. Overall quality/helpfulness (0-25 points) + +Respond with JSON: +{ + "passed": true/false, + "score": 0.0-1.0, + "keyword_check": "details", + "forbidden_check": "details", + "tool_check": "details", + "quality_assessment": "analysis", + "suggestions": "improvements", + "fatal_issues": ["serious problems"] +}`, + evaluation.ScenarioContext, evaluation.UserMessage, evaluation.LLMResponse, + evaluation.Provider, evaluation.APIType, + evaluation.Criteria.MustContainKeywords, evaluation.Criteria.MustNotContainWords, + evaluation.Criteria.ExpectedToolCalls, evaluation.Criteria.QualityThreshold) + + request := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: judge.judgeModel, + Input: []schemas.ChatMessage{ + CreateBasicChatMessage(prompt), + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(600), + Temperature: bifrost.Ptr(0.1), + }, + } + + response, err := judge.client.ChatCompletionRequest(ctx, request) + if err != nil { + return nil, fmt.Errorf("judge evaluation failed: %v", GetErrorMessage(err)) + } + + content := GetChatContent(response) + var result EvaluationResult + + if err := parseJudgeResponse(content, &result); err != nil { + judge.logger.Logf("⚠️ Failed to parse judge response, using fallback") + return judge.fallbackEvaluation(evaluation), nil + } + + judge.logger.Logf("πŸ” Judge: %.2f | %s", result.Score, + truncateString(result.QualityAssessment, 100)) + return &result, nil +} + +func (judge *OpenAIJudge) fallbackEvaluation(evaluation EvaluationRequest) *EvaluationResult { + // Simple keyword-based fallback + response := strings.ToLower(evaluation.LLMResponse) + keywordScore := 0.0 + for _, keyword := range evaluation.Criteria.MustContainKeywords { + if strings.Contains(response, strings.ToLower(keyword)) { + keywordScore += 1.0 + } + } + if len(evaluation.Criteria.MustContainKeywords) > 0 { + keywordScore /= float64(len(evaluation.Criteria.MustContainKeywords)) + } else { + keywordScore = 1.0 + } + + return &EvaluationResult{ + Passed: keywordScore >= 0.5, + Score: keywordScore, + KeywordCheck: fmt.Sprintf("Fallback evaluation: %.1f%% keywords found", keywordScore*100), + QualityAssessment: "Fallback evaluation used due to judge parsing error", + Suggestions: "Manual review recommended", + } +} + +func parseJudgeResponse(content string, result *EvaluationResult) error { + // Extract JSON from the response + start := strings.Index(content, "{") + end := strings.LastIndex(content, "}") + + if start == -1 || end == -1 { + return fmt.Errorf("no JSON found in response") + } + + jsonStr := content[start : end+1] + return json.Unmarshal([]byte(jsonStr), result) +} + +// ============================================================================= +// CONVERSATION DRIVER +// ============================================================================= + +// OpenAIConversationDriver generates followup messages +type OpenAIConversationDriver struct { + client *bifrost.Bifrost + driverModel string + logger *testing.T +} + +// NextMessageRequest contains data for generating next message +type NextMessageRequest struct { + Scenario CrossProviderScenario + ConversationHistory []schemas.ChatMessage + CurrentStepNumber int + NextStep ScenarioStep + PreviousEvaluation *EvaluationResult + APIType string // "chat" or "responses" +} + +// GeneratedFollowup contains the generated followup message +type GeneratedFollowup struct { + UserMessage string `json:"user_message"` + ModalityContext string `json:"modality_context"` + ExpectedBehavior string `json:"expected_behavior"` + TestFocus string `json:"test_focus"` +} + +// NewOpenAIConversationDriver creates a new conversation driver +func NewOpenAIConversationDriver(client *bifrost.Bifrost, driverModel string, t *testing.T) *OpenAIConversationDriver { + return &OpenAIConversationDriver{ + client: client, + driverModel: driverModel, + logger: t, + } +} + +// GenerateNextMessage creates a natural followup message +func (driver *OpenAIConversationDriver) GenerateNextMessage(ctx context.Context, request NextMessageRequest) (*GeneratedFollowup, error) { + conversationHistory := driver.formatConversationHistory(request.ConversationHistory) + + prompt := fmt.Sprintf(`Generate the next realistic user message for a %s scenario. + +SCENARIO: %s +API TYPE: %s +STEP %d: %s (requires %s) +CONVERSATION SO FAR: +%s + +Generate a natural followup that: +- Flows naturally from conversation +- Tests %s modality specifically +- Is realistic and engaging +- For vision: request image/document analysis +- For tools: ask for calculations/lookups +- For reasoning: require complex thinking + +JSON response: +{ + "user_message": "actual message", + "modality_context": "why this modality fits", + "expected_behavior": "what AI should do", + "test_focus": "what capability this tests" +}`, + request.Scenario.Name, request.Scenario.Description, request.APIType, + request.CurrentStepNumber+1, request.NextStep.ExpectedAction, request.NextStep.RequiredModality, + conversationHistory, request.NextStep.RequiredModality) + + llmRequest := &schemas.BifrostChatRequest{ + Provider: schemas.OpenAI, + Model: driver.driverModel, + Input: []schemas.ChatMessage{ + CreateBasicChatMessage(prompt), + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(300), + Temperature: bifrost.Ptr(0.7), + }, + } + + response, err := driver.client.ChatCompletionRequest(ctx, llmRequest) + if err != nil { + return nil, fmt.Errorf("failed to generate next message: %v", GetErrorMessage(err)) + } + + content := GetChatContent(response) + var followup GeneratedFollowup + + if err := parseDriverResponse(content, &followup); err != nil { + driver.logger.Logf("⚠️ Driver parse failed, using fallback") + return driver.generateFallbackMessage(request), nil + } + + driver.logger.Logf("πŸ’­ Generated: %s", truncateString(followup.UserMessage, 80)) + return &followup, nil +} + +func (driver *OpenAIConversationDriver) formatConversationHistory(history []schemas.ChatMessage) string { + var formatted []string + for i, msg := range history { + role := "Unknown" + content := "No content" + + if msg.Role == schemas.ChatMessageRoleUser { + role = "User" + } else if msg.Role == schemas.ChatMessageRoleAssistant { + role = "AI" + } + + if msg.Content.ContentStr != nil { + content = *msg.Content.ContentStr + } + + formatted = append(formatted, fmt.Sprintf("%d. %s: %s", + i+1, role, truncateString(content, 100))) + } + return strings.Join(formatted, "\n") +} + +func parseDriverResponse(content string, followup *GeneratedFollowup) error { + start := strings.Index(content, "{") + end := strings.LastIndex(content, "}") + + if start == -1 || end == -1 { + return fmt.Errorf("no JSON found") + } + + jsonStr := content[start : end+1] + return json.Unmarshal([]byte(jsonStr), followup) +} + +func (driver *OpenAIConversationDriver) generateFallbackMessage(request NextMessageRequest) *GeneratedFollowup { + fallbacks := map[MessageModality]string{ + ModalityTool: "Can you help me with some calculations or data lookup for this?", + ModalityVision: "I have an image/document I'd like you to analyze. What do you see?", + ModalityReasoning: "This requires careful thinking. Can you walk me through the reasoning step by step?", + ModalityText: "Can you provide more details about this?", + } + + return &GeneratedFollowup{ + UserMessage: fallbacks[request.NextStep.RequiredModality], + ModalityContext: fmt.Sprintf("Fallback message for %s", request.NextStep.RequiredModality), + ExpectedBehavior: "Handle the request appropriately", + TestFocus: fmt.Sprintf("Test %s capability", request.NextStep.RequiredModality), + } +} + +// ============================================================================= +// MAIN EXECUTION ENGINE +// ============================================================================= + +// RunCrossProviderScenarioTest executes a complete scenario +func RunCrossProviderScenarioTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, config CrossProviderTestConfig, scenario CrossProviderScenario, useResponsesAPI bool) { + apiType := "Chat Completions" + if useResponsesAPI { + apiType = "Responses API" + } + + t.Logf("🎬 Starting scenario: %s (%s)", scenario.Name, apiType) + + // Initialize components + roundRobin := NewProviderRoundRobin(config.Providers, t) + judge := NewOpenAIJudge(client, "gpt-4o-mini", t) + driver := NewOpenAIConversationDriver(client, config.ConversationSettings.ConversationGeneratorModel, t) + + // Start conversation + var conversationHistory []schemas.ChatMessage + var evaluationResults []EvaluationResult + + // Add initial user message + initialMsg := CreateBasicChatMessage(scenario.InitialMessage) + conversationHistory = append(conversationHistory, initialMsg) + t.Logf("πŸ‘€ User: %s", truncateString(scenario.InitialMessage, 100)) + + // Execute conversation steps + for stepNum := 0; stepNum < len(scenario.ExpectedFlow) && len(conversationHistory) < scenario.MaxMessages*2; stepNum++ { + currentStep := scenario.ExpectedFlow[stepNum] + + // Get next provider + provider, err := roundRobin.GetNextProviderForModality(currentStep.RequiredModality) + if err != nil { + t.Fatalf("❌ No provider for %s: %v", currentStep.RequiredModality, err) + } + + t.Logf("πŸ”„ Step %d: %s -> %s (%s)", stepNum+1, provider.Provider, + currentStep.ExpectedAction, currentStep.RequiredModality) + + // Execute request + response, llmErr := executeStepWithProvider(t, client, ctx, provider, + conversationHistory, currentStep, useResponsesAPI) + if llmErr != nil { + t.Fatalf("❌ Step %d failed: %v", stepNum+1, GetErrorMessage(llmErr)) + } + + var responseContent string + // Add response to history + if useResponsesAPI && response.ResponsesResponse != nil { + // Convert Responses API output back to ChatMessages for history + assistantMessages := schemas.ToChatMessages(response.ResponsesResponse.Output) + conversationHistory = append(conversationHistory, assistantMessages...) + responseContent = GetResponsesContent(response.ResponsesResponse) + } else { + if response.ChatResponse != nil { + // Use Chat API choices + for _, choice := range response.ChatResponse.Choices { + if choice.Message != nil { + conversationHistory = append(conversationHistory, *choice.Message) + } + } + responseContent = GetChatContent(response.ChatResponse) + } + } + + t.Logf("πŸ€– %s: %s", provider.Provider, truncateString(responseContent, 120)) + + // Evaluate with judge + evaluation, evalErr := judge.EvaluateResponse(ctx, EvaluationRequest{ + ScenarioContext: scenario.Description, + UserMessage: getLastUserMessage(conversationHistory), + LLMResponse: responseContent, + Provider: provider.Provider, + Criteria: currentStep.SuccessCriteria, + APIType: apiType, + }) + + if evalErr != nil { + t.Logf("⚠️ Evaluation failed: %v", evalErr) + continue + } + + evaluationResults = append(evaluationResults, *evaluation) + + // Check step result + if !evaluation.Passed { + t.Logf("❌ Step %d FAILED (%.2f): %s", stepNum+1, evaluation.Score, + evaluation.QualityAssessment) + if len(evaluation.FatalIssues) > 0 { + t.Fatalf("πŸ’€ Fatal issues: %v", evaluation.FatalIssues) + } + } else { + t.Logf("βœ… Step %d PASSED (%.2f)", stepNum+1, evaluation.Score) + } + + // Generate next message if not final step + if stepNum < len(scenario.ExpectedFlow)-1 { + nextStep := scenario.ExpectedFlow[stepNum+1] + followup, driverErr := driver.GenerateNextMessage(ctx, NextMessageRequest{ + Scenario: scenario, + ConversationHistory: conversationHistory, + CurrentStepNumber: stepNum + 1, + NextStep: nextStep, + PreviousEvaluation: evaluation, + APIType: apiType, + }) + + if driverErr != nil { + t.Logf("⚠️ Driver failed: %v", driverErr) + break + } + + // Create appropriate message for modality + nextUserMessage := createModalityMessage(followup.UserMessage, nextStep.RequiredModality) + conversationHistory = append(conversationHistory, nextUserMessage) + t.Logf("πŸ‘€ User: %s", truncateString(followup.UserMessage, 100)) + } + } + + // Final evaluation + finalSuccess := evaluateScenarioSuccess(evaluationResults, scenario.SuccessCriteria) + if finalSuccess { + t.Logf("πŸŽ‰ Scenario %s (%s) COMPLETED SUCCESSFULLY!", scenario.Name, apiType) + } else { + t.Fatalf("❌ Scenario %s (%s) FAILED", scenario.Name, apiType) + } + + // Print summary + printScenarioSummary(t, scenario, evaluationResults, roundRobin.GetUsageStats(), apiType) +} + +// ============================================================================= +// CONSISTENCY TESTING +// ============================================================================= + +// RunCrossProviderConsistencyTest tests same prompt across providers +func RunCrossProviderConsistencyTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, config CrossProviderTestConfig, useResponsesAPI bool) { + apiType := "Chat Completions" + if useResponsesAPI { + apiType = "Responses API" + } + + t.Logf("πŸ”„ Cross-provider consistency test (%s)", apiType) + + // Test prompt + testPrompt := "Explain the concept of artificial intelligence in exactly 3 sentences, covering its definition, current applications, and future potential." + + var results []ConsistencyResult + + for _, provider := range config.Providers { + if !provider.Available { + continue + } + + t.Logf("Testing %s...", provider.Provider) + + var content string + + if useResponsesAPI { + // Use Responses API + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: provider.Provider, + Model: provider.ChatModel, + Input: []schemas.ResponsesMessage{ + CreateBasicResponsesMessage(testPrompt), + }, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(200), + Temperature: bifrost.Ptr(0.3), + }, + } + responsesResponse, err := client.ResponsesRequest(ctx, responsesReq) + if err != nil { + t.Logf("❌ %s failed: %v", provider.Provider, GetErrorMessage(err)) + continue + } + content = GetResponsesContent(responsesResponse) + } else { + // Use Chat Completions API + chatReq := &schemas.BifrostChatRequest{ + Provider: provider.Provider, + Model: provider.ChatModel, + Input: []schemas.ChatMessage{ + CreateBasicChatMessage(testPrompt), + }, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + Temperature: bifrost.Ptr(0.3), + }, + } + chatResponse, err := client.ChatCompletionRequest(ctx, chatReq) + if err != nil { + t.Logf("❌ %s failed: %v", provider.Provider, GetErrorMessage(err)) + continue + } + content = GetChatContent(chatResponse) + } + + sentences := strings.Split(strings.TrimSpace(content), ".") + + result := ConsistencyResult{ + Provider: provider.Provider, + Response: content, + SentenceCount: len(sentences) - 1, // Last split is usually empty + WordCount: len(strings.Fields(content)), + ContainsAI: strings.Contains(strings.ToLower(content), "artificial intelligence"), + ContainsFuture: strings.Contains(strings.ToLower(content), "future"), + } + + results = append(results, result) + t.Logf("βœ… %s: %d sentences, %d words", provider.Provider, result.SentenceCount, result.WordCount) + } + + // Analyze consistency + analyzeConsistency(t, results, apiType) +} + +type ConsistencyResult struct { + Provider schemas.ModelProvider + Response string + SentenceCount int + WordCount int + ContainsAI bool + ContainsFuture bool +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +func executeStepWithProvider(t *testing.T, client *bifrost.Bifrost, ctx context.Context, + provider ProviderConfig, history []schemas.ChatMessage, step ScenarioStep, useResponsesAPI bool) (*schemas.BifrostResponse, *schemas.BifrostError) { + + // Prepare request parameters + var tools []schemas.ChatTool + if step.RequiredModality == ModalityTool { + tools = []schemas.ChatTool{ + *GetSampleChatTool(SampleToolTypeWeather), + *GetSampleChatTool(SampleToolTypeCalculate), + } + } + + if useResponsesAPI { + // Convert to Responses format + var responsesMessages []schemas.ResponsesMessage + for _, msg := range history { + convertedMessages := msg.ToResponsesMessages() + responsesMessages = append(responsesMessages, convertedMessages...) + } + + request := &schemas.BifrostResponsesRequest{ + Provider: provider.Provider, + Model: getModelForModality(provider, step.RequiredModality), + Input: responsesMessages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(300), + Temperature: bifrost.Ptr(0.7), + }, + } + + // Add tools if needed + if len(tools) > 0 { + responsesTools := make([]schemas.ResponsesTool, len(tools)) + for i, tool := range tools { + responsesTools[i] = *tool.ToResponsesTool() + } + request.Params.Tools = responsesTools + } + + responsesResponse, err := client.ResponsesRequest(ctx, request) + if err != nil { + return nil, err + } + return &schemas.BifrostResponse{ResponsesResponse: responsesResponse}, nil + } else { + // Use Chat Completions API + request := &schemas.BifrostChatRequest{ + Provider: provider.Provider, + Model: getModelForModality(provider, step.RequiredModality), + Input: history, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(300), + Temperature: bifrost.Ptr(0.7), + }, + } + + if len(tools) > 0 { + request.Params.Tools = tools + } + + chatResponse, err := client.ChatCompletionRequest(ctx, request) + if err != nil { + return nil, err + } + return &schemas.BifrostResponse{ChatResponse: chatResponse}, nil + } +} + +func getModelForModality(provider ProviderConfig, modality MessageModality) string { + if modality == ModalityVision && provider.VisionModel != "" { + return provider.VisionModel + } + return provider.ChatModel +} + +func createModalityMessage(message string, modality MessageModality) schemas.ChatMessage { + switch modality { + case ModalityVision: + // Add test image for vision + if lionBase64, err := GetLionBase64Image(); err == nil { + return CreateImageChatMessage(message, lionBase64) + } + return CreateBasicChatMessage(message + " [Image analysis requested]") + default: + return CreateBasicChatMessage(message) + } +} + +func getLastUserMessage(history []schemas.ChatMessage) string { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == schemas.ChatMessageRoleUser { + if history[i].Content.ContentStr != nil { + return *history[i].Content.ContentStr + } + } + } + return "Previous user message" +} + +func evaluateScenarioSuccess(results []EvaluationResult, criteria ScenarioSuccess) bool { + if len(results) < criteria.MinStepsCompleted { + return false + } + + totalScore := 0.0 + passedSteps := 0 + for _, result := range results { + totalScore += result.Score + if result.Passed { + passedSteps++ + } + } + + avgScore := totalScore / float64(len(results)) + return avgScore >= criteria.OverallQualityScore && passedSteps >= criteria.MinStepsCompleted +} + +func printScenarioSummary(t *testing.T, scenario CrossProviderScenario, results []EvaluationResult, + usage map[schemas.ModelProvider]int, apiType string) { + + t.Logf("\n%s", strings.Repeat("=", 80)) + t.Logf("SCENARIO SUMMARY: %s (%s)", scenario.Name, apiType) + t.Logf("%s", strings.Repeat("=", 80)) + + totalScore := 0.0 + passed := 0 + for i, result := range results { + status := "❌ FAIL" + if result.Passed { + status = "βœ… PASS" + passed++ + } + t.Logf("Step %d: %s (%.2f) - %s", i+1, status, result.Score, + truncateString(result.QualityAssessment, 60)) + totalScore += result.Score + } + + avgScore := 0.0 + if len(results) > 0 { + avgScore = totalScore / float64(len(results)) + } + + t.Logf("\nProvider Usage:") + for provider, count := range usage { + t.Logf(" %s: %d messages", provider, count) + } + + t.Logf("\nResults: %d/%d passed, Average Score: %.2f", passed, len(results), avgScore) + t.Logf("%s\n", strings.Repeat("=", 80)) +} + +func analyzeConsistency(t *testing.T, results []ConsistencyResult, apiType string) { + t.Logf("\n%s", strings.Repeat("=", 80)) + t.Logf("CONSISTENCY ANALYSIS (%s)", apiType) + t.Logf("%s", strings.Repeat("=", 80)) + + if len(results) < 2 { + t.Logf("Need at least 2 providers for consistency analysis") + return + } + + // Analyze sentence count consistency + sentences := make([]int, len(results)) + words := make([]int, len(results)) + + for i, result := range results { + sentences[i] = result.SentenceCount + words[i] = result.WordCount + t.Logf("%s: %d sentences, %d words", result.Provider, result.SentenceCount, result.WordCount) + } + + // Calculate variance + sentenceVariance := calculateVariance(sentences) + wordVariance := calculateVariance(words) + + t.Logf("\nConsistency Metrics:") + t.Logf(" Sentence count variance: %.2f", sentenceVariance) + t.Logf(" Word count variance: %.2f", wordVariance) + + if sentenceVariance < 1.0 { + t.Logf("βœ… Good sentence count consistency") + } else { + t.Logf("⚠️ High sentence count variance") + } + + t.Logf("%s\n", strings.Repeat("=", 80)) +} + +func calculateVariance(values []int) float64 { + if len(values) == 0 { + return 0 + } + + sum := 0 + for _, v := range values { + sum += v + } + mean := float64(sum) / float64(len(values)) + + variance := 0.0 + for _, v := range values { + diff := float64(v) - mean + variance += diff * diff + } + + return variance / float64(len(values)) +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/tests/core-providers/scenarios/embedding.go b/tests/core-providers/scenarios/embedding.go new file mode 100644 index 000000000..f426dcfcc --- /dev/null +++ b/tests/core-providers/scenarios/embedding.go @@ -0,0 +1,161 @@ +package scenarios + +import ( + "context" + "fmt" + "math" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// cosineSimilarity computes the cosine similarity between two vectors +func cosineSimilarity(a, b []float32) float64 { + if len(a) != len(b) { + panic(fmt.Errorf("cosineSimilarity: vectors must have same length, got %d and %d", len(a), len(b))) + } + + var dotProduct float64 + var normA float64 + var normB float64 + + for i := 0; i < len(a); i++ { + dotProduct += float64(a[i] * b[i]) + normA += float64(a[i] * a[i]) + normB += float64(b[i] * b[i]) + } + + if normA == 0 || normB == 0 { + return 0.0 + } + + return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB)) +} + +// RunEmbeddingTest executes the embedding test scenario +func RunEmbeddingTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.Embedding { + t.Logf("Embedding not supported for provider %s", testConfig.Provider) + return + } + + if strings.TrimSpace(testConfig.EmbeddingModel) == "" { + t.Skipf("Embedding enabled but model is not configured for provider %s; skipping", testConfig.Provider) + } + + t.Run("Embedding", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Test texts with expected semantic relationships + testTexts := []string{ + "Hello, world!", + "Hi, world!", + "Goodnight, moon!", + } + + request := &schemas.BifrostEmbeddingRequest{ + Provider: testConfig.Provider, + Model: testConfig.EmbeddingModel, + Input: &schemas.EmbeddingInput{ + Texts: testTexts, + }, + Params: &schemas.EmbeddingParameters{ + EncodingFormat: bifrost.Ptr("float"), + }, + Fallbacks: testConfig.EmbeddingFallbacks, + } + + // Enhanced embedding validation + expectations := EmbeddingExpectations(testTexts) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + embeddingResponse, bifrostErr := client.EmbeddingRequest(ctx, request) + if bifrostErr != nil { + t.Fatalf("❌ Embedding request failed: %v", GetErrorMessage(bifrostErr)) + } + + // Validate using the new validation framework + result := ValidateEmbeddingResponse(t, embeddingResponse, bifrostErr, expectations, "Embedding") + if !result.Passed { + t.Fatalf("❌ Embedding validation failed: %v", result.Errors) + } + + // Additional embedding-specific validation (complementary to the main validation) + validateEmbeddingSemantics(t, embeddingResponse, testTexts) + }) +} + +// validateEmbeddingSemantics performs semantic validation on embedding responses +// This is complementary to the main validation framework and focuses on embedding-specific concerns +func validateEmbeddingSemantics(t *testing.T, response *schemas.BifrostEmbeddingResponse, testTexts []string) { + if response == nil || response.Data == nil { + t.Fatal("Invalid embedding response structure") + } + + // Extract and validate embeddings + embeddings := make([][]float32, len(testTexts)) + responseDataLength := len(response.Data) + if responseDataLength != len(testTexts) { + if responseDataLength > 0 && response.Data[0].Embedding.Embedding2DArray != nil { + responseDataLength = len(response.Data[0].Embedding.Embedding2DArray) + } + if responseDataLength != len(testTexts) { + t.Fatalf("Expected %d embedding results, got %d", len(testTexts), responseDataLength) + } + } + + for i := range responseDataLength { + vec, extractErr := getEmbeddingVector(response.Data[i]) + if extractErr != nil { + t.Fatalf("Failed to extract embedding vector for text '%s': %v", testTexts[i], extractErr) + } + if len(vec) == 0 { + t.Fatalf("Embedding vector is empty for text '%s'", testTexts[i]) + } + embeddings[i] = vec + } + + // Ensure all embeddings have consistent dimensions + embeddingLength := len(embeddings[0]) + if embeddingLength == 0 { + t.Fatal("First embedding length must be > 0") + } + + for i, embedding := range embeddings { + if len(embedding) != embeddingLength { + t.Fatalf("Embedding %d has different length (%d) than first embedding (%d)", + i, len(embedding), embeddingLength) + } + } + + // Semantic coherence validation + similarityHelloHi := cosineSimilarity(embeddings[0], embeddings[1]) // "Hello, world!" vs "Hi, world!" + similarityHelloGoodnight := cosineSimilarity(embeddings[0], embeddings[2]) // "Hello, world!" vs "Goodnight, moon!" + + // Enhanced semantic validation with detailed reporting + semanticThreshold := 0.02 + if similarityHelloHi <= similarityHelloGoodnight+semanticThreshold { + t.Logf("⚠️ Semantic coherence warning:") + t.Logf(" Similarity('Hello, world!' vs 'Hi, world!'): %.6f", similarityHelloHi) + t.Logf(" Similarity('Hello, world!' vs 'Goodnight, moon!'): %.6f", similarityHelloGoodnight) + t.Logf(" Difference: %.6f (expected > %.6f)", similarityHelloHi-similarityHelloGoodnight, semanticThreshold) + t.Logf(" This suggests the embedding model may not be capturing semantic meaning optimally") + + // Don't fail the test entirely, but log the concern + t.Logf("Continuing test - semantic coherence is provider-dependent") + } else { + t.Logf("βœ… Semantic coherence validated:") + t.Logf(" Similarity('Hello, world!' vs 'Hi, world!'): %.6f", similarityHelloHi) + t.Logf(" Similarity('Hello, world!' vs 'Goodnight, moon!'): %.6f", similarityHelloGoodnight) + t.Logf(" Difference: %.6f", similarityHelloHi-similarityHelloGoodnight) + } + + t.Logf("πŸ“Š Embedding metrics: %d vectors, %d dimensions each", len(embeddings), embeddingLength) +} diff --git a/tests/core-providers/scenarios/end_to_end_tool_calling.go b/tests/core-providers/scenarios/end_to_end_tool_calling.go new file mode 100644 index 000000000..dd648514c --- /dev/null +++ b/tests/core-providers/scenarios/end_to_end_tool_calling.go @@ -0,0 +1,265 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunEnd2EndToolCallingTest executes the end-to-end tool calling test scenario +func RunEnd2EndToolCallingTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.End2EndToolCalling { + t.Logf("End-to-end tool calling not supported for provider %s", testConfig.Provider) + return + } + + t.Run("End2EndToolCalling", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // ============================================================================= + // STEP 1: User asks for weather - Test both APIs in parallel + // ============================================================================= + + // Create messages for both APIs + chatUserMessage := CreateBasicChatMessage("What's the weather in San Francisco? Give answer in Celsius.") + responsesUserMessage := CreateBasicResponsesMessage("What's the weather in San Francisco? Give answer in Celsius.") + + // Get tools for both APIs + chatTool := GetSampleChatTool(SampleToolTypeWeather) + responsesTool := GetSampleResponsesTool(SampleToolTypeWeather) + + // Use specialized tool call retry configuration for first request + retryConfig := ToolCallRetryConfig(string(SampleToolTypeWeather)) + retryContext := TestRetryContext{ + ScenarioName: "End2EndToolCalling_Step1", + ExpectedBehavior: map[string]interface{}{ + "expected_tool_name": string(SampleToolTypeWeather), + "location": "san francisco", + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "step": "tool_call_request", + }, + } + + // Enhanced tool call validation for first request + expectations := ToolCallExpectations(string(SampleToolTypeWeather), []string{"location"}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ExpectedToolCalls[0].ArgumentTypes = map[string]string{ + "location": "string", + } + + // Create operations for both APIs + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: []schemas.ChatMessage{chatUserMessage}, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{*chatTool}, + MaxCompletionTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: []schemas.ResponsesMessage{responsesUserMessage}, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{*responsesTool}, + }, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test for Step 1 + result1 := WithDualAPITestRetry(t, + retryConfig, + retryContext, + expectations, + "End2EndToolCalling_Step1", + chatOperation, + responsesOperation) + + // Validate both APIs succeeded + if !result1.BothSucceeded { + var errors []string + if result1.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result1.ChatCompletionsError)) + } + if result1.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result1.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ End2EndToolCalling_Step1 dual API test failed: %v", errors) + } + + // Extract tool calls from both APIs + chatToolCalls := ExtractChatToolCalls(result1.ChatCompletionsResponse) + responsesToolCalls := ExtractResponsesToolCalls(result1.ResponsesAPIResponse) + + if len(chatToolCalls) == 0 { + t.Fatal("Expected at least one tool call in Chat Completions API response for 'weather'") + } + if len(responsesToolCalls) == 0 { + t.Fatal("Expected at least one tool call in Responses API response for 'weather'") + } + + chatToolCall := chatToolCalls[0] + responsesToolCall := responsesToolCalls[0] + + t.Logf("βœ… Chat Completions API tool call: %s with args: %s", chatToolCall.Name, chatToolCall.Arguments) + t.Logf("βœ… Responses API tool call: %s with args: %s", responsesToolCall.Name, responsesToolCall.Arguments) + + // ============================================================================= + // STEP 2: Simulate tool execution and provide result - Test both APIs + // ============================================================================= + + toolResult := `{"temperature": "22", "unit": "celsius", "description": "Sunny with light clouds", "humidity": "65%"}` + + // Build conversation history for Chat Completions API + chatConversationMessages := []schemas.ChatMessage{chatUserMessage} + if result1.ChatCompletionsResponse.Choices != nil { + for _, choice := range result1.ChatCompletionsResponse.Choices { + chatConversationMessages = append(chatConversationMessages, *choice.Message) + } + } + chatConversationMessages = append(chatConversationMessages, CreateToolChatMessage(toolResult, chatToolCall.ID)) + + // Build conversation history for Responses API + responsesConversationMessages := []schemas.ResponsesMessage{responsesUserMessage} + if result1.ResponsesAPIResponse.Output != nil { + for _, output := range result1.ResponsesAPIResponse.Output { + responsesConversationMessages = append(responsesConversationMessages, output) + } + } + responsesConversationMessages = append(responsesConversationMessages, CreateToolResponsesMessage(toolResult, responsesToolCall.ID)) + + // Use retry framework for second request (conversation continuation) + // Step 2 validates conversational synthesis of tool results, not tool calling + retryConfig2 := GetTestRetryConfigForScenario("CompleteEnd2End_Chat", testConfig) + retryContext2 := TestRetryContext{ + ScenarioName: "End2EndToolCalling_FinalResponse", + ExpectedBehavior: map[string]interface{}{ + "should_reference_weather": true, + "should_mention_location": true, + "should_use_tool_result": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "step": "final_response", + "tool_result": toolResult, + }, + } + + // Enhanced validation for final response + expectations2 := ConversationExpectations([]string{"francisco", "22"}) + expectations2 = ModifyExpectationsForProvider(expectations2, testConfig.Provider) + expectations2.ShouldContainKeywords = []string{"francisco", "22"} // Should reference tool results (using "francisco" to match both "San Francisco" and "san francisco") + expectations2.ShouldNotContainWords = []string{"error", "failed", "cannot"} // Should not contain error terms + expectations2.MinContentLength = 30 // Should be a substantial response + + // Create operations for both APIs - Step 2 + chatOperation2 := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: chatConversationMessages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation2 := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: responsesConversationMessages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(200), + }, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test for Step 2 + result2 := WithDualAPITestRetry(t, + retryConfig2, + retryContext2, + expectations2, + "End2EndToolCalling_Step2", + chatOperation2, + responsesOperation2) + + // Validate both APIs succeeded + if !result2.BothSucceeded { + var errors []string + if result2.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result2.ChatCompletionsError)) + } + if result2.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result2.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ End2EndToolCalling_Step2 dual API test failed: %v", errors) + } + + // Log results from both APIs + if result2.ChatCompletionsResponse != nil { + chatContent := GetChatContent(result2.ChatCompletionsResponse) + t.Logf("βœ… Chat Completions API result: %s", chatContent) + + // Additional validation for Chat Completions API + contentLower := strings.ToLower(chatContent) + if !strings.Contains(contentLower, "san francisco") { + t.Logf("⚠️ Warning: Chat Completions response doesn't mention 'San Francisco': %s", chatContent) + } + if !strings.Contains(chatContent, "22") { + t.Logf("⚠️ Warning: Chat Completions response doesn't mention temperature '22': %s", chatContent) + } + if !strings.Contains(contentLower, "sunny") { + t.Logf("⚠️ Warning: Chat Completions response doesn't mention 'sunny': %s", chatContent) + } + } + + if result2.ResponsesAPIResponse != nil { + responsesContent := GetResponsesContent(result2.ResponsesAPIResponse) + t.Logf("βœ… Responses API result: %s", responsesContent) + + // Additional validation for Responses API + contentLower := strings.ToLower(responsesContent) + if !strings.Contains(contentLower, "san francisco") { + t.Logf("⚠️ Warning: Responses API response doesn't mention 'San Francisco': %s", responsesContent) + } + if !strings.Contains(responsesContent, "22") { + t.Logf("⚠️ Warning: Responses API response doesn't mention temperature '22': %s", responsesContent) + } + if !strings.Contains(contentLower, "sunny") { + t.Logf("⚠️ Warning: Responses API response doesn't mention 'sunny': %s", responsesContent) + } + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed End2EndToolCalling test!") + }) +} diff --git a/tests/core-providers/scenarios/error_parser.go b/tests/core-providers/scenarios/error_parser.go new file mode 100644 index 000000000..3b09c8b7d --- /dev/null +++ b/tests/core-providers/scenarios/error_parser.go @@ -0,0 +1,506 @@ +package scenarios + +import ( + "fmt" + "strings" + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ============================================================================= +// ERROR PARSING AND FORMATTING UTILITIES +// ============================================================================= + +// ParsedError represents a cleaned-up, human-readable error +type ParsedError struct { + Category string // Error category (HTTP, Auth, RateLimit, etc.) + Title string // Short, readable title + Message string // Main error message + Details []string // Additional details + Suggestions []string // Potential solutions + Technical map[string]interface{} // Technical details for debugging +} + +// ErrorCategory represents different types of errors +type ErrorCategory struct { + Name string + Description string + Color string // For potential colored output +} + +var ( + // Common error categories + CategoryHTTP = ErrorCategory{"HTTP", "HTTP/Network Error", "πŸ”΄"} + CategoryAuth = ErrorCategory{"Authentication", "Authentication/Authorization Error", "πŸ”"} + CategoryRateLimit = ErrorCategory{"Rate Limit", "Rate Limiting Error", "⏱️"} + CategoryProvider = ErrorCategory{"Provider", "Provider-Specific Error", "⚠️"} + CategoryValidation = ErrorCategory{"Validation", "Input Validation Error", "πŸ“‹"} + CategoryTimeout = ErrorCategory{"Timeout", "Request Timeout Error", "⏰"} + CategoryQuota = ErrorCategory{"Quota", "Quota/Billing Error", "πŸ’³"} + CategoryModel = ErrorCategory{"Model", "Model-Related Error", "πŸ€–"} + CategoryBifrost = ErrorCategory{"Bifrost", "Bifrost Internal Error", "πŸŒ‰"} + CategoryUnknown = ErrorCategory{"Unknown", "Unknown Error", "❓"} +) + +// ParseBifrostError converts a BifrostError into a human-readable ParsedError +func ParseBifrostError(err *schemas.BifrostError) ParsedError { + if err == nil { + return ParsedError{ + Category: CategoryUnknown.Name, + Title: "Unknown Error", + Message: "Received nil error", + } + } + + parsed := ParsedError{ + Technical: make(map[string]interface{}), + Details: make([]string, 0), + Suggestions: make([]string, 0), + } + + // Store technical details + parsed.Technical["provider"] = err.ExtraFields.Provider + parsed.Technical["is_bifrost_error"] = err.IsBifrostError + if err.StatusCode != nil { + parsed.Technical["status_code"] = *err.StatusCode + } + if err.EventID != nil { + parsed.Technical["event_id"] = *err.EventID + } + + // Categorize and parse the error + parsed.Category, parsed.Title = categorizeError(err) + parsed.Message = cleanErrorMessage(err.Error.Message) + + // Add provider context if available + if err.ExtraFields.Provider != "" { + parsed.Details = append(parsed.Details, fmt.Sprintf("Provider: %s", err.ExtraFields.Provider)) + } + + // Parse based on category + switch parsed.Category { + case CategoryHTTP.Name: + parseHTTPError(err, &parsed) + case CategoryAuth.Name: + parseAuthError(err, &parsed) + case CategoryRateLimit.Name: + parseRateLimitError(err, &parsed) + case CategoryProvider.Name: + parseProviderError(err, &parsed) + case CategoryValidation.Name: + parseValidationError(err, &parsed) + case CategoryTimeout.Name: + parseTimeoutError(err, &parsed) + case CategoryQuota.Name: + parseQuotaError(err, &parsed) + case CategoryModel.Name: + parseModelError(err, &parsed) + default: + parseGenericError(err, &parsed) + } + + return parsed +} + +// categorizeError determines the error category based on status codes, types, and messages +func categorizeError(err *schemas.BifrostError) (category, title string) { + // Check status code first + if err.StatusCode != nil { + switch *err.StatusCode { + case 400: + return CategoryValidation.Name, "Bad Request" + case 401: + return CategoryAuth.Name, "Authentication Required" + case 403: + return CategoryAuth.Name, "Access Forbidden" + case 404: + return CategoryModel.Name, "Model Not Found" + case 408: + return CategoryTimeout.Name, "Request Timeout" + case 429: + return CategoryRateLimit.Name, "Rate Limited" + case 500, 502, 503, 504: + return CategoryProvider.Name, "Provider Service Error" + } + + if *err.StatusCode >= 400 && *err.StatusCode < 500 { + return CategoryValidation.Name, "Client Error" + } + if *err.StatusCode >= 500 { + return CategoryProvider.Name, "Server Error" + } + } + + // Check error type + if err.Error.Type != nil { + errorType := strings.ToLower(*err.Error.Type) + switch { + case strings.Contains(errorType, "auth"): + return CategoryAuth.Name, "Authentication Error" + case strings.Contains(errorType, "rate"): + return CategoryRateLimit.Name, "Rate Limit Error" + case strings.Contains(errorType, "quota"): + return CategoryQuota.Name, "Quota Exceeded" + case strings.Contains(errorType, "timeout"): + return CategoryTimeout.Name, "Timeout Error" + case strings.Contains(errorType, "validation"): + return CategoryValidation.Name, "Validation Error" + } + } + + // Check error message for keywords + message := strings.ToLower(err.Error.Message) + switch { + case strings.Contains(message, "unauthorized") || strings.Contains(message, "invalid api key"): + return CategoryAuth.Name, "Invalid API Key" + case strings.Contains(message, "rate limit") || strings.Contains(message, "too many requests"): + return CategoryRateLimit.Name, "Rate Limited" + case strings.Contains(message, "quota") || strings.Contains(message, "billing"): + return CategoryQuota.Name, "Quota/Billing Issue" + case strings.Contains(message, "timeout") || strings.Contains(message, "deadline"): + return CategoryTimeout.Name, "Request Timeout" + case strings.Contains(message, "model") && (strings.Contains(message, "not found") || strings.Contains(message, "does not exist")): + return CategoryModel.Name, "Model Not Available" + case strings.Contains(message, "connection") || strings.Contains(message, "network"): + return CategoryHTTP.Name, "Network Error" + case err.IsBifrostError: + return CategoryBifrost.Name, "Bifrost Internal Error" + } + + // Default based on HTTP status + if err.StatusCode != nil && *err.StatusCode >= 400 { + return CategoryHTTP.Name, fmt.Sprintf("HTTP %d Error", *err.StatusCode) + } + + return CategoryUnknown.Name, "Unknown Error" +} + +// cleanErrorMessage cleans up the error message for better readability +func cleanErrorMessage(message string) string { + if message == "" { + return "No error message provided" + } + + // Remove common technical prefixes + message = strings.TrimPrefix(message, "error: ") + message = strings.TrimPrefix(message, "Error: ") + message = strings.TrimPrefix(message, "failed to ") + message = strings.TrimPrefix(message, "Failed to ") + + // Capitalize first letter + if len(message) > 0 { + message = strings.ToUpper(message[:1]) + message[1:] + } + + return message +} + +// parseHTTPError handles HTTP-specific error parsing +func parseHTTPError(err *schemas.BifrostError, parsed *ParsedError) { + if err.StatusCode != nil { + parsed.Details = append(parsed.Details, fmt.Sprintf("HTTP Status: %d", *err.StatusCode)) + + // Add status-specific suggestions + switch *err.StatusCode { + case 502, 503, 504: + parsed.Suggestions = append(parsed.Suggestions, "The provider service may be temporarily unavailable - retries should help") + parsed.Suggestions = append(parsed.Suggestions, "Check the provider's status page for known issues") + case 500: + parsed.Suggestions = append(parsed.Suggestions, "This appears to be a provider-side error - consider using fallbacks") + } + } +} + +// parseAuthError handles authentication-specific error parsing +func parseAuthError(err *schemas.BifrostError, parsed *ParsedError) { + message := strings.ToLower(err.Error.Message) + + if strings.Contains(message, "api key") { + parsed.Suggestions = append(parsed.Suggestions, "Verify your API key is correct and properly set in environment variables") + parsed.Suggestions = append(parsed.Suggestions, "Check if the API key has the necessary permissions for this operation") + } + + if strings.Contains(message, "unauthorized") { + parsed.Suggestions = append(parsed.Suggestions, "Ensure you have valid credentials for this provider") + parsed.Suggestions = append(parsed.Suggestions, "Check if your account has access to the requested model") + } + + if strings.Contains(message, "forbidden") { + parsed.Suggestions = append(parsed.Suggestions, "Your account may not have permission for this operation") + parsed.Suggestions = append(parsed.Suggestions, "Contact your provider to verify account permissions") + } +} + +// parseRateLimitError handles rate limiting error parsing +func parseRateLimitError(err *schemas.BifrostError, parsed *ParsedError) { + parsed.Suggestions = append(parsed.Suggestions, "Reduce request frequency or implement exponential backoff") + parsed.Suggestions = append(parsed.Suggestions, "Consider upgrading your provider plan for higher rate limits") + + // Try to extract rate limit details from message + message := err.Error.Message + if strings.Contains(message, "per") { + parsed.Details = append(parsed.Details, "Rate limit details may be in the error message") + } +} + +// parseProviderError handles provider-specific error parsing +func parseProviderError(err *schemas.BifrostError, parsed *ParsedError) { + parsed.Details = append(parsed.Details, "This is a provider-specific error") + + // Provider-specific suggestions + switch err.ExtraFields.Provider { + case schemas.OpenAI: + parsed.Suggestions = append(parsed.Suggestions, "Check OpenAI's status page: https://status.openai.com/") + case schemas.Anthropic: + parsed.Suggestions = append(parsed.Suggestions, "Check Anthropic's status page: https://status.anthropic.com/") + case schemas.Azure: + parsed.Suggestions = append(parsed.Suggestions, "Check Azure's status page: https://status.azure.com/") + case schemas.Bedrock: + parsed.Suggestions = append(parsed.Suggestions, "Check AWS service health: https://status.aws.amazon.com/") + default: + parsed.Suggestions = append(parsed.Suggestions, "Check the provider's status page or documentation") + } + + parsed.Suggestions = append(parsed.Suggestions, "Consider using fallback providers if configured") +} + +// parseValidationError handles validation error parsing +func parseValidationError(err *schemas.BifrostError, parsed *ParsedError) { + parsed.Suggestions = append(parsed.Suggestions, "Verify all required parameters are provided") + parsed.Suggestions = append(parsed.Suggestions, "Check parameter types and formats match API requirements") + + // Extract parameter information if available + if err.Error.Param != nil { + parsed.Details = append(parsed.Details, fmt.Sprintf("Related parameter: %v", err.Error.Param)) + } +} + +// parseTimeoutError handles timeout error parsing +func parseTimeoutError(err *schemas.BifrostError, parsed *ParsedError) { + parsed.Suggestions = append(parsed.Suggestions, "Increase request timeout settings if possible") + parsed.Suggestions = append(parsed.Suggestions, "Try breaking large requests into smaller chunks") + parsed.Suggestions = append(parsed.Suggestions, "Check network connectivity to the provider") +} + +// parseQuotaError handles quota/billing error parsing +func parseQuotaError(err *schemas.BifrostError, parsed *ParsedError) { + parsed.Suggestions = append(parsed.Suggestions, "Check your account billing and usage limits") + parsed.Suggestions = append(parsed.Suggestions, "Consider upgrading your provider plan") + parsed.Suggestions = append(parsed.Suggestions, "Monitor your token usage to avoid hitting limits") +} + +// parseModelError handles model-specific error parsing +func parseModelError(err *schemas.BifrostError, parsed *ParsedError) { + message := strings.ToLower(err.Error.Message) + + if strings.Contains(message, "not found") || strings.Contains(message, "does not exist") { + parsed.Suggestions = append(parsed.Suggestions, "Verify the model name is correct and supported by the provider") + parsed.Suggestions = append(parsed.Suggestions, "Check if you have access to this model with your current plan") + parsed.Suggestions = append(parsed.Suggestions, "Consult the provider's documentation for available models") + } + + if strings.Contains(message, "deprecated") { + parsed.Suggestions = append(parsed.Suggestions, "This model is deprecated - consider switching to a newer model") + } +} + +// parseGenericError handles unknown/generic errors +func parseGenericError(err *schemas.BifrostError, parsed *ParsedError) { + parsed.Suggestions = append(parsed.Suggestions, "Check the provider's documentation for more details") + parsed.Suggestions = append(parsed.Suggestions, "Consider enabling debug logging for more information") + + if err.Error.Error != nil { + parsed.Details = append(parsed.Details, fmt.Sprintf("Underlying error: %s", err.Error.Error.Error())) + } +} + +// ============================================================================= +// FORMATTING AND DISPLAY FUNCTIONS +// ============================================================================= + +// FormatError formats a ParsedError for display +func FormatError(parsed ParsedError) string { + var builder strings.Builder + + // Header with category and title + categoryInfo := getCategory(parsed.Category) + builder.WriteString(fmt.Sprintf("%s %s: %s\n", categoryInfo.Color, categoryInfo.Name, parsed.Title)) + + // Main message + builder.WriteString(fmt.Sprintf("Message: %s\n", parsed.Message)) + + // Details + if len(parsed.Details) > 0 { + builder.WriteString("Details:\n") + for _, detail := range parsed.Details { + builder.WriteString(fmt.Sprintf(" β€’ %s\n", detail)) + } + } + + // Suggestions + if len(parsed.Suggestions) > 0 { + builder.WriteString("Suggestions:\n") + for _, suggestion := range parsed.Suggestions { + builder.WriteString(fmt.Sprintf(" πŸ’‘ %s\n", suggestion)) + } + } + + return builder.String() +} + +// FormatErrorConcise formats a ParsedError in a concise format +func FormatErrorConcise(parsed ParsedError) string { + categoryInfo := getCategory(parsed.Category) + return fmt.Sprintf("%s %s: %s", categoryInfo.Color, parsed.Title, parsed.Message) +} + +// LogError logs a BifrostError in a readable format +func LogError(t *testing.T, err *schemas.BifrostError, context string) { + if err == nil { + return + } + + parsed := ParseBifrostError(err) + t.Logf("❌ %s Error:\n%s", context, FormatError(parsed)) +} + +// LogErrorConcise logs a BifrostError in a concise format +func LogErrorConcise(t *testing.T, err *schemas.BifrostError, context string) { + if err == nil { + return + } + + parsed := ParseBifrostError(err) + t.Logf("❌ %s: %s", context, FormatErrorConcise(parsed)) +} + +// RequireNoError is like require.NoError but with better error formatting +func RequireNoError(t *testing.T, err *schemas.BifrostError, msgAndArgs ...interface{}) { + if err != nil { + parsed := ParseBifrostError(err) + message := "Expected no error" + if len(msgAndArgs) > 0 { + if msg, ok := msgAndArgs[0].(string); ok { + if len(msgAndArgs) > 1 { + message = fmt.Sprintf(msg, msgAndArgs[1:]...) + } else { + message = msg + } + } + } + t.Fatalf("%s, but got:\n%s", message, FormatError(parsed)) + } +} + +// AssertNoError is like assert.NoError but with better error formatting +func AssertNoError(t *testing.T, err *schemas.BifrostError, msgAndArgs ...interface{}) bool { + if err != nil { + parsed := ParseBifrostError(err) + message := "Expected no error" + if len(msgAndArgs) > 0 { + if msg, ok := msgAndArgs[0].(string); ok { + if len(msgAndArgs) > 1 { + message = fmt.Sprintf(msg, msgAndArgs[1:]...) + } else { + message = msg + } + } + } + t.Errorf("%s, but got:\n%s", message, FormatError(parsed)) + return false + } + return true +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +// getCategory returns the category info for a category name +func getCategory(name string) ErrorCategory { + switch name { + case CategoryHTTP.Name: + return CategoryHTTP + case CategoryAuth.Name: + return CategoryAuth + case CategoryRateLimit.Name: + return CategoryRateLimit + case CategoryProvider.Name: + return CategoryProvider + case CategoryValidation.Name: + return CategoryValidation + case CategoryTimeout.Name: + return CategoryTimeout + case CategoryQuota.Name: + return CategoryQuota + case CategoryModel.Name: + return CategoryModel + case CategoryBifrost.Name: + return CategoryBifrost + default: + return CategoryUnknown + } +} + +// IsRetryableError determines if an error should trigger a retry +func IsRetryableError(err *schemas.BifrostError) bool { + if err == nil { + return false + } + + // Check status codes + if err.StatusCode != nil { + switch *err.StatusCode { + case 429, 500, 502, 503, 504: // Rate limit and server errors + return true + case 400, 401, 403, 404: // Client errors (usually not retryable) + return false + } + } + + // Check error message for retryable conditions + message := strings.ToLower(err.Error.Message) + retryableKeywords := []string{ + "timeout", "rate limit", "temporarily unavailable", + "service unavailable", "internal server error", + "connection", "network", + } + + for _, keyword := range retryableKeywords { + if strings.Contains(message, keyword) { + return true + } + } + + return false +} + +// GetRetryDelay suggests a retry delay based on the error type +func GetRetryDelay(err *schemas.BifrostError, attempt int) int { + if err == nil { + return 0 + } + + baseDelay := 1 // seconds + + // Adjust base delay by error type + if err.StatusCode != nil { + switch *err.StatusCode { + case 429: // Rate limit + baseDelay = 5 + case 500, 502, 503, 504: // Server errors + baseDelay = 2 + } + } + + // Exponential backoff + delay := baseDelay * (1 << (attempt - 1)) // 2^(attempt-1) + + // Cap at reasonable maximum + if delay > 30 { + delay = 30 + } + + return delay +} diff --git a/tests/core-providers/scenarios/image_base64.go b/tests/core-providers/scenarios/image_base64.go new file mode 100644 index 000000000..025af5fd7 --- /dev/null +++ b/tests/core-providers/scenarios/image_base64.go @@ -0,0 +1,160 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunImageBase64Test executes the image base64 test scenario using dual API testing framework +func RunImageBase64Test(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.ImageBase64 { + t.Logf("Image base64 not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ImageBase64", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Load lion base64 image for testing + lionBase64, err := GetLionBase64Image() + if err != nil { + t.Fatalf("Failed to load lion base64 image: %v", err) + } + + // Create messages for both APIs using the isResponsesAPI flag + chatMessages := []schemas.ChatMessage{ + CreateImageChatMessage("Describe this image briefly. What animal do you see?", lionBase64), + } + responsesMessages := []schemas.ResponsesMessage{ + CreateImageResponsesMessage("Describe this image briefly. What animal do you see?", lionBase64), + } + + // Use retry framework for vision requests with base64 data + retryConfig := GetTestRetryConfigForScenario("ImageBase64", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "ImageBase64", + ExpectedBehavior: map[string]interface{}{ + "should_process_base64": true, + "should_describe_image": true, + "should_identify_animal": "lion or animal", + "vision_processing": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.VisionModel, + "image_type": "base64", + "encoding": "base64", + "test_animal": "lion", + "expected_keywords": []string{"lion", "animal", "cat", "feline", "big cat"}, // 🦁 Lion-specific terms + }, + } + + // Enhanced validation for base64 lion image processing (same for both APIs) + expectations := VisionExpectations([]string{"lion"}) // Should identify it as a lion (more specific than just "animal") + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.MinContentLength = 15 // Should provide some description + expectations.MaxContentLength = 600 // Base64 processing can be resource intensive + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{ + "cannot process", "invalid format", "decode error", + "unable to view", "no image", "corrupted", + }...) // Base64 processing failure indicators + + // Create operations for both Chat Completions and Responses API + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.VisionModel, + Input: chatMessages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.VisionModel, + Input: responsesMessages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test - passes only if BOTH APIs succeed + result := WithDualAPITestRetry(t, + retryConfig, + retryContext, + expectations, + "ImageBase64", + chatOperation, + responsesOperation) + + // Validate both APIs succeeded + if !result.BothSucceeded { + var errors []string + if result.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result.ChatCompletionsError)) + } + if result.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ ImageBase64 dual API test failed: %v", errors) + } + + // Additional validation for base64 lion image processing using universal content extraction + validateChatBase64ImageProcessing := func(response *schemas.BifrostChatResponse, apiName string) { + content := GetChatContent(response) + validateBase64ImageContent(t, content, apiName) + } + + validateResponsesBase64ImageProcessing := func(response *schemas.BifrostResponsesResponse, apiName string) { + content := GetResponsesContent(response) + validateBase64ImageContent(t, content, apiName) + } + + // Validate both API responses + if result.ChatCompletionsResponse != nil { + validateChatBase64ImageProcessing(result.ChatCompletionsResponse, "Chat Completions") + } + + if result.ResponsesAPIResponse != nil { + validateResponsesBase64ImageProcessing(result.ResponsesAPIResponse, "Responses") + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed ImageBase64 test!") + }) +} + +func validateBase64ImageContent(t *testing.T, content string, apiName string) { + lowerContent := strings.ToLower(content) + foundAnimal := strings.Contains(lowerContent, "lion") || strings.Contains(lowerContent, "animal") || + strings.Contains(lowerContent, "cat") || strings.Contains(lowerContent, "feline") + + if len(content) < 10 { + t.Logf("⚠️ %s response seems quite short for image description: %s", apiName, content) + } else if foundAnimal { + t.Logf("βœ… %s vision model successfully identified animal in base64 image", apiName) + } else { + t.Logf("βœ… %s vision model processed base64 image but may not have clearly identified the animal", apiName) + } + + t.Logf("βœ… %s lion base64 image processing completed: %s", apiName, content) +} diff --git a/tests/core-providers/scenarios/image_url.go b/tests/core-providers/scenarios/image_url.go new file mode 100644 index 000000000..7c3abd201 --- /dev/null +++ b/tests/core-providers/scenarios/image_url.go @@ -0,0 +1,157 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunImageURLTest executes the image URL test scenario using dual API testing framework +func RunImageURLTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.ImageURL { + t.Logf("Image URL not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ImageURL", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Create messages for both APIs using the isResponsesAPI flag + chatMessages := []schemas.ChatMessage{ + CreateImageChatMessage("What do you see in this image?", TestImageURL), + } + responsesMessages := []schemas.ResponsesMessage{ + CreateImageResponsesMessage("What do you see in this image?", TestImageURL), + } + + // Use retry framework for vision requests (can be flaky) + retryConfig := GetTestRetryConfigForScenario("ImageURL", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "ImageURL", + ExpectedBehavior: map[string]interface{}{ + "should_describe_image": true, + "should_identify_object": "ant or insect", + "vision_processing": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.VisionModel, + "image_type": "url", + "test_image": TestImageURL, + "expected_keywords": []string{"ant", "insect", "bug", "arthropod"}, // 🎯 Test-specific retry keywords + }, + } + + // Enhanced validation for vision responses - should identify ant OR insect (same for both APIs) + expectations := VisionExpectations([]string{}) // Start with base vision expectations + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ShouldContainKeywords = nil // Clear strict keyword requirement + expectations.ShouldContainAnyOf = []string{"ant", "insect", "bug", "arthropod"} // Accept any valid identification + expectations.MinContentLength = 20 // Should be a descriptive response + expectations.MaxContentLength = 800 // Vision models can be verbose, but keep reasonable + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{"cannot see", "unable to view", "no image"}...) // Vision failure indicators + + // Create operations for both Chat Completions and Responses API + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.VisionModel, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + chatReq.Input = chatMessages + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.VisionModel, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(200), + }, + Fallbacks: testConfig.Fallbacks, + } + responsesReq.Input = responsesMessages + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test - passes only if BOTH APIs succeed + result := WithDualAPITestRetry(t, + retryConfig, + retryContext, + expectations, + "ImageURL", + chatOperation, + responsesOperation) + + // Validate both APIs succeeded + if !result.BothSucceeded { + var errors []string + if result.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result.ChatCompletionsError)) + } + if result.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ ImageURL dual API test failed: %v", errors) + } + + // Additional vision-specific validation using universal content extraction + validateChatImageProcessing := func(response *schemas.BifrostChatResponse, apiName string) { + content := GetChatContent(response) + validateImageProcessingContent(t, content, apiName) + } + + validateResponsesImageProcessing := func(response *schemas.BifrostResponsesResponse, apiName string) { + content := GetResponsesContent(response) + validateImageProcessingContent(t, content, apiName) + } + + // Validate both API responses + if result.ChatCompletionsResponse != nil { + validateChatImageProcessing(result.ChatCompletionsResponse, "Chat Completions") + } + + if result.ResponsesAPIResponse != nil { + validateResponsesImageProcessing(result.ResponsesAPIResponse, "Responses") + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed ImageURL test!") + }) +} + +func validateImageProcessingContent(t *testing.T, content string, apiName string) { + lowerContent := strings.ToLower(content) + foundObjectIdentification := strings.Contains(lowerContent, "ant") || strings.Contains(lowerContent, "insect") + + if foundObjectIdentification { + t.Logf("βœ… %s vision model successfully identified the object in image: %s", apiName, content) + } else { + // Log warning but don't fail immediately - some models might describe differently + t.Logf("⚠️ %s vision model may not have explicitly identified 'ant' or 'insect': %s", apiName, content) + + // Check for other possible valid descriptions + if strings.Contains(lowerContent, "small") || + strings.Contains(lowerContent, "creature") || + strings.Contains(lowerContent, "animal") || + strings.Contains(lowerContent, "bug") { + t.Logf("βœ… But %s model provided a reasonable description of the image", apiName) + } else { + t.Logf("❌ %s model may have failed to properly process the image", apiName) + } + } +} diff --git a/tests/core-providers/scenarios/list_models.go b/tests/core-providers/scenarios/list_models.go new file mode 100644 index 000000000..419249fce --- /dev/null +++ b/tests/core-providers/scenarios/list_models.go @@ -0,0 +1,161 @@ +package scenarios + +import ( + "context" + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunListModelsTest executes the list models test scenario +func RunListModelsTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.ListModels { + t.Logf("List models not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ListModels", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Create basic list models request + request := &schemas.BifrostListModelsRequest{ + Provider: testConfig.Provider, + } + + // Execute list models request + response, bifrostErr := client.ListModelsRequest(ctx, request) + if bifrostErr != nil { + t.Fatalf("❌ List models request failed: %v", GetErrorMessage(bifrostErr)) + } + + // Validate response structure + if response == nil { + t.Fatal("❌ List models response is nil") + } + + // Validate that we have models in the response + if len(response.Data) == 0 { + t.Fatal("❌ List models response contains no models") + } + + t.Logf("βœ… List models returned %d models", len(response.Data)) + + // Validate individual model entries + validModels := 0 + for i, model := range response.Data { + if model.ID == "" { + t.Errorf("❌ Model at index %d has empty ID", i) + continue + } + + // Log a few sample models for verification + if i < 5 { + t.Logf(" Model %d: ID=%s", i+1, model.ID) + } + + validModels++ + } + + if validModels == 0 { + t.Fatal("❌ No valid models found in response") + } + + t.Logf("βœ… Validated %d models with proper structure", validModels) + + // Validate extra fields + if response.ExtraFields.Provider != testConfig.Provider { + t.Errorf("❌ Provider mismatch: expected %s, got %s", testConfig.Provider, response.ExtraFields.Provider) + } + + if response.ExtraFields.RequestType != schemas.ListModelsRequest { + t.Errorf("❌ Request type mismatch: expected %s, got %s", schemas.ListModelsRequest, response.ExtraFields.RequestType) + } + + // Validate latency is reasonable (non-negative and not absurdly high) + if response.ExtraFields.Latency < 0 { + t.Errorf("❌ Invalid latency: %d ms (should be non-negative)", response.ExtraFields.Latency) + } else if response.ExtraFields.Latency > 30000 { + t.Logf("⚠️ Warning: High latency detected: %d ms", response.ExtraFields.Latency) + } else { + t.Logf("βœ… Request latency: %d ms", response.ExtraFields.Latency) + } + + t.Logf("πŸŽ‰ List models test passed successfully!") + }) +} + +// RunListModelsPaginationTest executes pagination test for list models +func RunListModelsPaginationTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.ListModels { + t.Logf("List models not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ListModelsPagination", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Test pagination with page size + pageSize := 5 + request := &schemas.BifrostListModelsRequest{ + Provider: testConfig.Provider, + PageSize: pageSize, + } + + response, bifrostErr := client.ListModelsRequest(ctx, request) + if bifrostErr != nil { + t.Fatalf("❌ List models pagination request failed: %v", GetErrorMessage(bifrostErr)) + } + + if response == nil { + t.Fatal("❌ List models pagination response is nil") + } + + // Check that pagination was applied + if len(response.Data) > pageSize { + t.Errorf("❌ Expected at most %d models, got %d", pageSize, len(response.Data)) + } else { + t.Logf("βœ… Pagination working: returned %d models (page size: %d)", len(response.Data), pageSize) + } + + // Test with page token if provided + if response.NextPageToken != "" { + t.Logf("βœ… Next page token available: %s", response.NextPageToken) + + // Fetch next page + nextPageRequest := &schemas.BifrostListModelsRequest{ + Provider: testConfig.Provider, + PageSize: pageSize, + PageToken: response.NextPageToken, + } + + nextPageResponse, nextPageErr := client.ListModelsRequest(ctx, nextPageRequest) + if nextPageErr != nil { + t.Errorf("❌ Failed to fetch next page: %v", GetErrorMessage(nextPageErr)) + } else if nextPageResponse != nil { + t.Logf("βœ… Successfully fetched next page with %d models", len(nextPageResponse.Data)) + + // Verify that the next page contains different models + if len(response.Data) > 0 && len(nextPageResponse.Data) > 0 { + firstPageFirstModel := response.Data[0].ID + secondPageFirstModel := nextPageResponse.Data[0].ID + if firstPageFirstModel != secondPageFirstModel { + t.Logf("βœ… Pages contain different models (first page: %s, second page: %s)", + firstPageFirstModel, secondPageFirstModel) + } + } + } + } else { + t.Logf("ℹ️ No next page token - all models returned in single page") + } + + t.Logf("πŸŽ‰ List models pagination test completed!") + }) +} diff --git a/tests/core-providers/scenarios/media/lion_base64.txt b/tests/core-providers/scenarios/media/lion_base64.txt new file mode 100644 index 000000000..df822573a --- /dev/null +++ b/tests/core-providers/scenarios/media/lion_base64.txt @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAGTa2NhQlgAAZNranVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNjMnBhAAAANw9qdW1iAAAAR2p1bWRjMm1hABEAEIAAAKoAOJtxA3VybjpjMnBhOmQ1OTllMWM2LTNjY2EtNGRlZC1iZDM3LTRlZmI0Y2U2NGYyMwAAAAHVanVtYgAAAClqdW1kYzJhcwARABCAAACqADibcQNjMnBhLmFzc2VydGlvbnMAAAAA+Wp1bWIAAAApanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5hY3Rpb25zLnYyAAAAAMhjYm9yomdhY3Rpb25zgqNmYWN0aW9ubGMycGEuY3JlYXRlZG1zb2Z0d2FyZUFnZW50v2RuYW1lZkdQVC00b/9xZGlnaXRhbFNvdXJjZVR5cGV4Rmh0dHA6Ly9jdi5pcHRjLm9yZy9uZXdzY29kZXMvZGlnaXRhbHNvdXJjZXR5cGUvdHJhaW5lZEFsZ29yaXRobWljTWVkaWGhZmFjdGlvbm5jMnBhLmNvbnZlcnRlZHJhbGxBY3Rpb25zSW5jbHVkZWT1AAAAq2p1bWIAAAAoanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5oYXNoLmRhdGEAAAAAe2Nib3KlamV4Y2x1c2lvbnOBomVzdGFydBghZmxlbmd0aBk3QWRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFgg1ZsOCBtzAhQceE4Tv0FH7dD41RrOtuGm9vaC2WW+sJ1jcGFkSAAAAAAAAAAAAAAB7mp1bWIAAAAnanVtZGMyY2wAEQAQgAAAqgA4m3EDYzJwYS5jbGFpbS52MgAAAAG/Y2JvcqZqaW5zdGFuY2VJRHgseG1wOmlpZDowZjViYjM5ZC00N2IwLTRiYzUtYWUwMi03NTlmYWRhM2JkNDN0Y2xhaW1fZ2VuZXJhdG9yX2luZm+/ZG5hbWVnQ2hhdEdQVHdvcmcuY29udGVudGF1dGguYzJwYV9yc2YwLjY0LjD/aXNpZ25hdHVyZXhNc2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTpkNTk5ZTFjNi0zY2NhLTRkZWQtYmQzNy00ZWZiNGNlNjRmMjMvYzJwYS5zaWduYXR1cmVyY3JlYXRlZF9hc3NlcnRpb25zgqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggunMbFQiP5kP9dxXSSsW1/BmCPZKIwgpWB2BE+a236qSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg8Bj6TSOrUxY4xnH2czhvyd8ipmYX/PzzTiT8CGl668RoZGM6dGl0bGVpaW1hZ2UucG5nY2FsZ2ZzaGEyNTYAADL9anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAADLNY2JvctKEWQfBogEmGCGCWQM3MIIDMzCCAhugAwIBAgIUbq4oo+7FuOQqNvod5kEVrmIWGzswDQYJKoZIhvcNAQEMBQAwSjEaMBgGA1UEAwwRV2ViQ2xhaW1TaWduaW5nQ0ExDTALBgNVBAsMBExlbnMxEDAOBgNVBAoMB1RydWVwaWMxCzAJBgNVBAYTAlVTMB4XDTI1MDExMzIwMzY0NloXDTI2MDExMzIwMzY0NVowVjELMAkGA1UEBhMCVVMxDzANBgNVBAoMBk9wZW5BSTEQMA4GA1UECwwHQ2hhdEdQVDEkMCIGA1UEAwwbVHJ1ZXBpYyBMZW5zIENMSSBpbiBDaGF0R1BUMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVh14xypQD33uBMgU9aukdnuL7dOjIz3VpkIz2UwpCUIxMAhLpTWV6JHjLvDamqOsAQruAtmJXwzVLbsuFpo36KOBzzCBzDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFFofa2bTlOewQYN9nAx7XcVzS0uzME0GCCsGAQUFBwEBBEEwPzA9BggrBgEFBQcwAYYxaHR0cDovL3ZhLnRydWVwaWMuY29tL2VqYmNhL3B1YmxpY3dlYi9zdGF0dXMvb2NzcDAdBgNVHSUEFjAUBggrBgEFBQcDBAYIKwYBBQUHAyQwHQYDVR0OBBYEFMpeEy4sGzWibWJFTTNDBWLsk/gqMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQwFAAOCAQEAeWg+ez3jguHZXbm4bruh1xJE0JcUKfUHUy3T9/qn0/I94RHpAuJHGtOR82heKf3qzXyKXu3rRh/w5kFKudwDaaqvxVpD2UXLAK+N9Nxqr02LLYybAJ8z04PrvS3pWKY3F5PvoV5nxA9DpHbJVSBJBt+xGo2atxcCGn0DenxkH7pRhqD+nYFivtypiEnuwNH8JrUbjRPJa07iMEfe9I7UEfsXPCisg0hxTFlMZPJkxqzqA6OvS71q+KQqg2qTzuZljF2JIQs1lWyROHBwZS2lZbruxqDTLu+uva49Rgq9XwQV9CeaEO+aRKW2QQgp/S+IQJK8Lv0U1GN3qWhnDxgBylkEfjCCBHowggJioAMCAQICFGn8kMTMiVCCOh6oX9KC/yjV/ZOQMA0GCSqGSIb3DQEBDAUAMD8xDzANBgNVBAMMBlJvb3RDQTENMAsGA1UECwwETGVuczEQMA4GA1UECgwHVHJ1ZXBpYzELMAkGA1UEBhMCVVMwHhcNMjExMjA5MjAzOTQ2WhcNMjYxMjA4MjAzOTQ1WjBKMRowGAYDVQQDDBFXZWJDbGFpbVNpZ25pbmdDQTENMAsGA1UECwwETGVuczEQMA4GA1UECgwHVHJ1ZXBpYzELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBFhLDp1DBmMzOa/iOpPHFavpylojYBTP7iuyC8mWA50GcmsThYBXHBOgoa/XH2t4KiiL6xaej9goo/gdiOwrLCXlleQ5YmpQ8li8vYtUWWMyKqJfKSJACWesINuevL6U9+3+T73exvuh6OPgUHkQXUGjh+WepF0n1v03K+/a8gaGfZEjhWAh6XKt6QfuGhjoBoe6mct4got3CqFE1nYyXq3J0MvkTm5v6u1n91NhXTMit76FxH4VsH+fYHfC9KuQ0Zoi+mROwfbHfYW3Nvm7W89/oMxdTKv8DdZajmtvnFiqRHRjHS7YDEVTW85nGcYuTvnBSuRLlxoV9aBjBArJvAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUWLrxqfIN50UGCrApp1qXMOonPQswHQYDVR0OBBYEFFofa2bTlOewQYN9nAx7XcVzS0uzMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAdTiGehcRQvBXfAawu3fdO42FymnF5EFaM4wheoZxf0Xti3xT0KrnMbhzP3dTYaBhn6ZOherz8Mg924znkFcVsF98kTZjk6loVulFx087JxSKnJJrAV2CKwdHy9EEVj+r1EMbLjQW6tJT0KINCuWNlxdEDhm7/9lhhgbCe01bWn8OcVlfONX/duGO350pM0Bi6iWj2iYVVcnlfFAwoT9KobjdkXpLfAuoJMjUK+KV05YCzKoC1Q+1xsKy98JAACCz4ss+0dbJya1Ci2FdrL5D5/erUAehjruC7ZNvQepsqJyMBxz0H5bEJeFdvMcNpawC7bmTrWkq+OwrNjhrP8J+iIltHBBQnnfLJqFHtOQb2ThKvkuDtj0ist0EP1KFom+0EImvO16l6Dl0/AYubyPFJfuSM6sXs6ZgEBFz370+i7Ug7TkuqHcETkLEvBa2uC1BIlScnh5MwFyaEn9V3YSinECYaIrlaf/ksrubk7n/Skt1XXMs7kTKZsFhJ3HsUKkj0yFRNoGNq1aPpngJG91V8nRTM/kV5zCnSRNMuagjsrGq/qXU38rUxTe3PInYPrOuzklvTGzJSHvr81GO34zX03wA0GmYMqWUMZaYwSbnIQkdGue3WnA24NUpEp+kwm+KxW3juwkp/4KKeFWuYYkqu3vpn/1Q/55cRGK23YIn6dGhY3BhZFkqtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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9z+ppiIlGWx2zVCJbg5m+iDH5CpjsOW462UCN2A5VMj8TSkxxEP3W9zj9aYhp4VMemaYhqj58epxTYE9scl07bGP6VEu5Uew08EAdlpiFRQX59T/Kk2MSQkAD2poTGdW579aYhYvmQE9QppPca1JD/qCfY/0pdR9CGM4Vj3qmSthSxUjae39aLBcUkn8Tn8aAJ5PkG5eCWB47Yz0qFroW9NUOyRcswODvH680boHoxYwGVz3QjBH40n0Ghn3eVwNygkYpkkiSF1bcB8u0A47dKVrFJ3QJjJjIypPeh9wXYSRQG/Ej600xNCoo8th2oe4JaGhpnzZU/dMROPxrOoa09dC1eDMMZPJHf8qzjuyp7FWRFMKtj5qu+tiGtLljSYlmugsgyKio7LQqmrs2obSGO6mdU5UcVg5No2UUm2Z0aCVmMnzEk9at6bERV9zPuY1UnaMVrFkySI0pkolfrSGf/9kAAAXmanVtYgAAACxqdW1kY2JvcgARABCAAACqADibcQNjMnBhLmluZ3JlZGllbnQudjMAAAAFsmNib3KobHJlbGF0aW9uc2hpcGhwYXJlbnRPZmhkYzp0aXRsZWlpbWFnZS5wbmdpZGM6Zm9ybWF0Y3BuZ3F2YWxpZGF0aW9uUmVzdWx0c6FuYWN0aXZlTWFuaWZlc3SjZ3N1Y2Nlc3OFo2Rjb2RleB1jbGFpbVNpZ25hdHVyZS5pbnNpZGVWYWxpZGl0eWN1cmx4TXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6ZDU5OWUxYzYtM2NjYS00ZGVkLWJkMzctNGVmYjRjZTY0ZjIzL2MycGEuc2lnbmF0dXJla2V4cGxhbmF0aW9udWNsYWltIHNpZ25hdHVyZSB2YWxpZKNkY29kZXgYY2xhaW1TaWduYXR1cmUudmFsaWRhdGVkY3VybHhNc2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTpkNTk5ZTFjNi0zY2NhLTRkZWQtYmQzNy00ZWZiNGNlNjRmMjMvYzJwYS5zaWduYXR1cmVrZXhwbGFuYXRpb251Y2xhaW0gc2lnbmF0dXJlIHZhbGlko2Rjb2RleBlhc3NlcnRpb24uaGFzaGVkVVJJLm1hdGNoY3VybHhec2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTpkNTk5ZTFjNi0zY2NhLTRkZWQtYmQzNy00ZWZiNGNlNjRmMjMvYzJwYS5hc3NlcnRpb25zL2MycGEuYWN0aW9ucy52MmtleHBsYW5hdGlvbng+aGFzaGVkIHVyaSBtYXRjaGVkOiBzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjKjZGNvZGV4GWFzc2VydGlvbi5oYXNoZWRVUkkubWF0Y2hjdXJseF1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmQ1OTllMWM2LTNjY2EtNGRlZC1iZDM3LTRlZmI0Y2U2NGYyMy9jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFrZXhwbGFuYXRpb254PWhhc2hlZCB1cmkgbWF0Y2hlZDogc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGGjZGNvZGV4GGFzc2VydGlvbi5kYXRhSGFzaC5tYXRjaGN1cmx4XXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6ZDU5OWUxYzYtM2NjYS00ZGVkLWJkMzctNGVmYjRjZTY0ZjIzL2MycGEuYXNzZXJ0aW9ucy9jMnBhLmhhc2guZGF0YWtleHBsYW5hdGlvbm9kYXRhIGhhc2ggdmFsaWRtaW5mb3JtYXRpb25hbIBnZmFpbHVyZYBqaW5zdGFuY2VJRHgseG1wOmlpZDozZjg5NzFlYy1mYmI3LTRhNjUtYmZkMS02NDdiZjA3NmNkZWFuYWN0aXZlTWFuaWZlc3SjY3VybHg+c2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTpkNTk5ZTFjNi0zY2NhLTRkZWQtYmQzNy00ZWZiNGNlNjRmMjNjYWxnZnNoYTI1NmRoYXNoWCBXrLWiLBM4Fry8A/IB7iu/MxCzouPeypKJ8KXHWVmayG5jbGFpbVNpZ25hdHVyZaNjdXJseE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmQ1OTllMWM2LTNjY2EtNGRlZC1iZDM3LTRlZmI0Y2U2NGYyMy9jMnBhLnNpZ25hdHVyZWNhbGdmc2hhMjU2ZGhhc2hYIOrRr0uzfKbpI/y1tdRpSHVHRuX80+uIh+cw2RZH5pJ5aXRodW1ibmFpbKJjdXJseDRzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLnRodW1ibmFpbC5pbmdyZWRpZW50ZGhhc2hYIL6oKgHn8ipVI1ZuIgc5UOQXOaNUtWpLm/+S6jJ15lMvAAAA32p1bWIAAAApanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5hY3Rpb25zLnYyAAAAAK5jYm9yomdhY3Rpb25zgaJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggKUevMNQb3INb3/hvv/JzbzmO7ovgPhrp0xnw7pSYsxhyYWxsQWN0aW9uc0luY2x1ZGVk9QAAAK1qdW1iAAAAKGp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuaGFzaC5kYXRhAAAAAH1jYm9ypWpleGNsdXNpb25zgaJlc3RhcnQYIWZsZW5ndGgaAAGTd2RuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFgg1ZsOCBtzAhQceE4Tv0FH7dD41RrOtuGm9vaC2WW+sJ1jcGFkSAAAAAAAAAAAAAACwGp1bWIAAAAnanVtZGMyY2wAEQAQgAAAqgA4m3EDYzJwYS5jbGFpbS52MgAAAAKRY2JvcqdqaW5zdGFuY2VJRHgseG1wOmlpZDpmMmIyMGI3OC1kOTgyLTRmNjEtODEwNi0wZDA2YWI2NGViMWV0Y2xhaW1fZ2VuZXJhdG9yX2luZm+/ZG5hbWVnQ2hhdEdQVHdvcmcuY29udGVudGF1dGguYzJwYV9yc2YwLjY0LjD/aXNpZ25hdHVyZXhNc2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTpkMDdhODZjZS1lMTQ3LTRlMDAtOTUyOC1iZmJmZThkNTI4ZWIvYzJwYS5zaWduYXR1cmVyY3JlYXRlZF9hc3NlcnRpb25zhKJjdXJseDRzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLnRodW1ibmFpbC5pbmdyZWRpZW50ZGhhc2hYIL6oKgHn8ipVI1ZuIgc5UOQXOaNUtWpLm/+S6jJ15lMvomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2R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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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//s4Hbx+cHB7sHVjVg9f71y9f++Dt7ecvXlxZzDYvbexun610Vm5fW3/88tX7d5cnw5ryohaMCsPjCalttVpnp8fCsWa1jpZXl58827PmdH2xtXOWT8/2G5vTq5cvPT94NE36/ub6eHg6Zv700cE33n7j3rXiye58nvzWlUthOj4blefnVaPd6raKndd7a5urRUblJK2saMzM6RnbzDWb3cUObq6vzMt6dyDldHL72uaHX51POUkVW8vtd29e/u3Dp/tH52vdPCcclmlUKVEqSOZ6QR/Lak6omhEudos26+55WZXCAADivHXe1HVCMplV+aPpjxQVJCGCRY2izpJDoapsFk1yMB5MAptmBr7hzsdzXzTmYb79eufmlZWPvzo9GqoteHNreXx6FNRWSZvNTsYTmrnprAxBWi2b2Mcw390fnwxiZuHurWt5QVXgpcXsyuYqh9nOwfk06aXVoprNv3p6+sF33jobv7y61T7fO6jGoyz3vf7CfHL87rs3p9VsMp8XeX5au/2D2b2reQzj4xOpVU/HurXKLahvrtnFy7f/1//1r7sOlvzseKfcOUiVn68u5RuLzcHpQZ2o11559OjF3ZurN69u7e4eP3z4ev3KZQKzvpAtLi4dHJ5trl8dTCvK3e7J4Oq1jYPDQxadVWn/cLC5uvjLX398OqX3377dygKH88XF9nQyxxjOjl8vr3TXr1771/+v/3Rtk7/x1t1nr543OkuBpd/vPnv6sN3rNnpLnTG0e+1hGY8HZ+PdE9/sLK73dw6OXx2dXrp28/PfPyawW5cunR4do0K72xtmcHa42240293+00ePxDrXmDctX91crAMHwUvXrh2eHp4NJ/duXy+6zZfPHj84TD1L79xbvrzSgHJcFP719vat2/f6y0v/4T/+l+Ve47vf/U41q/8//+HHb95a7nWaP/n48fHJ8P69t3u9xi8++uzzFxPbsj/8/vsbq2v/8NP/vSDzl3/ydkOqD3/2szEUJxOOVfjW169Xk9PJ2emn5+MHT3Ypz69du3nr9s39nZfDs9OD19u7x0eJw9ry8kJ3oeguHu5t9xe7rU57MBhNZ+NWq11XtTVZCMHkvjA2c/D4qwejUdXqLb+96Rp5I1UhVOP52cHpXmgvLr5z5SqTjM7OqungvIzn8xkAriwurK8tFk7SvAqIWatFBLlzqytrMVT7x6c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bl9zJbV01ddW88/Cd4//iX23rcPLmxcX52eFoPHpy79FHPyjW5VfPTlv5CpI0iMTNCv327t748M/eb+eZaorvX746ffG5AslS6rfbN1fls6+/fZuiBDGtPLF57854b3fy6PE9EVjczGbzRSszgFrk9eWWe7u9g4NBcIvE8LBr3lwULsCgnVICHUxd42qOr99e7ozTcdpugT4aJ+dvXm1nl+O93YODvShMoM7PX/d7XWLfTbN2x9YNnk6vp9NtEygLaj1bfLtdtFomayWzVbFar7NWvruzS8wC0O6Ujest5ovgN5LRaP9epXm7uJxO5wgsKNtVnbWx3R22Z/HF9eZ6tnnz/atf/uzOj/7s5x883v6f/v2n3540osOHsfnk3YffvvymKfDDD94/ffl8vl4uA2zWdHzYyzqT6fwC27QzyLZVM3dldK6s4minQ85vvchm02t3lc0//342GHc+/PjR/DevFqWejAeLTbVdleNBhjo5vS5VliyWm72dAQlFwSyzoY6CJKg6Xd4urlvWHI3ab69XZxc3aa+/vF52e9fv3jPropQkybv5dr3t7o/TiS5e6+dv61fPL/Ju2vg6y7VaEIgaTXrz9dVqu7bWsFGbOh5MOr0OliBBJPqwt38QFwuJPk2oaUITldapSVK3Xc9WhWoD5kpbC6ocdFuLEOqqAPDOURV8N89DaIJTlWtQ51erIlTVDz94VLnTbVnz9flgOKxKl7eSnXE339RpMnj+dqnTwrRaarFeLapRd5ekElftp/Dowd3zWTVdX+2Pe3VZGJspC9A4QzDotc7Prp2XpJUb5YoKQXvKzflFOWjBpJfFytW3iHDxCcq29GyxQestVzVAJjVAUdfbDTJRA8QxenadflZt6WpT9HKttTYaq7JOcwM+hBBDgMm4UxfV4vIiRmKGNG+10ux6VoZVPdt48jyauPGwNZtXBzv9ulysS8n2y3K9bllIgcpys93K4X43tdlitSydMha0NpvpxfXCH/boaG/caaUXs9Xz83UTrUHVaWmjVL1dUZLcrMsY+XgAdw+6Z2dTEOkaIVLrbTmdFg/vjdH57WqTt/atlmfffHdxRR887dw52vvm+tX07WW2czfpjzOeD9syyc355c3Xz+eJMR0DCTcRQqITZVTU5nrh9wzcv7P75as5E5APBsLx0U5VNZ9+d5Wkumuxl0RUelnIaiv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IJYgRUq/XGi7w6f7VqHALQy9Pr04u5rOKLWAZOJJ0ayX6zk707Zv6f/X3vvmv/+3v2dHR58dFRSom0Wwv/ckH+//ln39xNCjfurl65/patZhyNb19fW00HA+Gs8GoWphiaXNlbblz5+autfTl01dfPto7OjpOk6jRajpjlAgr66040k+evnTkb97YWlvb6jaie3evbS7VWo26kJEpQcatq1F5fnrebdXu315+7/71Xn95tiiPBu769WannTnnWEYLDxfj4mqcn51fvTi+PDofLrVr929svXtna3OlniR0eX41Go4Ayn67LiAHO7+9vfy7373dTvVXj/bWNrbv31t/+tWzsjRCS5VESqi0VXv17FErkyawI3V2NYoULvUas9x9/uVTaya//d1vRBS2t3Zu3dpMFNWbyWI2ybTotDrzuVlb38mancOXh69d6J65rHxgobPG6flw72B06+Z1a+fj8eDd976ZNdPBYPTkydedVq/ZrLd7navBldQ6r1yk1LWd7VgHpakWRYeH+1vrmwRYVTbWaPIFM7EvOp2mt+anP/1FPnfOwsXRxd7h0ebmdqffXyxmUtLmxtoXX3653FtqNNKqKGu1dJbP+/3Gr3//vb29g35v+cGDu1EsHHOz3nIgR4PJ7Tu3XVG0mq3SFvvHw9K4Wpzcv3d7Op9+8emjmzvbaaOTpM3paPrt99547503P/n4806n/40HbynB3W63224BoFKk4sg6c+/OTUBxcnY1ns4UEhEtLS+dD85D4brdTprUlKrV6kvHJ8N2p23c5BsPNitTPntxpVRjtbe80m3ee+der54sL/fO9g4Y6OzsanPrWj4rP/7k03v373z0+OjjJy9zGxr1Vrfbaqaw0Y2arTRSvL6yVJlQlmZWlMBojBMCSy84L6p8KpMkdzyfV8i+1WiVzi6tbayuZcb62WK6ur6xc23dlvPjg9OkHi+MPb0aooBOXb391o3r17dllB0cHa9vrBTFLEnV0nI8m47yfBLF7elw0Wol996+/eTF2cWoSrO6cflkPsqyeGmpubbaHc/zw+OTt+7fPz/e//TLTzY2d7wPzGY4GvU7jd/49bdmg4vhaJhF0au9l0pFWbMRDE9Ggzhpri731jbXsjghhFZNUYTj6bzR6SspojR5vndBkiaLhQfsdGNjA4RiZWPn0auTn374abPbSwSOBxc377y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XX15aqn1+/2Ern0QaJoOT08FMKvji/qdfee3aLLV/8NOdL1Var9668vDR/c5i5y9/9G57aTGpNLq9idHmzu2rO48efvrJ7uVLW4tLqx+8/8nRwf7lq68/erSzu394+dLmvc8+SZsLS606ATJqpeJ5lr105cruswdMmBXh+tVLH37yoNNoHx8d5fPZd3/umwfP9vu9rhbSBzudDer1anc0VUIHH8aDcbNZT6qVYr+MYtNqxHFsRt3B9ub6rMhSbfJyMhlMFtvV2ag/m+SXr273e4fekbceZVoEv7vbbdzdaDQXfvDjT0ytIZOF2bRIgg3kjg+fjyfzS9duDLpPZaw+/+IBCr3U6Cy3F+q12u7+XrNVU9IYIZeW1t95/2NneXW1iXLsytnu08cHJ8en5/OA8sq164HlyfOnWumDw6PxzPWGM6NTItx99uD1179sg/j4w3sLK6vVZuv5wen21c1aNWqnMVD8+7/727duLf61731z1j+c9I5PuxnEUbveXl1IRSSePd2dTse/9HPfabc7Dx58YdJKNa398M//stmuZaXtj9xgUix1Oq12p9s9eenay5HGvac7wXF9ofXml78cpB72p93BqN5Z3lhfP+12e90RID7+/N7padlup0mzFjJnKXhwCtmXmQSpFOae8iKwR81WhyxhWauYmnadqi4KGVyZTafzWlSpJo+fnj98dL65Vrm2fen09PRg73B5uT2dyv3j7jQ3Ny5sAQ3OT84u37i+vlG88+P37J3rd1999aNP7k8m01qj2khSW1oRw8bi0tO9o0Bw+5XbhwcH84n90je+8me/9zsLa53/6G/++t//Z38yLcx/9p//1t/+n73+//1H/89//V/9472P723Ur95oh/d7fmOlNRoPvEclZKpYIUeR8SpIG0qPCBRC8AAiNZVYIAfDMBEBgVjLAIQEiZGCZGDPviAFMpJxnLiyJGIh0KPAstSRCkDekwJlkpgpOFs4GwS6tNIowzibUyWSqEVhGcibSEYahdBSgAd0BXtiLr1D6YoiNkEgE2E355mXhChRGikkso4ihYxC1OMoNkZjSXEynxcohFKgNLKEEApPGIgRhZDAAQMHw2I8n1NgqZMIBIMKLjieuXGQUqOCukiaaYX8TEVpWeSD4awwuLrUYhal85mfKYNE0nkrNBRZhiCn87lq1LWS1Vpsy8AsvLeZy9M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plt1udzLu96/6L548zfJM6igvqp/9/GetOL15awsCvj59vbO18c//m382mkxipRpxazEbnY4Go6uzje5amjSnC+4ttUUUxUMzHC4G00UkQrddu7qaPn96EWloNhs6itrNev9yUFSuu1xvLW0+ffKy8nzz2rZz1S/+9he1BP/JP/nRaFz85X/51dHr0z/8w7feuf/B7ubyy1eH/+Unf/7evfvvvHXn2dOHraWdl89fmby4d/fOTz49m7zqa4n7+9sn09Pzi0Vnu9VdXXn1/Plo0K/XaoPhdHVjYzQctOv1tCadNZPZZDCeJlou9VYfPnr4u9Mva7XarXduzobnuWuPhsPV5fVXz1/3lnpmcdVb3X7x1eFtb4aD6WQ6ffvOvensIgq+mQil6OrqSoNotRvNTtKst1qteqeRXA1PT44HH31wE0MZaf/65PjuezfOzy56Teq2Gr12c3f7+l/+5G/iJB71J1dn553l5VYCJwevpGokNXFycvaNb9ybLuZJI+0udQ9evPj4nTt72+vPjkeUJLf2l5UAkbZJ1UeT7OL05Mff27hze/uLLx83O73+Ihv0LyOAjdUksYX0YVJl9966nuX22fOjxXSx0mtGWlRlvrHb/N1nz3f3suvbvSRqe4K//emD+x/dWO41/Xz26tV0ba/6+Jvv/PrvvvzJ33xy68Y6BMRqBAG6qx0ToMrKG7feuurnM3OaKA8MK72GqexgPOwur9bSVru7/LvffnZ4evTRt975/Zcv15dbvXZvNJgt77+78E9ribDGj1hSq4PzypZ5s9muN6KL07Nbt94+P3+NIHauXctm4+VuO02ipFbPigIAknqtrOzJ1blMI1NMO2u9tZUt0b8846OTk9ONjY3D4xMVx1LRbDZZX3vrPJL5bDaZDJZXt6bDSbfRADMmy+vrrU6ne35wMPEzzUGu6kbUevfde2tba69fvbLWLK10lIjyohyPp3mWee2X2p3hqDg/uXj2+OjFgYvruLezO56Pe83a0mrr4ODEUbJxbQWh1AJePnmSl9Ttta/v345rjSKbX55eOBuAklcHr49PjluNmimNjKKrs5O1rdVWGufz2Wg4GgwHcVofT/PpNL/z9t3ecisrMoUklRoMh61Gu16P19ZWhVCnpxcPvnrcaMqLwcKZ7HA6mM2z1W47lnGRY7PVqso86KTXWGqmUKslnsmFyTx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bG+dWJy3qr61vHNS7h61hEKj+SO4ed2JIy+X80vzi/u4u1km2kANIa3UHlOiT01NfeffNG1euj4fu55/eMwxtopZLO8b2VuOTT+84pr63u/ns2bNWu5dJZSZnZruuN4pEMlcxk+lSqZRKOBjJne31TCIzOz05Nz21srLsDgOMNECIbibHHofIzGZLsQSjMM4VytTSUwbReItJsz8Oe/22QXUh8YeffHJweEJ1UzEvnbSa9aNPfvUz129+4+s3L59dDt1jIEcUq3SqsLR6vuPGX9x6eOvOo1uf34cKzs1Mxe3DcXfHMXWMjW6zz6NII1pv1FOAEyXicAiVqk4v+hzcvffiFz/+oRw3i4XszPyZxdWriysL6Zx9sPas1T7yRmHKsd69+frv/dHb82dP5UrzqXRJMlStFicmp3MTcxG0hhHBGKctMrN0SkvkTMuerxV14QKgpNIrE7V211178coyTdPRRm5QqlSnJqujANV7/WKpQjVnf/cAsNAySGWiWp4oPn25JoRIpTOpVOZ/+g9/YaWS77155f7jbQC1+blphcDTVztcSt0wNQqSGhc8KJYrnU6v1zxMGTD0ve6I1/f3Clm7nLHjYXs0aNcbBxCTB1/ctmL+r/7514YD/9//yV+cdDpn5he2N7ceP3lyfHg0PzMbecPPP7/dc6OlC+9aidywO6CA/+rDew/vr2Uz1aWzpzbXNy6dOfPPfu83v/LtryuiB0z/F//mH//u7723sXW0ubZ+67MHmFrFWg3yYHVhtt7oHB03333nzaoD7929qzDmSPvks7sQgBsX5tz2nmUkjnviz/7y7yyAK2nIw5EPVSJVTuen52uVjZdrV27cNA3z0YOnbn+YTGVWVlY3908m56bPnjpFRSy9/ubOKya8q0szp86fWt94MTjepsh7sbkz5kY+Wzi9UFxemvr8i1sXL519482zW3tbCrKN9d2MnUpl8s+frWMd59L2wc7LYrmipYrr+3XDTCkZa2A8Nzu5s7mr2YlEzjSSybvb4LOPP/nm6yvzWRAyFQijPx6USuVml+3u1v1R4BCpSaHpcP7U6edb3ebu+utvXzNzztqretK2Vms4djnExDI1JYzIV+1W34/1UOB+vZUU7tnp5JvXJxWF7XpzNBiI0M0W00wx205aJtje6muavbhQAkD0BkMMHMsKp6bSKRsfH9ZHo35mYjYC1vp2a/vVo9PLU5XJ6UY34BFfnpnJ5JJ7jcHIhVRPtBr9k/oJJMhM5sOAb2y89D3mGHa14CDmWqY9NbfopLOtZuewzY/2j1vHe1OVTK1a7Y9828mmE4Zp6itnTrme+vHPP28c7+dyjoLw5OSkWq1cf+3i3u7+1vZmLuVU80kV+ynbevx0p9fz33nrMlHhsNOCCCqojYPQILJaLjEpo1gIGReSRi5hQoAUUgJpTKrAj0MvjmKuUSOMeRTDYRSFMeciBkJBBBXQYxYNh34wDqVSACKEQCrlJB2HGCRgoTsOIRRASU0j4yB0vTgKOEASACAUZFxBgATkUEHBVNrUHY2Yug6wwggoILiSAkDBhe95GlK2RXWqUQ0Lxj0/GI48L4pCJgghFsUJSmwEUrZpOybRdceyCMUQQsZEHHIWh73BaByMCQAZx0pZJtL05ijY3K/7XmRSmk04tULW1jVEkZIqjpgXxEpJw3QYU34Y+Yz5YTQceUwyz4+YUuPAj4KxUoIgRDXdtHRMNCkFggpr1LAsw7aFAkIICIFpaBgDCaFGKcJSKclYJHgYeQHBSAIpY27axLKohpGCKhYRIkTXdWoShLGI4y+vCL7sWWEMIUQCAqUUgRBSRDRDCqm44IIjjIQUXx6HI0oJwkqJOGIIEyURIZQSAhTmTEAAMIG6poc8hv/rv76EMRFMKAigUoggALAQ4ssn/kihL+9eCMQCSCmVrmlCSKE4ghggEktGEAQCQCUxJUoo+OU4CACCGZcaxRrCnDMMCdYAY9wgFEGlMJKMcSEJpoggCbhgkBIshCJYCoCE5JRQKIGQkhAkYkUJxBrlXMgvk6kUAIUoIpwxrjjBlMUcSKEUGscxQsSLQsYgi3ggpWWaQsYEqaxp6YYWxgxgIDgQSoiIRxIAhKhSpmlwGSEJGQbemPsR40oqJZVUSCFEpWVYgRePPR9omhQ8n6BpE6ccHSpFdBp7yhOBilksEYsiRCmC3DZMgpBCjECkFEIKB2E8Go/thNPr9SCgiGBNx4xxKAFEgDGACNAR5lJACBWAFCmEINVpGAiKhalpWMMq9AuFNONw5AYRlz7Xu606IfG51fnqRGnsxb1uFwPUdfuUWLXpmcO9jXKlVC2mpVS5YrHb6r94+fz06VPz8zPDbhsj/Gpnf/egu7qYnJ1cGPQHRDcQVr12N+skFMQnJ412p10tZMvVsufHQRi5PXfQ7/oh16jhR8ML506l0g5QcjSK270+hgSpYHPjoFTMFDMZpMFB141j6EV4v95nUNOglPGQYAKxNhwHuZwOBMsnrVzOTNq25Vh9T7Va0W7j5P3X5mqVwqNXzReb3XjUPD/vFCuTd060X915qcfi6lKikncO602ExPJEKTQL/eMdqDhjDACYKZUPto7KxVSuWDjYP0ylTQJgMpft9oPecJhNpdsDP0HCTCK9dTLqMaPe6ToYnF7IO9nyiyfP339jScGw3hgf1kdnz87ntODXz9ppg0yV8k13vLndWV2s1iqF3aPjzf0B1q1C2kxYWq99cv7C+X7fDZhLjerG8yeZAnrnnXdfvNzttr3F5Zl2Yw9r9kl7MHSFpclLZ2fjcJQuz6xv95SZYY2nFhRf/a3f/fsvXv3dT379x18/89qNS3/xNz8rFidgHGsoOnv50sbWwbPn6/lsQaOgMxicO382GPa7/e7U5PyTp6+cVHLsdZaWVk+Om71BfzB288WabTkfffhw4dqqFY9yKJ5YmPn08yeKOO9cvxp0d+ZWF9qd1u72Hsbo7IWzPOSvnq+nUoVB4OULJd3UN3eP+6NRKpGNXOX5rfffvRBL/ld/+0U6nZyYyGQTMJvO9AfhlQsXN4+aP/3lZ69fPWdpandnizFg6OTqueWO6332ZP/Rk/bvfH3+7SsrOwcHEUo83hnwKEonbM9tN9udN9+48fL5s0op/+aNs163vt/oTS1e++TTe43GSS6Xrnf7331nJVecfLK+7YZABuNcStt+tb24cn7taPxy57CcRjfPn0IU3X38ikNrOBr1mcZClsklMwkqBvWvvnezftI+aZwsL57aPzqgidKHd9ck1IPY1yHK5lKtoZ/UpIWUYxId00I+gcJBpZBrNk8WFqZEoMaDQaGY7rv9nUOv3uUX5+LZ+dM8Di0DQyDS2Uwc8vv3nxYq+WIu1Ww0OVelSq5aLhULWcTig+ZgPO5lc3lEk0EAPvniE0cHZ1cWk8n0wPNCv1erVDEGlmkfNQZb67ulGjm9cuHVi72hj6md2tjeSGhidqbCvDBbzIWjYOj6fiwBYEpCwHhvODx94WzKoYe7e24gBkNPKshCnp1IEkAJNriAhKLWyW42m6jUZrv1zUptrtPtJRImC8PA7Y+C2EkUOv1BLlfCKGo2On4QzcwunDu38vzhA4D0iXJ+7HbTmcLG7nG7Uy+Vyk+e7mumXpvMNpq9uYnM5GQtZuKk3o5DUMgQl+mMoWIxR6W3s70OiBbHuD2OhYSFnJFNJQcjL5mwSmnqoPHLIy4gosSUcljKWOVydjTGbgjbXa/dOinmnXza6QyHsTIHY3FSb9lWanlpqpSmD2/fmV6cHET45V57qjKZSmqbGzsjL0qWa/X2IIp9DZHluSkFxP76qzdeP6+I82r7ECO4NFV68eTxlQunq6XyTz78+Mq1yxPVyf/hf/7PpXLiK1/99ubmY388KGfz7e5gY689NTspJTP54PyFpSAQ9eOuF8apdHow6hw33Fxx7upcuLHX0Y3UzOycZsBXz56M/fjMpdc21l/plm1Re23tuWDorbcv+25vc23PyGRnZ8uLE5VPbt3pjiOXiaX505oGoQLS6/n+UAq0cHql02jsbmxWcumFM6fXXh7uHR9pGkllkxPTs2EMn77YMYi8fHoas9Gls+cePnn+009erSymvvXVd+89fdisjzK5yszs5PqzR17MjoYSW3kvDLqt4ddunjfAeHdvd356dvdgn0lx9cKZdrvVbbuTM3M7O6+uXT+XMQilVqPvHh3uZ7LJbtsbeDydy59dXWjuPkHphe2XL06OGhcvn97cP2CxQtCsVYrlSm732b2LZyZWzl/+X/63H7z5lW98+PMfn1qYNW17/enDTC7NoLN13J2upK8vTx02ttY327Nz86Ne49Ty1Ob6SYhQvxMQogWaneHtr1+rYZL4/s+ep8qlTqtVyyds01nbqFdL+PUrZ7qdY8DD8sTczvrG2A1mT59aP/LreweXltJOytzdHYQCWhrqR2q97gcxSlnk4nwuRVk07g9IajbrlOx47VV9yGHDV7qRKObMmWQcMfDp+uDEx2+cn0rGg3Grn6vlIn+YzuU8Px65vkHQuTOzo24HK/+4G05OFpdnZw+PD15sNRTW33/vtZ0nv354gmYyTjjqRRxna/lzp5cbB9svN44rM0vFjEFxEAWh64ZAEjtptk5a+2NqYpYw4crKPBQSIK47SQyFNxwOfTkYyaOjeq6QunBhSdMNDMl42CepQqc7uvXJ5xOTk4Wp+b/64ScCkpliisbNd66fP2oM7q3tYz2JKWKBqymQLZb2msO9VmjqcCJnzxWs2B++aERdTxBDg0JIwXWDGMTUEaI40DEBEBCITQPHLGaMCUgFlwoRRCBCkEJsWdjESMRBJ8RhyDElQgpDJ7mEoSSHSCmAkFSabnVHIyaUIjhiSDGGdKQ4wJj0xwFClOhEMsaZZAJQDZuUAgBiKQCAXAgFQSylgaml25RKm8BIxEGMBgGTiiOANY1IAQee1xmHAiPAsG5CE+sZi1YSNO1ooZQ9P+qPWDFlzUzmSimTADQa+81uL+DKsRxMpI0p1SDVtTiKg5jrRCMEQogIlTqhFCOlgFLyy+YkgVJhDJVSABKIOFRffswFQEGhAJQKQCEEokgxhAiRSsRRQBGNuEQICs4VEAhhAYDkACopoZIcKsAVAIADrCPOpRJKNzWoRMQ5RRpASnEAgAQEKgUQAlgCBRBXkmKCKI7DGEGIKfb9sa4bGGIumKZTJDHVMIsFRlASACVAAP4XJBlDKBRHkMqIKa7iMIIQ6zoRQiikAIIAEUwIohRRjBDGlEDFsQBRHBNIIEVKKSElRhhihPUv8QSEkBSCEQQB5HEYG4RKIKIolowjRKAEUCnJORASISGkBFgiiLGSpmZ8OQ0CJaFSmEAAkRBSKakQVEAphYECQknGBAaa4BIgCCANmOBCSQAggDpBgMCkaUoeUogSliORGI3diKkoljETo9HY55JiJHkMgVKCI4UkgP44CEIPKAU5R4pQSIXkIuRjNxh4HsCQQollYFNiGgamGtFMFivGY6QURMTEmuNYOjEQ1EI/jqMYCcwiQQANwoArbhhaGMSmZlENUw0qxiiECCMFpU5BQiMYA0378ndFrGGKgAy9MVSBiTlBQglOLXPkx83BaOCNh6NB5Lcr1eybb7+Zz5a2t/cO97Ytx0JAxYJksunm4UaxkF5eWrTTOdPKbm9t1ZuHFy6cW15ZGfY6UcSHXnC0fzRTKZxaXPV8Pwh8CIQSKpVJRnG8v7uDoVqYnUlncp12p9U43t1c7/VbtpPIVUpOSpusVZKp9LDvv3i+Mei3Q9dFwq8fN6emp5dWVoKA7ewcHzf7jV503Gx57hhHPZtGpWI+V8jrFE9Ws8VCybG1bLGgBG0PguP6sH7U3NnYms5DSu1fffb07q21fr05N1MtzVx4vN752S+eQI+9eTVfLdpb+3WKtcuXziLCjl8+i+O4ODmdL1eWlhaY189k9Gwme3hwEHDgpEtMsKPdvdGo4ei6iKKEQy0z0e72dTvRH7hYgErOyCaS3daubQDLSnbqveGgn8kYBiX90SiRSmOKBiPXHXn5kuMGQW80aB63bYuUsw5j0UnzoJhPIyh7nUPM0eHBsc/4yuqlzfWtp082J2emWvXO/mFPAiIYy6folavnW53+0pkrvhccbDxuHT3nEF6+/vbd20/+6vu/fve1uUuvvf1//cn3V1fONI53hoPGm+997dn64U9/edsybULEKMZRpI7260/WNjG2hu4wldShDC2NtJtt20n6bkQAvnrpwvbW9vzSdO94f9TpXLz29nHLWzuILl69+mJ3TU9YD+8+Yhx99du/XZ2YGXVGw/GAOnoqnZyerbVbR9//i8/sZHpyan5ze9uk/rvv3ADE+vzTe+dWJ99759rCzGSpVNExPrM4c+fhk1/+/GMEQKvVbHfd6sTKwFdnzl3TnORRo2lR662bK9/97e99dvfuQXPQ6Yj+4bpFpaGbJyfdr9y8XsxoK/PFUwtlJ5Gtt4btRiOZgI5DkW7EUrx2eiGVNkeDzqDVAEGQzxU69f65S5crU/OCu9WyceXc1e2Do5/+8u7r77xVyGUqpWytWsIEdnqDXrNj2g7GpNfvmbqlaei43nj1ah1LICXjQgmEuj0PxkNTp5lU2tZpypZB/+TKlcu9wSDh6MVcodk5SZfTyDQO9uu+P7p0fmJlcapczA8GYbvvtfve4VFrHIraRGl6qpRNOTfeuHH1tWsTtWrK0QejzsHJoWFqpmXv7ewMe81bn//KMdBrr71mmsm79x8yFiwunibU6jQH9+8/2draTDp2PjOhFI0lkiLYXn8qguG1y+cohkE46nW6uwdHTEmEJFBhOptstPpm0jo5OfZGfiptETV+6+030tlCjGA6kQ5G/qOnG4f1ziiMHCexezRY3+4wBSwzCal5fNwplaurZy5NTc4VqqV+f1StpEv5wtzCXCZT6HW7Dx8+GI5aBwebAY98hTZ39w0dLc3NXb92/sqlhelKIp/OnD+1WCwWvFGPqjCf0FKOlsqkIR8f7Wy+eHzHj+PZ1fOcOBOT0zNT5UqGltPpcDwWPDg52AuFTJdPF8rFXqfXbnWdZDHgiU4r7jUbo/a+SeKJSh4jYtnZCxeu8TBUMszns323f/f+PS2ROH/t6kl7pATWATjYfHlysJPPOZm0JeMwk7AVl82O23DVwmyhWMo+fbYzaNVtLMJBP5GbCKF+9/ErPZktZLP7Byd2MlGZMDsD9/H6KzuVGrt+s9ddXV1eWq4QCDKpnBvBk9bQHQ9zpXSxkBmPe0uLC0tzE35nfyTTN9/7KoLo4f27MvJv3Lw5HsvBwFuYnWkeHEIlaxMzK+fO7B8c5UvVpdVpinm70T5qdi3Lvnbx7Eyx/OuP7/3647v7G+u+H7z39vu2pd/++JOEiX/ze3+Yq8787MNbgIozy3PnTp8edkcP7j483Hqucbfb6TdbPSn59vbLU2dWfue33pudnmoeHrx54yY16N37r+7deWQXajFwkCRBv82iMYHB04d3j44PDMPxIf3Gb3wnY6pffnh3olJJ2lCJUCF9feOwORhv7e3ef/g4lZ6oFcsEy8Pdo6OT49GgnSTwye2PDNvGtmPZ6clSUcek2+1s7W4TJbI5++XmbqJQ9YOgfrz7xtULzcPd9ogLbOXShaSOZkrWoF3f3N2ZnFyYqGWV4ES3To471UoC+mPDxK2hz8Y9L1SfPTnkWL98vpo2+GQ51WqPkqlEuWQdN/nh8Uk2l+sMx/3mYT5lI51EAUfxwNZgo+2OxzCZsHjkizCkWGYdw9Yh8+WL9ZNxGFJb651019b2B7E5c+ZUcaq0MJHLk8httO6/HNq56s1Ly0UkjtZ2h+PY14wxN10G/CDGgEkW7ZwEL9aPIbVNJ1WZKPZ6/Zfba7qNT5+epzB8+vjuldfffvvyHONeMj81u7S0tdk8OdxZPnN6cq66v7PR754wrrgEqVwGEumHLJEtFhLcolgIc3P9mEnpjseN/V3AsJXKBFFUyCUrlSKidHvzYByGfuAKjLY2NgEWMwuLOwcHraP6mVNTra631x03Y+fB0+1COpV2EiELEdYsQscKut54ImdNJIVi0Umz9+KgE9N0LmknDEgEVwIijcQR51GkJIuFFEinmg4xpBRbtpPPF7KpVCblaFggKAlUMY8EE6GCEmsIS4il7wchE+PA77uBUlintkE1XTMQVI5lmYYmmSQKAAxFxIkEFCEdQwiYCiMJFCIUGUjwOBI84kxwIQDAFJma6egUI8B4xLlgEFGsOyZ1NImFIhRAIShBxVyqkraxkEoKwYHkchyEjaE/CoVtWuVMJmni3mhw0uy3WqNOr48pNi0rlbAR5EihSDKpIMKaruu2hhFkEiiIlBQwYkJIiAnFFArFIRQSIyCVphsAgFhwrJCmaQgQgqCmEaGQAphgXXGpG5jxGEFAscYkxxhCKBSUSkDGJeBCiTiIYygAQApCoJQkOhIx0wnWDU1EjCNAiQaBiuMYalABBRGlGCEAFUYKK4KghEoJSSHABEGgbMvWiM6RBBBzxgGWYehJGYc85oxxxpFGKQCIYgIx0qiGpEIalliZjg2V4AIQTKGACCGgBACSRyGQSgqAAAYIIAIJwBIISjSFFESCA8E551wqzqmCBCAAESYaAFBhJTFQAEqlAEJCAUwpxBAAQgnFAEMhMIRMSQkRi6KIC0KIrmuMKwAJUzFEAGOEIYAEQ6mUklAIQnEseBQLpfA4jMZBLKQKwghjBCGCSiokAZKmSZFUQkEpEeexYFxBiBCVXI4930DEMBGlUMcIYSQZkBJwACgljq2Hoc9iLgCUQAGuEJSWhqdrRV3HlqnFMXdd3/NCSAiC2DA0DnnEoogFseSSEMGFUhAhMvSC/igQQgAAIBQAAkKxRjDGhCDAheBSIsg1A1GshIgTDs0kkgDDKI4BggnbSiQtU6cYQC5hve222gOgoEZJOZ+YruWUYNtHB0KpqakpU6cei0ulkhLx5ETp5o3rhq73e6NOu0ewNjMzUynnO81muznQNHMwHKaS9uLipOt5nZOmqZs85pJLxUCr1TI1rVatSqBCf6yUJAgVK6WJiWmNmJ7fTWX0+bkpwcTe0SGTKu1YtWohiqN8oTI9PX3//vP17QMuKYfpvg/avcgxwOn50kQhoen6aDwSQFIsBp2GadKk7UCsdftea+B3B8PVpeyZ1dm9o87+/tg08dmV9MLM9Ivd5t1n7XIC/e7705eXFjvtlmXAc+cXh53e8dFgctKpFcvJROrkoL75cj1lmqViZeeoGQRxbWIq4mxnZ+zY+tn5JYyEG8QK0ka9wwAe+4Hvx1PTmYlyQYlxq+HNz08zFrpjP5vN5LNpQ4cJ00aSAy4x0WxDi4NANzREDIUIAjCKQ8DjhJmwnWS70ZqamoYw3tlvnDt7pn48+vmvt69eWkGYPXyyPj07G8cBwejcmZWDg6NWd7h/1Fx79mJ+frrgaJadXTsa/fij2zcuJb7z/hs///mP7XSucbLPWPC97/3uoxcvfv7hvYnp6dfffi/ygq2DI2xaL15tEc2BhrGzvZ1JkPNn5h3bzhdSk5NljIBQ8MH9O4YJp6up5Qn90tlT/TH48NMXxYq5u7P29bduPts43G+0L195a2un9aOfPBDIElG0tDCHCDw+Gt99cPhbv3P1a19788NffLE0Vfr2b3/j6bMn//HPfpJMJm9cPWXisUbk4cFJYWL6cH/z5VYd6XBmKqNpdGpu4eGzp0LpzzYO/t2f/MPQVaPRqFQq/viXn+w2vIfPD9a3d7Ipc3q6Ohr2Zmr5ubmZ7RcPDBJ+9f2bMYRPX+7efOP61ounmxuvlmYnr104n00hfxx0BoPpiWndIHfu3L989YaVLn5258HO1sFbl2/sHB/cfnJ09c13d7b2fa9HAKcaHg5HGtU001k9fa43GI28UaVW3dzeBtQahVyzTd/3splUKpmKOavZ2ky5Ztk2RrKYN//w9787vzDlDoeOaVs2WZifzKeSY3eQSCXPn6q8d311qlZAGJ60ult77bEbj/x4Z/8wYqyYz6azmWdPXnz26ee7u9udbscw7HJ5msdiOHQtJwFYuDBbfO/mm1EsDg6P5xfmSsXck4ePHjx+srl3FDI5OVmbm5smxHzw+NlwNGKxl3LgN9+7JlmkeHjj+rXxeNDpjDChXPq2rvW67drslKVTTTPbA79+WK+V846tbe7uH9XHPI5TWccLwcgLI58RJ2nr9mAcDUf87qPnvc64NxpvHRwetfpBLOrH9atXzjMecqnCMM5k7UtXL1imlkvnTi1OumHYbg93Duth5OoGWt/aKBTsWsEZtfbGbm/Q7mu6Xq0WhYgjf+SN+5NThes3ls6dXbj7dO3DTx7oemI0Gt+4eC6bNra29wq50ruvX714dvb27Y1Xe/3VU6cnpqekhK7Hmn0vgPa5i5dKxVzCJjNTNT2R/uzu4063l85me50eAGpxfsbU9F9/fL/jCok1w9AvnZ0/e3p2olyEPNY1zTK0XDZJMeQA7DVd7HeuXn1DIKs/GsxOZNMZ+quPPixWpgnVPv7kc2KltneOX67vIEL1hCGBNCAIYhSEcDQcnl2aVyIIQ+YG0rHSjpnoNk/SacfRKJaiUkwvzdd++Mvnn99+dP78udOnTn322cNep3f+wsrzRw/rjfb5M2eG7uDk6OD4pK5Z2X7XnV1YTOqg2+8oKBIJXUP+t95//bvvX37vxqqdpOvbx3ceP7ly8fzbb5xhQfcHP/jzjaP6669fma4UBv32xHTh29/66ts3Lr77+qVaKVkopF6+2nm53YgBqdcb+3svPT8+bA8+u33/7Nnzly8u3l9vP3mxgag5M1WeyNFcAl9bnb64MjPqj+89ady687x7fPhv/skfTM+W9ra35ucmNayKxdLebrN53DWtTKY49eLFc8dJzNdyTKr5ufnGUT2bzU5X86+2tsehOG72XrtyKWWAfDblR+FJ46RSnmh32d0HL19/9+3nDx9riBTzyXqrPfBYHLNiPrkyUylnU72h9+TJq4lqdeyPASTDgV/JpipFXSmuCLV1bSzhYQs+3jjMpBK6Gk3kbR2D4+P9fD6bL5hb262U4wAGu8NRJj9RHwhq27NFh1I1DiGHmmEn0xamRFi6njSRLnk+j8MQvNpzjfT86YWUG4EffbLz4MWxTvR8kiaMOO2Ak6H68MFWxiC/cbW0kINccQGNMBZ6Mt/sjAmhhUKuVCBbe8P9o3prELgjzzb1hJVECEk2vHHlrK5b9x48rqRppVw5brURBO/cWD7ZO3n+6MHq8qn3b57p9ron9X4qU8IItzqtnb1jM1GYyJoxjwWkI5/X641crgQA2t/fAxAWsumIuYSCXC5n2s7W+rbvRk42WSnng15P02htqnJwtDFZcBaq5u7RqC+1/b7YOmyajsF8MRj0QgZCjtxRYCJ1Zq5SSWuIku6Yrx10IVSVjGWbCEOFFdYoBUjGPI4FHYxGrseAUFApoOTQG3MWGZRkE1bG0BKmbpqaAJJzwQQ2sHIsk2gagYhgIiHujoPR2B8Foc+jIPJNk2oaRgRyyQUDABNsUoSgbVJbJ4igOBAhi0UsIdGkBIILIVQYsThUMZeOqRmGJgH3o7g/8pkACctM2bppQgwx0XQKFBE85RgT+VxSw4KJiAdhqAIOm13vuNNDABQzCajhRt/f644HHheCWwbRNaBrBCpmmnbMlT8aKag0jRKT6oRIwaVQCCqpZBgHCmBCiIYNyQQXPIwioBRESEouuACIA4gixnRKoFISCAFgGDMEZBzFCiiE8Zc1HYI1zTAIxQBArGGKEZNcKSmUggBIKbBOQx5jqRBFIOZQSUggJQRygRHCEkghA58rJpSUsVAQQR5zgaGCII5YFMeMxVAIjWKNGIIpCAgiFMEvZS6IOOdA/RfKlwspAINSEoCkkghpnMeEkC9RMQSAkIAJxRRHhAjJWcypRnSTSMADP0QAIqpBBSnVOJORlBKriAsEAJccQkCpyYWQnFOixXGolMIIIogwhVxwAIRuOVABipESEhGkhAQScK4QABhLAIiII6QUEEAJKaACQgKFpFASKERwFLEwigESmCCkYBBFiGDLtgxCHdPWCSYaklJRijHCGqEw5ghhAJVlaYmkZRm6ZZgRB4ORH4qYEJKwNCh4GIUAE0A0AJCUwLKJZdC0Y5oahop7YRzGoucO3YCx6EsjmYWx9IOYC25AjJUSAAWc98ZhGDBKNUKIEkoBKaRCkocjDygphCIYJiyjkEsnLRsrrBHdwJrve97Y1TS9mMnmUg7VnIEfDly/3XHDMEomU4l0MplOWEmH8XBt/dnYHVTLZUTx4WEjnc7DeEgpKdeWbt9f+w//+5/duXvXsEm+kDIMY+y5nW4TAtTp9VuN+rmzy1RTw8EgWyo4uQyTkvlBr9fJZYqpXOGkeSx5QCg2DCuRzJhGqtNu7dcPatXc4vxCxKL1zZeWac3PLppOdjCOhq6fyKTu33vYb/VrE5MYGe1GgwX9xYX0u++9aaYLO8et45NjDaupchoAlnCMQqHUH3R2D/cG/UDyaOXUYrFWefHyeHfrBOu0WMqlkrn7z57eubVp2Oi7787lbHzvsy9cj+Uy1sn2y1ajMbO4pOkJrvjaw0fDsVg6vTKM0cPne4ja0zN5i7i9VqtQsqZn5t1x/+B4iA1bcuVzqaAxGo1LGS2XshBG+0etjEMyadsLQom00diTQuoI7x01/X57cmpCAej6gYzFdKV2uF/3GFVYUzyGSiwuL3eGIzdiAcPPNjurM3hubur5q91yNZss5R8+uD815UxM1NqNo9XV5d3j9tqrnVwmJYLRhQtnqtPzM/Nn/Di+de+z2kTh/ffe3Ds68kf91VOnJieq//Sf/puPPv3ihz/6KJVwADSePHt8cFhPEqgDnMpmpmen60fHTsJ5892v1E8OERSphHX3s18mEokzK8ueF1ZzifdeW1ldXA705Ie37wcxKOri3ZuvP3j26vaz3tnzF+7d/eQ//fnferFq9kaOkXz+/MXJ0GVAvfnOuUpl8v/1//z/lHPk9//4v/qHn/zi6drxyqna3Py04nGtVNrb3E0l8gma+PXdV9lK9eKlizIIrlw+/+z5s+2D4djtbe/upatTWiKDMDrZ2Xr57MXU1Kn52clTy/OGkyFASt5Zni0PWyfV6tR3vv07u7t7P/zz//TNb7yZLtZOGv2LZxbSDux3t7sDtt8YlCdP9SP8+OnG3NzsUbtx+9aT23fWL50/PRyP7z3Y+vZvfGdnZ213/dW5lVNp2xj0eo5Ni9ksiyNE6Ge37s1OVjUqk6mCkyppZjrikhKdAh66fU3DlWLSoNF41NQpQhI2j49/+qMfnFuZXFmavPPpZ4NmnfNB0rayaStXKDb29/aO28/WXiRT9kQtW66ms6aR0NDi8qIfei9ePMsXU2/cfP3MuQtzi0uuF33yxf16o5HPV8+fu5ir1KqT84f1xoMHD11/aBi43Wg4jlkplS9cOH965dRw0OYqfPz40bA7miiXpicqs9OT2Uym2ahHjLV7fS7QqbPzJgVnl5dn5mc0Q4NcBeHQdhzbSaUzOWraz9fWZ+ZmrlyYL6TTCdu4eLryna9/xdDR9qsdL/Ydxyzmi46jpZLa4vLycBg8fPhQiNCkwNK17c39l69e6TrttFp3v7iVSeVWVlbDOCpmsoVMdm6memZldWF+yjF1Fo6v33zttbdvMMmoYUqB95uDXGWmNjGniIGxbjvO0sqpP/qD30852sH+XmfQ+OmPfzhRm4Oatb51+Pzxo1oxf/P6yoOHT27f/mhvY3MwGmNEe53Gx7++98kXd3oDXm91O61GKZ/NJo3nTx/540Epnypn80kHrK7OUpPevnuPjeOg147GI9ummXQim8v7Put2ev7Ite0k1Y1Gp/vq5Ss7lXBShWEI8oXE7337G3OV9MO7z3XTqE1W87l0NpVY395NpPNSYm8Y1lsnyWTCsKyNja3tjTXb1LrdEyHlwUkjCNjc0pJmmKlCrt1p9dr1peW5N66v3ru7/dc//Ekg6aVrN15sblYKiW++/+bWy52Ii3dfv3RmZdIfD7f2jp++XNvfevWVr3/za199mwWjmalyv9XYfvWU+Z2kQ77+zjurp5cf3F07PNq/+fYbv/2973zlqzfGw9Gzh3dTKevKtavPnzxZf37/ydN7CJOpxZXTq6ecbMGPTY9b/jhAHBw36i1XHu8f7r58/s33X/v21y8cH/kCiNbxoa7D5emp46NjJ5P+gz/+F5evrhgmffjw0e0vPr5x5XS2UKi3XajRc2eWMrYmFRj0xkKIwWD05OmrhTMXJ2qpfqc1vbjQF+rChaunV1dHQ39ve31vbzOdtnRKEpmM60eHrW6mWLpz55ajG7qJB+7YSJYzDkrZtNke9gedUiZh23oinaQUdzrtpK0BoQDGUSROLy1RDAgSEkTJTNZjYnOrFXgh0WgQjlfmSyoMm626aWcDCUZeODdXvPWQe9ieKqdePn5SKlWSSdMN5DiM/YgLiFOZVMamjqayCZJ2jHLF6gzAw1dHCFrXX19dms0Ne+72830WRlizZlZWr702v7cbP3iyFkqWrSaKWVuOB363DmI8GLJOb4hNc6pSm5m0XT+mRgJDrdv3Rq4vFNUJ7Q2alVJep+jp2tZkNbcyW/zk1+vHjd7SmTMaoJsv7lPDeO36GwAxf9ROOvrswjJX6N7tu6l8fnVpodFqRjxuD9jWfmNu6bSdsA929wxNF6EoFvM8GgIVl0qV/jgY9vuWTQzDSKaT84ur2Uy22+tevbCyNGkdHbaPetFuJyJYzyQRJsQTVGIaceH6PsKoVMhmHMvQTCFZ3wu5VA6lto4Ej4SQWLOp4XAhBMBMqVjIYci4YFChwA/90JOMJZO6qWOLII1AFodh5KcdM52ghaRpaCgMYhbGSoKAiViCkHHGgeQxUjJpaTpRFEspxdgLoziGgCIJTIOSLxEALqOYSSkNzdB1SjD2WTT2w4gJx9ANzYxj6QbRwAuGvoeJ4SSSGgBQMIwVhciAKGnghA1TSQ1DKqSKJQwA6Q6CRq8jlEjbCUyg64edodvq+4ZGDU0zEHEsXcYhl8ILIz+IqK5BgAiFjmUlbYMLGbMIKsRiJqWKJYcEU0gIQAAhghHAGueMCyUFi4EUUiBKEEIEIUKobpoapVBBICCG4Es+K44CFkoJhJRKSKlRSjCllEJMEEQQAEPXmeIAQYIJQFBJiSACEEGIABYSQDuhKQwJ0giCPGJYJ0BIIQTGhGKCEcSIQAAjFiqpAIRCSKpRDBHgklCC4ohLpTAClGiSc0wwlMrzfUIIJjoXkUZ1BAHjguIvhTKIJAx5QDDxPd/QNIyIEAwiomIFEFRAYooIJEBKgRDEUHEFIGJxhBE2NF0pQIklmJRKASkQwABgTBEXsZIQEEg0SoCSWEquIBbIpAggqISAhGIqWYgMCpkEhDLOAYQEkbEfcsYQQizmlkmkEBhpnMeYEKpAJBWBChJsSSokhzqEUIUCKKFMnWbTSVOjkoW+HwoghOQIEyRx7IXySw+BMZ/xTMIiUlJTc3Sq6yjwYs5ZFKmARTqltmVGXqBiDiiVACqJ7KT5X6QLoo9GbsxjjJCp6RgQjqXiWEKJNWpSqhGT85jzyCTUdaOAeUqwmHE/lFBw09B0DSEkgoh33WjQH0vBdY0WckmKsBJxLBUL4lDGlqbXiiWo4NrajqYZrZMuQoNyef6nv777/MnWdNW4eOGM42gEkSiIjxsH05VaiPhJvX7m9FnLsbvtpmkbBGluf8TjCElh6LoX+o1Gp5izqWP7bjz2ovWt7WwqSQ08Pz91+tTCzs7R2rP1malKJp0Jw2CvEQyHnoDWi2evkAzPnpsfjPxWu1Et6JWSk6vNHxy17j1Y1ylYXipQIPvdRqFQxrbV63frR10Ng8X5fDJd2T886Q1d07QQJZpUpmW/2D5s1eOlGWPl1ILru89eHHAFU46pAWknEsWJ3ObuoQIUiRgj+M5bZza3693BuFjMGTjyx5GG5FRtajSO3RBt7rURAEnbaDXammFDZFrmWMQcsmjge1ygai4j/BE2LQhgsxsWK2ZzEGwfievnkxKg4+ZICl7NmgDCens8FCa1STh2pyspjMRJY1Cu1W4/Xnc99FvvnN3c3BiFcXW6POz2qpmMlcjsH+xP1WpSiKfPXtYmqwsLUzYBjY736rhrJ5PHh0cX5ssXLt1o9YNbd5/kDHx2ttwew3/3f/xtr3X0jQ/eP95v7p8cQ2UvnJrr94bprBU1WOCNJkqFM+dOf/jxreZJ5/KlVc7dSrmQyE4eHDe5iqulWugN7t5+NkTF/Wb33GLq2+9c57H3lz9/ujSbX16Zuf/Rh69dmHID49TS6Vuf/EO3P5ikJSAiy7H//kd/XUhrv/V7/+g//ee/vP3g4I0rS1Nle6qce/ONm3/259/3In5msvQnP/jhQQ+sXsq9eHynnLPazYE/9meqyVotO7+0urZ50GwMMYJOgly5cmN9fdOxErYB2pq5t7dzemb61MLUxu5hslh6udv+P//D97/3zQtf++YH/+P/+B86LihAjuSwOjW7udMrlfKf3Hn+cuuglK1AauzsHiFKz52byFVrP//w06nZtGRtHfKF0wutfsfjwnTsXBiP+vVioXTv0ZPJqeq5M2fv37lz3IuPh1HLFVywREJ3dIqBhJjqdsIf9yfTdnWy6g57rzZ2Xr90hSi/NXQ1nWbz2Yvnzg992Wg2IYSN48bubj2RdhIWTiXIRDWdSWWTSaczGIexf+nSZcsygyiqHzUHQ7r2fMOPeULGccw/+ujzgDEviEYjb2m2enZ1MZEyPX/caXakFElH39rerpYrw/4AYlkqZIfDXiaVMCi5/+A+oTRhJlvd/tKpRapAu9vikkd+lEsl/RAjkvNHA84lIqCzP2r0BraRWpqtRHE4Gg4rhZQ/Ou7UW+l0KpVJS8knapO6Rfq9gZRkYXG+l01lU5buJNbXd6CW4EGfQFXIp5+u7+8dHvG45AdRf9DClNjIOjw6QaQ2N7f0l3/zNzuHzZvvfnV+YWnQ7jc7/kGjRYkRRXHoe6VCPoojRLfeeOv6H37v21ubT+NY3nuw/tNPH2YKVX/YC5V2//H68rnT771Xevn8drFQweNRyoKXv/Hmq80Xh3vDg6Cl6fY4aMwgMlEteL4Pid5q9SZqWqGSPd5rf/3d6/vbu/sHB+mMc9BoaYYzHu5DqtdqlYOTpjv2eAwsJ+mGPkf2yPX80G8c95492y1nC//NP/se8/993/UL+czRwZ5laeN4PDW90m6MRt1OEnIeYilFMZfXdMyYaDY7tVpRw3Bjd2f0XNYmqjNTlVxW7R82Pr375OtvXloqWz/+6N7d5xuzk3mL6tvb6195893FhdrW7vbKUr6U0A40deHN67363sb6s5Nh9I13ry29++5P/v5vI2Ucb+yePXfm3oMne/uNc5evSxYdHtWjMAzDIJfO/N//+/+6ffT8sy/Wtg+75y6sTpQTk9NTv/ro9l6dFSoOY2Yv8PPN4XQaaLryWyw/O1+bLW6uP71/+8Mbl95Yf7mB4nBysrK+vSuEtbS88tOfPdg9dJ1M8sq5VccBn9y9Nzvj1RbOfPr5fYo7Z84sT0+ke/3R5WvX9g+22ofWJ3fWPWhPzi6/fP7kfjwejWPHObp+/c1Wu6sh6bsjTUOEsLAXRobZGQT5rJPTUsGo5yTsh0/Xrt+4lvUbqckCEKg3bO0e7Wayme2to7m5kvSHHOhtPpSKP3+1/f5XblxenW78aqMTgGpRTM5M7G8cPduqr67M9JpHqWz6zPL0+kF9MOzFHNy9//wPvvdB6d5PfvSz+//kg+VffzHYr7cmqkV3dBAHA0mtw5MY6FRFY1Ozh8Ifj9x0OpXOBluHAxKpYhGUc/pytdhqDGKgHY+8tdvr77x25s3z1tqOP4zdUiE5Xzbzq2R3uyX8+mQ1MfbDVrOhJJqqTTUa+8dHnUIhzWLZ7Qya3ZFh2QuzU4HXy6ZsXxlrzx9fuXppFIQf322eD/ilUyXNFbduPT19/uzFs6cbe1u9AS8V5i5eRLc+Xvv5h2vf+Nob509Vn748CFzDG5/YhjEzXRn06kzykPO40zl7eml7f7ff6SSzBbc/SNQStmNu7h2FDOXL0wc7O47NlipJyMXxGKw3RsmEY2hkOAwlNQhUnJP9nu8yaOh62qA2FaOIxjz0I6gBms44vDdyR7EEruOkEiaN40gKxojGQiYksInBIeASBZKDMTMMjIG0TdvRZRQFjMeWYUMdIKgrKLgQBAApEfMi09AFxoHHEmmbMUGTeDj0YkrDmHsxQ0hIGZNImjrCMYopihhTBCmoAMKQSwRJJORgGAoODU1LJO3ByBuHQorQMCVBimhUQQgBRFhKKS1IoG13GFcsDmIexkAjRAjgBpJQoQFioIgLqLAZBmzU9yQQmOiOZmFNERZzrEGmRn5k6gQIZVhaEIUaggpiwQU1KQCKc4YxAQgKJZRAMRDYUIohAxGFgIVkGEUaBpAiKIDkKggCBJWmaXEQGxqVSjChNJ1ICYSgEGOgoFQcKowxhAhAhbjiCCCAIIZIQSVjDhAgiAgoKdE4ZwBAyQFUUAIFpAKExIzphAihpOC6acRRTDCRkhMEIcKMcwKBEDwKIkQo/L/+u5u+71OiCSkgkFJJCDQIMZdMcI4hZjzGWBOSAQARBBgRiZRkCv2XgihACAkuMcYYopD7UkJNowghJWKdGiGPDawpwbniyUSSScGDyDR1DqUOURzHGBOlBOMcAAgVhAQSQoQQGtUAUEDhOI41XeOSSx4b1IxFrASUShiGqQBgihMFw4i7Yx9QLDkkGFGChEK+HwAgNYMACSBSBBOsGNLsKI6VEFyIMJaYENs0JeeWSTmTUcTdIAo5pxhHUSwBoFTru+MojhOObRBg2RaUUorYMWkQxIQSjRClJAHAZ4wAJBDs9/sAaFRDGgFYKahwzCKIMIJIp4QJriAeez4AiAJEdUWIxuIo5MymBELkhzzkDAmEDWpryNSg45gaIUCK4XDYHodAgGwmm0hYGItg7EKIFIQUId0wk4nUcOx2BwMFJFRIA3jh1NLDZ+vPN5rnLsy89drptAXcsWslMt1m27C0mZmpJw8fG6a2emZ1Z3MHEwwUY0LqmEahp1MCId14uYk1WshnBJdRyA5PGpZuzi/PKu4Xy+XRoL+5uV/M5+eX5uvHx0d7B6NxOPaZYdnFdCKXtsd+rzcINYwTTlqx0YO1eqMFc1kyNTfpkJCFYSqRDlnU6PQABnPz87VCxhsHt+8+bw3CpaVZz48Pdo6X5wojPwxjmC3nbMLHg9Zxj+XyJS7jnIGnSilEzFuPXrT6wknoji7Onll68XyzO5JzC5PjXrOUJJlsLpZyb785GEaFcuF4v12ppq0EOT4ZjgNg5wqdxknS0mbnp9utLkYqY6pCIQeQsbHxKozA++/f3NjZv//s5B9/MPH8gJ30+bjTXZnL+bH46GGLWnYhhbMmnqxk+72WF2OuVLPnr64sZfXR2kZzCIxSTru0NKGYeraxSxy7Uqq+WHtZbwVf/cpb+bR2sPWiN/SRlq97yuLtm9fPIz35J3/9c4rI/+O//ReNk63/5f/8WXeg3n//Uui197frb7x5bXK6uvPsXs+Vhen5W5/dv3B+jshYCP5q8+iP/vB3LBS1Wp16L9ipd14+2/7G116fLpd/9JOfRpFwqgs7u/Ub56cuLpQ+uXX/8+fDP/6jb71xvvbyxd29k8FgxM+fufjn3/+bd25eNHTdjdxwHI5H4Vtf//YP/+aHt542futbF2Yr+e7xy9/9w9+89cWTP/3BZ++/fQ4D9mijnp9ccVu7aRvPTM/fffQ4ky1SSmcn8s+eP220vZn5pV6vdXF1odtr1Q8bM0vLWzsnjX732++9Pj87ufHs4fNXu9/4je/88pcfrsxO/MEffvdP//QHP/r5y7ffu/j6lYv3733Rd10WGC4L17aOq7VpCAGFsWliAvD87NyvHz2STBaS1tJCqZAtvdze3TqoryytHPXF82dPJkrF6vT044cPvvn+61kLdE7qDRc83Wp0PZ5KWAaMF2aqB8eNMUOLsxXunpyfm9Swtr69USwkXzu3LMNg6IdGIt1tN0Q45lx1+qPRwJ+ZrlIrkbH12YWJtKMrGA+6XaTA4eFhMlveP9ivVcqJhL32YlM3HMNMFcvFTAI9f/6i3RndeP3t7rAzGvSq1UIUuJzLMPQTpjMxNTEYjNv9TjaVGvTdUq3c6/S7rVatWgk4AxwpEceIh+4YIU3T9PpJHSKt7/YJ0gxNS6QyQHGEcH8UDVxPSAUF0yhUADmWruvUoObWYSuVLtkJ2m82IBhXyjWIqTvss0jMnqoOOh3Tzg1HgTcOIEKFQiJjJ5VOHz1eM7Bz+cKcF7iBF9VbXcM0WTg4ffqcF0S//ujROArnZ4o8Ggula6nScOzFYzeVzcYszCSSrUbTc4caFdeuLExOLWwdjb54+GowDiELL5yaLOZSmy+3K7U0MpNTtWkl2d1bv56cnTi1enY4GAcRGri9Xs9jYZxOWqmUI0Hc7Y8z6QKQ0Yunz6rFfCJb8VmYKRS7HbfR7CRTSSF4p9PiUmsOQ4/rY6X6PTcB/a9+84ODw+Pdzc2ZglVOo+9+8MbJYePHn70qT044tjnsdk4aHUmMam0CSZC2Y2JlN15uMxFP1cojdxBylUun52cr6+tbQYB1G7UPd7/+/g0B7b/80Ycrmej93/yDJ5uN/cNDHciJSmrUrp8/d8Hn8MMvbl1emalUSp98cd90clcvnS0lzV/eedZs7l6/dDFfLihBN14+TtqJQrX8//3f/256KruyeKrT2LMMWJnIvXixpQT52tdvhqH98e3nnX5jvpo4e+5cJPT7z/YePHlmWPSoE2YM8M71heXZuf/4g58yoP+T37wGYtZuH1M9oVvZTz+5NTE1kS8UP/307rkL5yMhHz56njKJZcD3vvpOs9V99eTRW9/8QKf4b//iH956Y3VmaurHv/zwgw8+yCfQxkH3Bz/+3LCyZxcnR8ODXDrX6fofPdq5tJydnJw53N3MZRPffP+9p89erO+3do9aS/MTFpGlnGEYua2tHXfsTk2WkygCdn7kxb3mwUQ1m0xm1l7tpRLGVCFtOLmPbz+WQGQNo5JQb7/39oc//ekvdwEMxdJSxR8220f8/JUSEj5zg+pECWpoe3cwGIadLvutD05JpP/DL56+tkgtK7Nx0FxcKnmj0I/k5OTc3dtPFpaLw4EbhpAhNRiECmrZbPqw0XEwlTIsJvFkyYacMcOQZvbWZzuTjrp8Y+njB4d9T+QsUbHFzHQxmcu2jg9SmWq73W53Rv2xyuT0hJnmYT/hOIp7mGq5TL7TrkNTzE1O5hKG1J3d5w/z2WRldvVnHz3bPxmeW86szk9FLHr0dGN2vrx4anF/e8uyaa005QXep7c3EZJLS9ONTj/2BEMo9gelnFOrVSHRsW49evS4XMycu3Dp7u37kNB8IVmrTUmI+v3hnfuPYkk67WEmkaxOlI+Pe/dfNAa+mC5aM9V0MB4OvVgiIBRBCAMRJy0diBhACjEVSIVj32cokiqVTPnheDj0LUfP6AhhKjmXCigEdIJNnRCCQ5/FgGsKajrWMDAtwzR0IOXI85OmxaV0wyiSinMIlOIKACEEBBpGumboFFg6IpgAgFujwPVCRLSYxZAgBKFSCCHU86IoFkTTNQAQVEyBkEnGpUIwrWkaIYhIoVAYRxCghKV7oa/rOgBIMpZMOWEQGoaFMXdj2em53ZHgQDm2qSshBUs4TtKkPPQDJtLZXNo0KQx1y1BMxjzKZ5KGQcMgUBICgHSL6BQxKQmAGqUY4ID7lOiU4i/PxIUQnEnLMtCXXRkpKEGCKYUAhohDJeIQKIIgpBoJw1gqqQBQXBIdS644E0oKJgXCGCDIuNAQEkJ+6ZxJKRVUGGGhFBfAMgiPhMQKAoAQlFxCgqWQSkmMiGASahABBCFAkDARSil0zUQAKs4hRUACBSUCQColhIQIIymkTkwEFSWaTgwosZKCA66AgghwyTEiUnEIEdUoUCpWDAGsoOKKQ0gIJkIIhXHMY0UBRjRhmhQAjCHEmDOGMUAaAgQQhKTkLAgwBjGLsYBCSoyIlBJCouumZTm6pkkBeSSAQpxxzw8YiwBEke9LISAkXEJMiKbrFNEgjKIgVkwwJoMwhhjpRNcJAUhwKQgUOkWYQAQxxVBIRSCxzJSQnEVhxCSlpm2alKI4ZIwLHvEwDII4iiUHSsYcBIwDQIZBxGJh6jiT0HMpW0cQstjQCAAw4zgEfGmjKQ6lRiiEMIgZVFin2NKohijRdd0glq4ZCOkEcsGCGAxcf+AGQkmghBvGfc8LGTeIwQAceiHjgjOlGTSfNbNJ3bJ0KAFnjIs46SRq5czibK2UsoBkfhhATJECjmFkMinJ1MuNnf39puDIc0OCwNRcbe3Y3d45Wp23371x2rbhMIx1PdFpdXTTLBYq29u7mOK52dlBv6+EQIB4obBNExFlO45u2c9frHu+f2phDhMsuGgPR6VK6ezFZY2ATDqJBDto9mZmpsrF4qsXG4+frrd6w5RN37px7p3Xz5i2vnt43OnHhWzeshM7u0fPX3YIge+8MX3j2nwtjXRTk1C5Ie+7Y42QmdqMDOJPPn3w8e0Xh52oVk5nDHR03CiXdcOhCPGpmSJV6uXLo+5ILs0uEslQOLZt3B66959uCIVT2aQNouXFpb3DQXesZucLKGoV02pmcXkQorWd/uZelC+mCKHJrJlNJ6Tne1wQIwGBDgTIZ23AYh7HWENR4CZSyeE4UAotzGXySfxybY9qSYJtdzjq94exhIFM7B11FQDVvI2lT3U0GPshJ4ahJyxy/cKCgeK7j9qY6jkNrc5Pm7rmh6HrxTpN9Yfh+rZ/8fS8htn9h0/2W0G2WJ2pmLYaLCzOHDSGv/zwFg/Ytz64+XJ9+69//AVS6p/+V+/n0ka/3fuNb78zVXUe3/mkVClUl8599OFn2YwJ1Jfb+vDC6ow/7G9u7b3Y2v7wzvPd/dbFi6vZVOIvf/LjAUv89u9+e7qQpMDtjnrMyPcH/vs+tJYrAAEAAElEQVRXkt98b/He/ceNvlh/uVcqFdZf3b92eclJWI5hvH7lMo9jYCU/vfWo0RvPlnVTg8Pe3rnL5zY2tvf2Ts6ulDAmhpOZmqjWj094OLh46cru4Ul/EJaqlSAYffb5nXIh+/47l4JhM5Uw+kMPC/Av/9W/qNc7zZ73B7/9bj5j377z8O7TwzfeefvF47unFyvf/s7X/qd//7d//bOXb12f/eDdizv7h//w2cYgMgEaN3u92elS0tYVFEPPdV0PIuPW3cfeMJyfnUmnLSzx3u7Ri5eHc9MLOgbPnj9bmJkoVMvHJ80wUuORt7G+L5DOIR64XjJhpkyQdbClIxbFEVPrmwd+CF692lIgVILl0ul2p3338avD5vjDT27fursx8kXExWAwnl8szs9ODged4+ODzVebjx4++cWvPu92xxLB0xfOGbZ56tRKMpXcP9q3LMOy9DgcAhDfvX0LIW12dvHhw/s8HJw9M0sBR4Amk4lctpLM1G7derC5uW05ybEbaVqqedLw3HGpXErlshBCABkEMB74jmlZunl42LATmZn5qbnZmenpKdNyeoMuVMg2TdvSOOAUw2qlUClk7USCWgkvVB03DpgKJXdHMYKW4PpJaxQyqJt6rpj0e/7c9EowjmwTvv76a9l01g9YyOL1Z09bzd5Bt9dsN0UcQCh8f7yxcXjSco+OGpSSi+eXlxcmpicKuZTl+e7cRPH918/XysQySSGXNyh8/2uv/9bvvJdP2w8f7X5xb308jM4uzbx5fu7qmUkC5c7OEdHpoBc8fr7XHw0UErny5M6h/3d//2mz3vC6OwYmEoL7a8d3n++7kcRYj8Lwxdo6l+zb3/m6lUs3TnZ3Dw9//JNPe6OR7/UZwKZBq4VkOWdlUnrEfEunSR2MkXHv2Yux55qmNrdyWjP1tZd7WLdTueTaq729k57tZC6cWxn3hvXjA6TpB4f1VmdoJjNjP9raOUimi1HA273Rw0evBLD749HFM1cvnF+99fmd/b2d165d1xKpesc1NCxCd2qyuH9Yb47EYddNZzTHhOsHjaYbz8zNtVvH9+7e73rxV95cWV6Ye/Vy55e/+qI3bFy/8Ua9US9Xa//9v/hOxlQ8HuWT9sLMdMqxL50/xxD/kz/7xV/87Y+IRpLpTGsIfvqL20dHJ5fPz751/awB+FuXZmZqmV98uPHFk/Wrly44Orl7+87GYWerFR+2PSaiUqFw1Gg4ufL5c6cePnuiAP/g/ctnVyYgFH/9N78YeXF2/vxf/cXfZ1L6t7752r2Ha4JqtWrleH/v8ePnvu+dOb0iWNQZuEEQYaV+65tfef/mUrPRk8G4WKo1WsPPPr1XLWVeOzOzPFfrdruGbYUerB81TCtx8dzZlAObgwhhbWqiQhSPRoNyLmdruNsdtxpN09SSqUSvx3OFQns02l5/+tY7Vyr5JMWyeXwMAbEzqNMaUJxBQHXaAyTVXNWeKNqmDj6/u64EfHdV220xO5OYrSaPt5pBqBgTGmGlPAiGo4nJGSFijAzTcWIRcxanLWrqMdLoVkNudcIAJvgoyOPxldNW3Ydb9e7iUjmtxQQKl+u/ut/5+MG+nSuKeDhTy85M5x1bO2hF9V43AhhjXKoWRez5QWthqZrS6M7eUS+gFg1qc0sHbb/f7Xzl5nI+jz550F878SYquZXZzOMnJ1v7w+rEdLdZ3915JYD99rvny4XM/l6DKFirZGt5u5DKNxuD45bb6fbG7qhWnTo4HvZ63sXLl7Bwj5u94cgzNVLIOd/51leuXFpaXqztHnWerB3ZqcqZlUopCQ7b/uZRXzcTxYTGuVJMICg0ghBSiGjDIAqjKGtblkYwUa4f1btd3XDSmYRiMlLCD0OlAAAQYRwz7g4D34upoSsJYwDHQewHwA3j8XDMY8kYDzkXnBmEYCVNgmxDJ0poBpUCxAL6YTDyosFYxCGnAJSSVs7GJpIUIyylBpCJISFEAwBjBbmUkgGolOAagVAJBVAQikAoLoRGUTaTSNhE02HCtgXjijFCTRZJBVTgjQVHWUuv5tOVQkKjEAAANE2nFufcHUVCUkK18XjU891hwMZeFAipIO4Ng97QJRrVNayIghJFTKpYCQUkUxIoqhkYwTCIFABhFFJNQ5iwmHMOEFEEYQkQxF96vgBKgJAGCEAEcSYBRghCgjEmWCiMMIEEKig1nVKCsQIU41gohJBSMYQKYEQ1jQkGpBSSK6UkkhqmlOoQQggxhggjRDWKMKKajr9s4wCpRCSYpJoJIAijiAmpAJRAxjGPOUeYIoTxl5YZRIoxEbJYAo4IRghggBBCGBKMEKIYU4IwAhJoukYQDXmEMNKpKZVkghmWrnhMEIqDEALJpZQASMaQBBBRABSCACik6QYXCiEIMYIAScUAhEIJiJBSisVMCKYQIgQS+mXGFGlUU1AZBqGUSgUQAVLGPBZB4EdKIgwBBFxIBaDCEGsaj+M4DrAgWCEhAULQ0LFj6pRS29ABBn4cxbFCEBsQEQwxBYpLxiPbtARCChEFMYBUAG3oexJCjzMu42TaqBUztVIBIRBEATY006CZpAMQBBi6rhcGcRyLgEXjKI59RqhGMZacKQzjKPTDUGIS8jiWuOdGveF45LqGZWqYIIyFhFhBDCFUqNtzlZLpRDJjm5YOsPQoUQnL0E0C0JdJKQAkDwKv2WoOh30RK4JIwjY1Xe93R8fNJo/51NSEaRu2k8wWSrs7R48fP83knffevE6B7w9HBqYD12UB13TUatX9IJioTflR5A8HAILOoG1QDUE4Go84gzsb9XqnP7swm0ykPD86PG6kbHN5cToIfdOh2VLp4LgBRMSBeLW+NhgOLcuenpw5f/EcwerB3Qdrj9aGo4ASvVVvbr3ajrioTtfKE1XJg2G77o+HJyet4SCAMqIIep43HAy6g/444LqZdBJarZiN+61iykomC2GgTNMYtZtrr/Zy5Uy5MhkGA7/fmSymHCO3d9IMATWdjDsIVhfTlg1bjbatER54mYQxWZze2m7dubuz3fBml6ecZK7XPMllHQFRZxB6gSQaCYMhF4Ag6A57QDGKlZ1IYEiPDo/jiC/Pzm5svPB8UK6WD0+8YT8UnmIcD0LZH4iSQ2ppWkslytl0rz+SgrPYy2etbNK6+2DTSdPlleml6VSCop4bn7TbtomquWyz0T53unzx4pnttWejwXDp1OmZxcXBmGdobCXKkXCO6r3vfuvaXCG7tr4TEvvrH7yp48jr9mZmJnP51Kcff1YtVQ0j/3c//xwCeO3iOcCDZK60cGrVcqyj493K7OwoUIEfnF1dnp6q/vDvfx756L13XmP98PPPb0nBFk+tPH76oj8Ofuu73/z53/3kV7+65Q54pVbLOImYibnZqZ3NjSAebK5v7+weP3v8ctA9mpnOvvXOOduxowhEkQp9lcvnz52/ACE6ONg72t1PkbA6Mb2xvtZp1t9774ZB8eFu4w9/71s3b7xm69b0zGQ2aUX+oFSt/cc//dP17aN/9PvfnZue+IeffDwOosuXzx0fH0xM5N57+827dx5/8sXT12+e/97v//aD27f+6ge/WD1zZn5uQdf0C5evlYoTrjfiYVgq5s6cPrezsyUNZ+nUsjseKqiHCk/OL1GKq9XsxtqL5flKsZxvHh4f7B8UchYESiEhFYw4RwQToCxDq5ZLlGoxgJjQhKPrlNy4+TqCOGfbN29cM41kulDsdvsJ27p548JEpfjue+/96//mX73zznu9QX9+cmJ6etKxjXS2UJmYs5OZdDITh1HjpF4/Ogj9aHZidnJqRgGcyWeP9jYLxcrS0jJjMQTh3HQFsViI2I/455/eaTZ7T548HvSDhflJKOLNjZf15iETkVSy120+ffLA90ee6/eH/UqtYiQzJ60WIsS2zWaju7t3EoVBrZKdKBXtlNPtjwHU0o5VqeUl5AFj5UptY2On1Rv3+qO+O4aSHZ7sD32PJguJbHE46HMOZ+eWOoPxh5/dbrUaCZ28fHZr73A98FxD051kslzJXLp01Uqmd3cPisn0B1//2vkLp04tn+KKPXv8ZGN709JwpVaZXVzJ5TOddoOFPRSMolHf7ey0jnYe3Prs5cu1U+cuXbpy3TCctY0Xd794qKKhjoFu2hEPi6ViqTJhWfbDe/faB4fJhFEupH3Xi7wwn01h7poovnB2Npb8s1sPmo326dOnC1n7YGfjqN6KYnTqwsWvvvPmtUuLLI4Vtbd3tg8Pjw3dzGZSQgiqAyjiTCqlOamTg3ommex3h/fvPzITheNm6+ikpWvo/JWzG+t7OwdHiWz23OnZg5Nee+g2BmPfC4bDLqW6QHLsjZ1MslVvnvSGrW6/1x1//6+/T4h96epVhNSdW59yzX6xue0PvOZR62B/f3H1cmcMnr3ac91grlbtNXrtZm9yYWF6erI76P/13/7ixYNbM1OzyWxVN8jB9ouTgw1Kyd9+/weJQmZ2Zr7bGUTEltQZDAIm/H/6j//5f/d/+7cry9MbG2vr60fNYXzYjT764tHf/PWPKjm7kiTj3d1vvv3Ot7759otnO2PXnamlNLvW6EcnTW//oF5vdJGjbZ+EH37ySYTJxMzky8cvT/Za1Ey89f43fuN3vt442hgNPQjBL//hp0AxalmtfpjJ5I6O9pZOn5HByJadswvl0O1AYj/Z2PmHn/3o+uWz8zNZ3cAEqtnpGjHh1tbWaNQ7tVDKJak78KaXF7vDzsjtt1r1WnESmNlffXzX0EAmmxqHEmv6zPSUpqGDk0EUexPlUhCIw04/lS1t7BxLIE/PVbM2yDoOMs1UPjf2uDfuEcM5bnn9/lAyUMolZmayTID1Z88TBjZS9sbW3uricraQFkB4fugPuplkYuiG/niYdHQWuZqh/Eg1OgNKEbGtxYXpYtk8abH1476mE12JctYplsyt/Z7f7a1OO4ZFI2onM4luK/j1J3v9fjgadpMGWFzMz0ymkslkoxE8fdEOQ1ApV5FCvjssVssa0l48Wdt49swyzWpttn64Q9Xg21+7Vp7QP/506+mTrdPnzpw/M/Xq8bNue3T1tTcxxYf7Wyf728VyLpNM+OPRycmBEiyRcrK5chiFoe83T04CbxgJ9um9+5Cgubk5JsjTtSeNxvHR4UmzcVjIZt9668Zbb19pN/u7e2sONfOlBNbA0UActiMZE6ibMVQYYMeyOKDYMMulnB8E7miUyKRyqUQhlwoj0W51BBcQgpgRCQihOoEEQgwxBgZVAIahb5k0YWmY6BwrFsNAcC9WQkIWcwEANaiuU4IUgtyxDSyBaRAIAFJESjAOwkiqgDOKWKWQyyXtlE0Ng3IpvCAUXCQd3TF0QqCUEBPdtBxDM5K2iRUDuoJY+EwOPW809oMojiNFqI4w9UIpJZAQGoauaXoUxGNvTJUoZBIpjfLI4yySFAAMoE6VhjAmhmYILkd+OPA8ijGl2LB0oQDnQjM0i1AmRBQzRCkQMGIRjzmPQq6EYZgIQAwIQpgSjBAGErBQCC6UFApAAUEkOYaIIASFRBCHLIRSCqVizmLGVRyxOIIAapqGFcGYcCkpxl+OSHQDYQ1jiCCiVDMMwzE1JaAQAiLAWQAgJgQxISglMhSSC4CFAkgqqCTkQhGiAOeKc6pRiAGGUHGpEaoRHQIIAEAAIwQBAIBQTCAWEiMMgUBScaoQAEJKpSQgUMcAKAQjJgWEGtJYyDgLgRKUoDhkukYVVJRQSrWIh0wKjBGASoCYSByHEYCKcSbkf1mtEwKBIjHjSgCl1JchIwBhHDMmYiFEHMcKIKAAJSTinANg6BqCREIglESUcha5vi+UFFxGUQiEQkwQipPplK4DBQHEECBhaIbgXHKFEMIQKcEBEIRiyzGUUiwQnHHbNgGQSkoWRQhALuLADyImg4CzILYxTGhaJmHF417KxlP5RN6hBOI4ZsPxuDsMuNIIsf0I+K7sDSMJOcaQaBhDKmPJGBACjf0o4rQ9cl2mFEJOIpG2DEyJxzmRnCCiaZYXjwiWmWRSo8rUpa3DSr6QTDhciiCM+iPXj8AoDDxfBiFDOk0k7IRtUAwkAu3eqDlykaFPzNSAYtzzEimn3hk+222Ws+i3PnhLM/RWqy8UaTS67WYjW0yymIVRMDU1QRAIPD+MVRCGmUSqVivyMMQcHe0fHB3tL80ulMrlW/fubmxvT81NnL98RkluW0kNWw/vPWk3OjOVoolAtjSRyqaySXOimj06bv7sl/c6g2BqaXZ6oiY5CwQqVqtTU1U/HFpEmk4iEMZxx+12QkpRMmnHIdNsOwRqOBw7jim83kTBsZLpvtR9QDqDcb/vSw4bXXdyqlQpT+7u7XjeaGY2s7A00+x1RkPoWHaj3kqlcb6Q7bS7ukHnJpPzlWwiUVo7dO8+O8AUrFYdybwXazuhQMlkqjMa1AOqGKBQaYRksnYgGcZsomDrGECob27vEKomp4u9kO0cdtMZZNFeNuNggjDhpglEHEIMlucrQeialiYUH7teIpNUUtjUfry2FSlcnchaWpC0IKDGYdN9ud2rFAq1ilXJk1qtsLGx7aTM61dPz05kHtx//pPPXiZyqUI+8WrzxcJS7eKFK7/46CODxhfmisPmgYKqO3ZHHrz7YE1hPVOu/se/+vm4Mfjeb33V0ollab7bxZC3mq25+dW9nU673b15aeGNy4tPn78IiPmP/ui7BbG/1uhhCH/3G29mSLy9ufYv/vUfHbf4j3++dv7iim3DVMK5/eCZAuZf/fCXuax17dzq333+1BfwzRunTs1VSwl9Kl/QOBv0uuGYP3nywvOj+lHv+cbecBzcvHH56plyf9Ab98fvvPOWjMXHv/rkX/7zb7//9a+02u2uB4ajcf2gfmZ1JVCwE4D33riYIcPv//BO32OnFmZ1ws6fWqhV5//upx///Ucfn51LvHFh9tef3/9i7eSdd68ulYzN5w/acaa5t1VvNFtD1h3HuWxx62B7wKCVynXavUZruLlx0Bv6GzvbuULatBPzK8vTE3PNg8NzZxZeu7hULSZnpqqFXNa0LE2jacdEMjA1feDGm/sN3wsMKAwVz08XiHRHbr86v/zpF19UK4nlmQImqFDIMzZAIL5769Mf/tVf3b/3JIiicThOp5PpTLJUTp9erNgmfbG29vT5S6rrRDN1Q9cI5TyMmWfruDZRG8fGF1/c80L3/KVLocDrm4fDfszCcG5u2R0NWo3G8tkppNFatXj56vmpiers4my55Jw+fWZ58fThbuPo5NBJmt2+d//B1s7hwHJsQyPtViMMwlgA3/dtCwx6o6frB0/XtiJPjlypoM0UJMCfnMhWa9VYgplylnn9makMNdHuSU+39FMr80LET19uMGQc1718sZAqFrvu+PzZVcuyW/1+Npc1NALHvebxnp1IKdMKw2HKlL7b4pGcWzgzOzsxdEeP7j86ODxUgvW6JwjBmaXZhYXy9WsXXnvtfKGc399vPXn+KgyCpCXfunLuzOmyQEa3H8WxLGbSUrJkSr15Za5SynUGfRaE5Zx99dKyhGroqWSusDBTPj+feO/KYt6x9o9au9sbpYw+WSkhYu8ft588flhO629cmrdQ3GqOE3ZBz5R6niJW8uzyUt7WQt+LBccQx3Hsjt2JpfmDnt92o6tXLmUS2qu13fmZ2ttvXdnabW6sbaTKNYmUhPpcMeuOR4jSXDZ7am5he3MHRaJULmkY+YyZqSQH2trm5trG5s03v3J29VSjG54cHId8NDGbO2oNn669yuSLh43+x7/+/J3337n+xqWTo0Ov1ew1DyHVpVnyzJn3v/a1m9cmsOhZ6TwwUuVyyTT0zZebpkWHY//2/ZdbB3VCzUEXfPbhr0+2bmVTyYtnVnJZR3IWMW4lCwcdfnhw/G//7b+0s/bPfvVT09KSJpLhWCOkNfK8sWdqaGpuBiI68oK5iVzj2H34dOPopJudnDwajp49W3/+6DYU8W//7vemMmKqQHNp8/BwsLE9arfdmbnZ0OdPX25ee+36pdNzpVQk4vCo4RKn9mDb31xfq+bKg35/cXHS7bcBMgROHndGBdus5p0Hawedobp89TXC3Y9v7z/oWJXqLBegtb1ZymXrrWD3oG0YZH4qlSslHj5as3RYLZGj4/6RZyizsL5x+Pa5hGPQkRLuMKQyMgy6fzjOFAuz07m9fZdJVO8NswlSy2mxUrvj9NmpTKurfv18XTOtYgIRLpttF9rpVFILxgNDQ+k01QFAHEQKHA3DeOyFo8ZiNZVLk6HHjgeq0R2HI29hqpqgqN3xI4Er+SwY9aopcPPKRLVEu+0xYzAMlQjHGTReKNCrF6eIAbb2W5ECVDN3G6OeR86ePbU6Y7gRcUfdbFqZmfSL9ZME5f/qd69NlcFff9L87EnjtddOX1iyXzx78WSjPjFzfnU+74XxqFfPZkgyk6Zmot13B/1WMkHTibTtJKenqqmEsTRTizzv0cOnheLERDXPY7R32NUMU6fW3TsP1jd2Ti9P/eM//kopDTvNnSgQxLSAjvc77mbfExFjsWoNg0Aiong46JlQVAv5gEcjd0QRq6TIRN6SUHXGrO+BMIqlBG4QBYALwQAEGChKiIYxhUqHxCCKx1wCKQSWghMEAMIQEcl5wjARUlIoxjnSkeBCQ8rQsalhBPjYG/ue9LkMg4AYGEmBlKIaIRgLEUuuHF2zdCKR6oyGA8+LRKxDmLR1U8NeGI1DMfRkZxgNfdge+Z3+gCkSStV2xwMvFBJalq5RFTOgMDYkn66k86lkHIuRF7sek4xplDIlBFDU1OM4ZDHjCirJJRcYwigSgS8gULap6QhGURiGMSJUIsW4jBmHCFBMFYRxxICSEAMuBcZfvskASkkoBYFIcBmxECLEeEQxVQqSL3s/GGANU1NjLAYKKwwC30cIxUIiIIASURhHnHPOYh4LxqMoVgpKLDSqKQAx1oXgUcwAk5wrZFCEkYgFIlBBiAAiOtV1QwGFEMJA6hRzFksIpJRRFEaRD4CMhUAAIqWUkgACCRQHCmCDYIgBVJAQADFEUKiI8RhCQBAiCMWMYY1gQoQEUAKkFFCAIB1hCADWiGbpmuAKE0ogxhomiCCAFFAaIghAJRhnHCJBEJZQCcZjIRBCnAmIASUmohQDAKSCBMVcSMaAkF4URHGAIDR0C0mAMU6YjoRY0zSEAJYSYoQwinw/EhIorgQ3NQsqwBgDUDEphAAsjk2iGVgDQBEEsI4xxkopxrkfRoqgUeC7Iz9mQqc0YeqplJ1KGgkLEyxtQ5cxD2PhBioI4/bA648iP+Ix5wPfG429kDPNpBpGFEENKgi4BIJQGseccRUx6YdfasdICs54zOIQMGlYmoZJGIWcqUwqaVtI01S5mF5cmEo45sh1ozAOw9DULUKoTpBhAMvUSoVCPpcHQI39qN4Jh8NAcOwYVhQGvVaLKTAauaNh/8Kppd/84LqmoU67lcmVRCz2DnaTiYRhWkEYzM/N6xp1x+547HrBOJtJF4qpVr3e7Y8AMsYBsxKJ8kR5a+tAt+xvfeMbM7M1z3UN0+oPeg8fPk4mExcvXcjlq0Jqe1tbvVbLMGm70zmpd2q16YmJGgKw3mw0W13N0Pxw2DjeS5mGhMazF9uvNptSmZMLE1Pzi5ZjaZquYWPQHURCCzwGEKqU84cHR89fdnrtvm3pxEAj389m7VyCdk52EgYqTRRMJ/P0+aujw2aikH++0bBT+M3XzpwMomazU8yiatGBUByd1HcOOkQ35uZKEYCH7b5um/lcggW+2wkUQMU0PLU67Q37ceAJP6gWCpaTjBgcB1w3rXRKT9pWpzUKObEMNFVK1l3ujQMIWDZT7Pa7mCDT1pQ3Nkyj16zXagUehZZljpjn+uHc3HQ2hRFnpmYMBuP+wHNMtLI8LyJ/PHJ91zN1LZ8vRWHw4OHz3cPjuaXpmbnTW1uvLCp/81tfe/zw/qA3fv3160DyqYXlUnHy6LidTpuObVcnZ/yQzVRK33j3VNI2GB/HUVwrV6QQAEpE0e1Hj1yXn19dbJ3s7+83L51dtSwtitnRztP3v/bu5NzSJx9+Oj9lz0yUb92+N7s0QwgdDTrt5vHVCyv5XPLCueX5xaUHj56V8tl33rqaz+UMApqd9qudvVKtlC0UmwM3QOZJJ7z37Mlo2P/Ke++eu3AuiFAqqZ2//FqzN/77n3zxB7///oWzq3/zn//0Fx9/8eGnX6y9PKhWi+l08uXztcAdx5JvvHy2u7v9xmsXNYwCf3znwZ2nT15QK3v10vXvfOeDjd2jn/z8C8POpsvVT+8+ODjpHp7sjgOgCGHe6OzKnDsaHe+ezE8UQOy1h6NAotOXLjPBRsPh6vJyt9V8/OjZxtM7uSTN2LiStt3R6OD4qNHuciG6rZZt0HQmTXR9GIy9kBUyGSvp1JIiayKgsFL481t3IiEU0PZ29nnkFnNOPp04tbyUTCVzxQLVjWarbRqgP+rXj4/aJ/XNjc3D4xPdyJw/d216ciLmUf34+PCk3mh3E8kkpvqw2z88PjisdxzLirxhv9NCSEVxyBXzQh9T+sZb16cqtUG/HYfheDyOQl/HNqHm3t5Os35ULJUvXrmeylbX9/ZDz1tanDEIbTSOp6Ym33z7PYGMZ68OGs0B1AzLSSytng2hatYPep1mszVodYe2nfRHQ6Ti169fWFyaSBjW8vREIMnDp88sA3PBj487EZOnVitzs5P5ZGp2YkLFPOnQYirhuYNef9joNCzdsgzzwb07D+49spJ5O5EfjEbd5gEU4fnzK8VKFUCysLw8OzPd6bZNw8nmjPrxfhx5M5MTV65dtm160thtN5rNbjOfL0oZEQP64x7RaCrl9Hrd4aB+9fKlieoUJhqh1HV7EMjhcLj28sXYGzHPLeWMKxdPLS7MTlamPD9CgEf+KGMnWMDWNl6NPX9peX62aoGoEQ67+weHH3302e7hIeMyYDHERiGbsZPG/lEz8KPqRGV7e+/xix07U5yoZve2tm++dm5lPvP81c5BvUmw6brD8sRc2rYiPzw6ORh60fTsjOuPRt5oampeiVinuFgszCyf7w3Z//t/+N/KxcTZs2d0E6VSiffeev3iuSUNo42NvcWpytLpMx/+6tetZjebzXKJz5y/FrNY8v6HP/3kX//r/9ZOZv/ge39oEG3t6YNkOpXIpDZ29tv98Ve/8u7q6vzTF2sHjUGz3x264SiUuVQaRv7p5eribDaToZy5qWTis4e7j18e/sv/+l/aEG0/f2BT3Q/C2dmzCsE4iofDceBzpeHWcWtqujyzOGEYhmXndo6allNcXL2omc6f/9mHn/zqk29/95u/893fWJhfzk5MIQvdefik1xlOTlbWn+384K9+FAvy1jvffP3tm95o3GyeOJZ159Gr2tz84kQlCsKZ+fn6yX7a1i0NvXr19NLlGzMz2c8/+TkQ0QcffFCo6H/3w58fNBumQY9dtnD2Yi6f8Nx+o9kZdf3LFy8Sonb3tpPpbCzV/tGxbTmNk77b3P/WBzcoiJEKx97o/0/TfwVrm53pediKb/7eL8ed8/5zDh3QAWigMYiDATiJYTgcii4VXaRou+wqV/nMZVtFuySLoiRalEmKw0nABACDDHQ3Ov/df0475/Dl/OaVfND06TpcR3c9dd/X5TiOl4C9k87i0nI+g3qDwXDkjUae4+YybqrdbHAW3bw+NRoGdx6dJig3vzzJVBL0x4V8IZVKEQgsiBcmyoUCcU2NhaDloXYn7DZa1VI2lTEO+0GkUgBwytvzFd1Nqe44jiXKZvXxaNg4aq8szRRr6e4o4oBqxCzl0tGwZ0A2MZEfh/H61onPOGDw+fOtjbWNbLo0v7gaeEEYhPlswUzZh4enFIjf+frVhQr50S82fv7LD5bOXT1/aXX90fNf/vLt8TiZm1tBmGoEl4p2NqNnHMdKuWESBuPucNjTTadYLC4vz5xdntnY2mr2vGIhZeo0GPv9UcAlzqXT9z998JMf/RLK+Ctf+ep3fufrCzPZAokyGqMU9hLUj4knsM/UaXeIjJRp0nF/RDVSrRRNqvMwUUmSc/RqIV1wtExKAwgzIGLBWSzGXiKEimLhx6GUUAEkJMOYEAyTmIexGEZhwASXIImi4cAPwsC0TCflEAgQRKamUQhMDZu6VsplMq6LsEyiiDOpOHMzjkYAANI0DckTpQRU0qDA1QgCOBZCciWgsAgyCElpOsEQEcyEijmPuYiYGo5HWMOCKz9Kmp1Buz9KFI4jwRKpADcImiynCq6BhRRA+XEklLR1k/MEKZDK5RORjKPI0ohOIZJcSenFYX8chYxBQnVqKcAlS0zdsm0TQxInjDNGKRU8UVAhBTRKIKaEUiQBRkhIQADUdI1gjSBMECGahnQilYAYUU1TCoRhDBDhkkOgqEYwgoIzrOlcKNO0IJAIU6AQUAgTjWJECQEQQKmkEgQjy3Y0Q1dIJglXADDBlZRCCoQABCCOBYaIC8GF4lwoCJHCAEBCsBIqZgxCAf+Xf3qbICUkhhgAALlMpIQYkoRHBGuCcwEkRAgIAABUSiKEAABSAQwkUDDhESGaAlJBrFM9jiIumW5oiktKsZIKQggRhFIIiRRgUClTN6MooIYFlIRQAikhBzFQukYRBAmPMaIIIwAQkFIBJQEgCAGoEMRBkCCoBBAYUUQwkIhzrpSUEmIoCNFCliAAqGGwKAZAYKwhqATEUnAFAEiETjEyzTgKgcIhi5UEEgAKcMgSLsRgFGBNC8OEmBYUsUN115ROynIdSyZ84IdBLIZegCECCAYxYwwEYUgItHRd04FBDAyidCrFWCKFlAoPhiOpICL6yPOjJMEQWZZmahhBiQCUEmRdypkKY2abtmFTCnnWNCEGMeeB7ymFKCCx4hArijUIoGFoAADB0Wg07g7HkmgEYZZEpqmXcu5gPI58JpEkGjYJPnfpAhB+HKtMPj0ejlrtVi6bmZ2fYXFsO6lM2t3f24qCQNfNdNoxNDoa9sIoDiNyetQ8bNeX5iaKebfX660sLZmuXj8+QVD3vVEUhEvL87btnO4f7Z+0e/Wmm3PLhSJXvN/vAqEgopSQYa/Xao8LhRzVSbfRLORzpm4ctodJFBcrZcu0uv1Oxk0P+93BMLQMDerWcOwBBctFN2XRTx8d64Z588ZCr97udNrZUkkzFJUSxrJUnTjq+ns7ewjCWrn8eKeeMPy1Ny+L7ubPH4/ny/jyag0q8njthAFt4CcEQUGMk5NBueSmLbg4mR8P+k+3+m4hd/Vs5clGY/+kt7KYnsmbmpU9OB2cNAcYJ6tLFTYaFwq1nu/vHJwUs9bVc/NvfXwQ+ONsrhgItbXfmam5lTTSUYyJASh1c9X1p+uprG1oNGIg5PrynBb1+rnixJOd3VGEJivZpWrhydpWdxyWa5VyocgCTxrO6XGD6LRUKbtW6tN7n0xVyxfOrT5+tja/stJotHY3D373D//xv/rv/1XrdPQv/tnv7u/sD0ZDirRcwQoYJVABiHa3Nz//xutHB/thOM4Xpv7q+7+4fuvCl1773L//H/9dDLXsxMT0RPXRx+9PTWRf+NK3//RP/ibwTr74ypXV5XN//sO3Oz0fSHbpzOSVcyu6btx7+GR2/sz2xka9fjy/NN3sh3GUDIdh4/T0t3/3a4qLpw/WanNz95+utQeeg+Hv/p2vQJH82V/+dK/JXrsxsbhy7s/+8uev3Lycz5Iw6hxtn9QWVh4+3Ezn3G985c1PP/zw00c7qaL75udefe+tn2VK5le/9q1f/vy9xtFRsVK4+cLnfvnrdy5euN4ddD78+FE2X0jn8lv7hzu73W+8+UJaky0PHuxuO65u6LTfa59fXg2S+Oik1er7L7/+etrR/+w//fWX3rgo4uT0sGM4+h/+nTc/efhs97DV6AwBotlcttmsZwvVTnfAFBr6SehHqbQFlQz8cSpd+vJiPLl6+dnGwdrTtTdfv7W0OHPv3oN2e7S0OFWquJVcOuc6j9e3UtmC3wt6vUYYjwjAM9NTAEtvFCdKxkHsWLblku3NbVt3iUakkgsL01LKJIoH47hYLPLEr58eTE9OGLYVhMmwP/SD8MyZswlLjo72yqWSEmp/9xhiMhh7CMtSsWDrhkS6H6n9g6NWo3HuzHw+k2t3OinTavdbldnF+/fWFDJLOS6FXu8FGtaqtaJJYH/YXls/lgovLU1nXcsbtCemJ3XLWVt7JkPRh0W/s/vF16426q16o0uIun39IoDMG8VxyKmJDY1k0plEsMPjhmNnBI9lHIz9YDQOT5sjTOGZ1TnHoMPhMJ/PKQD9cUgNA2K6u71hOZnlpdLO1kGrMahOVubnFysTOR7Hz57uPNs4qExULV0XSYAgHocx44BAqOtsYnI6YrBxelIpF8u54u7BXsq2Ah6Fo5FGEYYa0K3+IJRALM7NOSnBEzoO1Nb2mkZQuVaqTkwU86Wnj5/U2x3dKt1/8jxIoJ2v7J92QoHSWbffGyYMFvNOztHTBlDh4PKZpfLszF/+xQ9eePFmOm09eLy1e1KPhWaY2aUZOxj6FsVHrSY10uVCvpRPv//hh+WJuZlaYdjvHZ2c5svFm9eufvzuB61O70uv3zxtt7zh+Euvv7S397xUnTuqDz78+NPrl1ZtDZ/2esfHHaDU0tJctVIa9FqpnPbW209OT/g/+YMvVHKpOBnfv/fs3IWLiuAffP+nZ5emb7/0wnf/+kflyuRMLZ14ra4nLDM1v7D8+MlTCfhEdfLRgydHHd+PpeO4L11bmClZz55t9hL92eZhNp2x0lpWt3YPjzBUt1+6/vTRQ9vO5ArVJ4+fVOaWN3b3x93g7JmJV25f9Ab1nSfPh1ArWHRmZm5u5dzdJ8/XHz+ZqORdG0MlJcT1Zo9Q/e/9/u9/9NFHjx48ri0th3E36Y6vnj3jJx7ElGKZ1qHjFt56991bn3tzolb6i7/4c5nE/8f/8/8+VPif/5f/dXEmgyA92mv/9rdulmzp+V7kxetbezcvn8069PHGrifN7ijq1EcXl9Nz1WzYPbx2+9Zpz79/f707SkrF2iBiG1vtl65VVqeLRwebElmdbuAncqaS1hEcDvovvHxtr95776Pt/lhduzw1U6L1nX1FNTudhQC3jk/MjEmd3OZOa5zIxkgtlyzmjalOuWG2vZGD4bXZ4mQa9oajUQiHHDZaQSFvx1HIGDIJM3QNQogJzDgQcIYE0VPWeOxFCisGZRRNLxQED6MwsAkFhp5NOxhxQ9e9kIehn7P1+fnZiKmfvPVwa5/dupR/8eaZmA02trab7WRpeTblWFHkU0LjmIVJwjmYmpgVLDg4bfgBm1+YrdVyLI7W9g79WLt4ZqrRaAd+knKzUTIq5ItSwPvPNza2OhlXn5+bvHF+NYi6P/rFJ2u7UYyJAjRREkMABc9ZqJi249A3DCPtmgQjzw+DIMYY65YdJcwL+SiMTEwwBIngEiEAAWJAEahj4pgQic/Y+IQj+VnY03WY0gyd4iiO4iQxLFMnOJGCcQUJEUlsaIapYwwQpZQxJiXHFGOEFUQxV73RCCgqie77AcLANm3BYX046ocRQcgmWtrVEYQYwIArP2FBLKWUrmFxqFgSY6LFnHEmMZIapbZuOAbEmBoYMMEJpVzBYRCNvDhhwjJJKZWSUgQiQoBwJTWkT2eNVMpESgICwzAWihNCoUKEAKpTIIVl6BrVpRQ6gRBRSBEPYwmkRgihmpQcKMUkJ5hiiIRkSmEIpIJY8AQDIjGKkxBKhAiQQkIIIcBRwgiBCkgAiUgiTA0hmZKAQswRAFIqwQGmPE4MnSZCEIwV54hqSRxDCJkQBGMAPvMTY6kkgggIzhWAGJhES6IYUowgghAxHkMEAYJxxA1Ng//hn7/IpZRSYEyBUligWAldo4lICMBBklAEpVKcC4SJEFwCpRONsURJQTCRQOmGxpmQCgKgAIRKAiCkwkonRHCJCSAYSykFUDrBLBEIQwQUQljwCBKshIQIIkABkFIoiAGCSACAAQAKCMAx0iAEUimMkWBQ8kQiACCEQipAGI8ApQh+phlLdM1gSvCIUU2HAAIEGedCSIIhRBAogBQAEMYxEIoxxjDVhFQKoOGwDwnlHCoMx55vanrKQFnH1qlw02nfDz0/5Er5IU9YAhREkIySOPBioukmgZom8jkHC4CRtHU9DFgouBfEUZDohi6lisI45sq0cdowpUgYF5quAcktDUYxMwwDKi4Zz2TSjkG9sccgAJgggThLiEEV51xKkSQCE5HwiIFBbwQoNC03Zomto4laYTQa+R4DiGgaKmStVNrBQHhxnM+VbF1rNOqZSnZmapKzKJVKE4wa9ZNhf1gslLKFtIhZr9PSMDpqdHZ3j8JQVCbKK8vTIgkoJZl0utPt9ftjx7aoTqYmJwgEa0+f97rjTm+csuzqRHk06nc73enqhGZRFnoSoNPThkYc27E8PwQEpghpnDZiaDhuzhsNWo2eroH55alWd8RCRInkUg29wDCMWtk92j/Nl7OViTP9wc7eTnOmls/kMmHoGSoxLXvI6L27e0qz5mcKBzuHIw/cfmm5krWe3n0Y6eblM5Oz5ewHdx41usxxywkbRoE6GHGDwJytJkuplKMdnHQo1SbK1W6vW2+M5xdqNo4zWWv7dHjYSFot/+qlUiGlJIvypZnn6wdRlJw7OxcPTh4cgpRJEgYOTvpIw4szZY315mZqrcZJdXr54cZeGMS5vJVLp1gSH592P/fKjWaznUSi7401Aqanatl08b337yCKz55btWxze3dfKRxzOVnLoyTaaYwdS5ubmQw8P+RJd+AnMZubm3u2tnX/2em/+Kdf/dzN2//tf/P/5siaqJZ45D95tnP7xZt7B4dzs7PXrl341//9/+fb3/4qi5N+v/v5L3/jL/74Lw72j0qVUgKhnXIeP9z4B99+5Ue/uPNsq/Ob33y9nKGdZv2Dj5+Xq9XaVJVAXs7Ydz75tFTKMYmY1BOJKq5/9sKLWzutn7//3v/hX/xD4Y3+5q9+cePFV+98/EFjEBZLlSuXlyYL7v/yb/8ylc8tXLwxmWYPHzwDkMxMlmbmSr1ma3l5OeT8zod3X3j5tU/u3H308PnFS2cWllY4i3/17kf/4n/zlUYzXFtvjWP//LmVD99967QxXFk9s31cD2M1PVFZ39vtjJJiZXKpmnJF53ldNOqt8xeWDMTLhTQXUECt3R1wJfv93vP11u//zhertcyHb70zszCbc4sXFpwf/uwjmKrtHTeKhcJ4MIjiRGLa6QysbP7gsEUQzWaNOBojwd106R+94rYj8/H67vmVJZvKk6NDRPRSbSLyuoE3rlYyBoVxzPyYhV5ULJSMtMtZOOp0yuVyOpv/+MM7jCW3b1yWGuaxNKnxZH2rnHUhlnESQ0WoBiFEGKOpqWlCZLvT88ZhfzDOF1O5XL592tQ1HRm4flhnggReZFspPxpRoiYnpj7+9L4fslKxfObcEg8Gp/WWaWaFYlIxKfi95/Xl1Qs5h41GnlLIH3t+MJ6frS0vnzltD47bvj8cXFqZHA5an9zbWViZy6SdjA632x4R8UylPBoPJqbLM1OTzUZjb/+IUOf50x3ThK+9cuukfrx/UC+XioozJ5sJh8NSpYh0t9cPvfHQptJ17aEfuOn0qN+lEBw0O65bkMgQTExWbaLpFFFvNO50ThBS5XLBTRebPX9/b29xccEfjvqjsZ0pDTod3xvnCzZSpoCq1+uH4Xh1aZnxqFQoDEYjgmClXG61u9v7R9WJWX88SOLQcB2iRCadBhANhrFh2d6og4Ffm5gWyoDUOKn3nm6fhlDzI3XabCaEhAmKOcyaxALhtQtz06XC4/ufXr19cWfzdP+kMTFRufXCjdPT5v/6vQ+XzizLpJPS4Nm5hYHn7xwcJXE0N1N10/mf/ure6y+uFHOp3e3dDx73vvDauRtXr779sx9VUqq8fPadX3167dLMmaWp52sbV2+89P0f/qyYzSuMACKdcez7EU+Coom+9OrVk4F34/atH37vl5ubuzMz2XPnVv1gtPXk2bd/+9uPHj988Gj9v/ijP3j7nQ87w+ZUubyyMLN52Pjw4yemplcmphqN+u99+8u2Y+4fHP3gVx+PQnV5ZUp63cpEtZeQTi84OWqELKyVCykz2+/Vy+VCr9c7rvenZidl6AmEyjNnP7nz6WgQZUzw4q25V27d2jg+/MVPP+332fy0fu3Kxd3jfcbAytkljQXIsPpDfu/hE+GFb7569vnG6eTK+ZyrDfv93Z0t23Uy6RwFiYzGt1945YP797qD0Su3bu3tbtx7tHvtxet/9A//7n/8b/7lD+4f58vVfm+YwskLl6YnCinLde9/+lSA4NbV64dHrefb9Xyl1Gk1+u3gpVvnCs4IMJnOOIfH3Y+fdqamKuls6vHjrXYPLE+AF66sQIR39uubh32swEu3VxqtQ6xkvpBf3xufDmSr4796JZtS8X4rsNN22naQjBqt4dTy9MFx9+TYb/nAsGAxlymnU8NBt+v7vQA7RL55rWZiNRgNJXU29pqWY4QJ7Hk8YyjToEgKjQDLQCnHHvXHKcfJOLTV6ULTpUBokBVylsCECGPodYVK8tmsZRFIkO+FSRyUc+mFmcmd1nBta7S7X1+exlfOzWby5f3GYH9jszJZzeVyg0Ff1zQF8fFRI/Tl6oVlkSSnra4fR7OV/Mr5ldNG6/C4WSwVRuO4VW+ev3De90bjUd/OFJBhrW8dbmydJF5SzcHXXr5cKJc/vr+zfzqsd4f9UexzIBWgCqRsi2JBlLA15djUpO44CVkYKaQo1aAAvSihGqUQCSmFgrESIoFAAEIFRsA2dIMaAPHQ8xXChu3wKDQIzKQsgJBUKEoiDARCmkJKKYgwiMNY13SNIigl1XSIuW5YkIEw8rBhBTHoDEZ+FGuGEYYRBNCw7FiozshHhFJINarSjkkxFiwSCnV9How8LWVjRJVMqEYYU0zAOIqF5Ca1XJunnBQEAEGFIYjjhOj6OGADP4IQ5lNWNmUILrujsVCQSZrCSaVczDgaVNw09DjhcRRLBDBGru0MR2Pd0DMpCysOMMQYAYSxBAJAJTjVKSGQJQlBOoJKKMBZjDBFCCCAExkJhTBCUgjFIdGAUIDHCVAwYRxjgAjlnxXRIVIAYoIFlwhKhLBQAAHFGdd1IhQCQCGIwjjSMUGIJDzRNBonCUBAMIkhRgQqiBQQUAKsgAAK48/IQphLgRAAUgGAJcAIAgAgoFTDEEnGFQU6pUIJhDDnDCGoFBAAaJQiDDEhhk65ElQzqW5BSBAiScwQxEowDLFSQkmOENQQ5VIhrLwwTFgcxywJkyCMhWSSx0oKwRnClDHApJIcSMkRhoQQiikiSHIOMYIIComUVJwrIKXiEiKIKNIoxQhAhCBWECPIpUiUVIxgPQhjyQTECCillFQKACWlSiCGSgCgEIQ45jIRMcJYNx0/8KNE9Ic+RBQiGjAxHI4zjplLEdfWLZ1QDY/Gnu8HAEDGFFTKojpChCmuJGASCil0nWRtRwfQRMKiuh/EsRD9oTceBUhDUgpMkQLKtdFUMWPoUEiOIVBSIoCihBu67piGYdFszk6n9CCOE6VizqOEDb3Ai+MgSmIlk1iNA9Adhv1xGMXCsK10JgckMxDP57IsjL1RGDOOIHKddBAkx6ft/dOu61oIgJHnOcVspVyRXDpOiiDSaNSBwhPVWjGb6jSbu9tbg6H36PHm+vqBrZsvvHD53OrUeNhPEuGYThRGfhBpBLmuMzNV63baH7575+CwMxyNJifL+UK20+33B8OpiclMNr21tb970O73xghQw4AsDDQMKdS7Ay9B0DBQs9U66YwhAS/dWkrbVhCoGChMCVJCI7xQyFDNnJmp2Xbq+eOPD/ea8zMVhIBiUT7jhIzut/gHn+yHCcjnreN2fxiCl16cyejR3s6anXdePJcrZ92Hm4drx7HmFrr9QWuQnAwSrBsYCyzZdC1/eNzWLce2Up1mqzsYzM5kFfM0Td/Z69R7SQKRacHpqYlgNCzmS74XRrGfzaR6/X53GKWI5AIMxmGUgIWpHIgGhqX5URDFout5B8de2s0QTCEQ3qA/Pzc5HEcnrdFwGFPFNSUJNp5s71LTXl6ezrrWB+9/pJnWay+/OF8r8yAeh7KYT8/Pzh4fHimktZojbxyurJ7tNNosHP5v//BL18+f+8kvfvLgUefW9esR483ucHF1NptLE6Cu37yy/vx5pZKbqEyOBqOFuclHD+5s7R9IJCqlQiVjBGMvm8l/fPcZT+JvfP3Fbqdz98H60Wm7WHSvXJwDiT8cjt/+8LFuFyrVSSWYpmmCg/4oCJJo5DXefP3a2dlKuex+7rVbg2HXNJ3rVy6WXUIg+fd//NeFyfwbn791Yzk76vWlipcXp8+cXVycXYyjCGHjzseffOXN158/e/zOnadnr920HatxcvKjn/68Njfl5qYfPnn6fPP5YOht7Rwd1burq3MIw54flivFOBH5TO7m1TOG8ge9pm2ldBxev3E2n80mUaQRU0LyaH1v67AJqcEC/5//k998+aWrW1u7GMBg0ANs8N7drVHETxttk5qH+3uBP8xkM61Wi+j6oD+QLCoUs1xIpBTkkY3jVL52fLQ/W8lXC9mHj55KyLMZ00ascdouT9YIMQ7260KAmdnFi5cuTU7Wup3+uN8/f2Flenb6+Pg4k3MvXlxlUn70wb27D9bfefujo/3DwcAfeoGTypmWzVmcMDY5M9vr9R4+2Bx6YnuvwQAixD7cOSwWK0Yqde/u0/GYmbYpldINmM/Yhm6eNpvF2sT5C+d1E49H/e5wTA2bA3B4VHd0/fz5M7evLPfqx+tb+43jjonUzatnF2drUeifdrudfp8lSRzFp/W6k8oZBlVKH3T6adv0vUEYijCKC6V8IZ2qnxz/xXffi7luOhlIieOk+sNxoz7IlyZWz57NZtOIpDDV+/2x3+vDqD/sHHIZFkvlXt978nSHJXhqZm52ehoA+Pzp2s7G/sbmDpSoXC1VauWZ+VXNLrQ6weHx8exE0aBiZ3195MftUTgc9udnyi/dOBOHstGoY0IvXbl84eL5Yb/rj3v1ZhMCTChRCDhuOu0WT1vNfKmoIXn38WEqU014IlXsBeOjw6NMJhf47OCk2em1j/b3eeynHTNjWiXXqVKBFKEYY0gSxhOuICDXr150Ta1zcnL50lkm8ThI/urPf/Daiy+lDMU4K6fd5+udzaPTtGW+dHnBImJ95xBR48yFufc+2mi1jt9484vnF8yfvLXx8N6T2ZlqqpA+v7z4pVcu/uLtrb2jDtHdX7/7/uLccmsw1O1U4Psqib3h+OKF66lC7u13Pzw67Moo+N/989++cL661Wz99Nd3Zqbmrl1b3Vq/d+vGpVwm98GdJzdvXEuCYGv3cP3Iu3H58h/9o7/f9vj6TrM1Yv/jf/rhux8/DqPkn/3h71ksglJGgn54d1OMfNcA33rzuqT69kEbYFFIWTIJFdYCBU/r7erU0qDV87rHC3PTE7VcpZZ7eH/v/Y8fQKF+5/e+8V/80dcBT5Kwe+vKKovCw93jfrdVzuQRCN948dxvfv22F/elBnePW/3OcdrVf+tb34pCP2ZJGIPeONk82Juplb1Or908dtLF2sz0r9/65M6v3/7t7/zGzYuzBT1ZmEwXi6WD497xcUdDYGFhCiLtsN5PpdIs9JJh27FzowA8OxqGgbAdd9Bt5Aq5QkFrNBrjVmt+MlcpG+sH4OO7O+m0NTtZmi5ZAsGP1pqum5USswAlvpdJa9mM9nCtH6F0rpTzvCAKvUiqqdnCoN6kil1YnbyyaHljtX7UH/j+TM0tmsQ0tPoI/ejDk936yHCdala/MOfkNJW1kCbFkGGFDFOnlOBeP/LGsZ5K7530mv1hoVxRcYw1ChEdj0Ii4XjcKRQzpWJZSRHHEVDCzWXNdPGoNX68s5ci4Mxc9txiOfDgp/d31rd2U0ZqcXl6OBwOun3HSSmoCMHzc3MMJfcerHXH3pmVxZmye3BaP20MKtWJldkCRCAIw1iogeels2mJUafb29vemalU5mrZCJInJ/KPv//onfefLk1PvnBu7sXF9GpRz+kIKhhJPOJqwOEglgGnSQIjEROsiGlwKRXACtK0Y2KgpJRKSAWkjnRKIEZSKsUlHI+jgIUU41RKhwoIwSFBmOIkTkQcIcRNShTETAjfixkXXELDNCBFEWeBEL2xN/BjP4gDwYiuxWGANWVbpu3YmqETTfNj7geJQfWMoSspAs5GQdjqj/xxJKWEQBgagKbGEgEEtwxiYmwRbBqYaBRiHEoWCBgmCRcyYlxALJUCSuoEAghCxoIgUQraBqrmXUwQ50k3kbv13kl7HAaCUuI4hmNbGCJC0WA05opjjSoFFUQUawRQhKgEikKsmybB2A8CDesIg4RzICXEGiZQKhBzDhUhQAEusYIYAZ4wyQVGGkAw5ViE6FICJIGp6ZRoCEHOOcJQAiiAkoIRiDAlHAAJlZCCsZgQzIVgiiFEo4QrAaUQAgiFgFBSqBAIIaXgQEmlhGCSq4QzDIAQkEvFuJCSIwkUhUgJwTlHhCgJJJRKKSm5hAhjhAjVCUJEi+IISAkkkFwgIAlECCtAgJKSCY4Q4YJDoCCGiEAhY5EwiLFBCYKYasSxdI0igLBgKuFCAgkgwJhQoGGMFFBMKAhIzJlMuPwsvStFKZFAAQwABJgAqRL+2VRDIYWgYAICginGCFKoSQU0QijBFBMpuQJccA4lMKjJE8EBjBkf+v547EcJ9/3ID30JccxYFMcA0/7QE0qYup1yUxRDQwcIyTAUfhhFXAgJhJJc8Jgnfuh5fpREkW0gx8CWRQiSJkGGlUo4T3iSxLHk3LBNSqilE4JE2tEdW2c8krFHETBMDSMAVKLrluk4UkkNYIpxFMdhxIKYcw5CL4mTSHARBJEXiv4oaI+GfhAYtu26jutYjEVSxflsCgLpx0IRDRFsWVrM/XqzBRE+d/FSLpMPY98wtHQ6Zeq6bdneOKjXTzWE3ZSuGaTd6u2sbfpePBz7tp26cPnK2fNnbJOMRj2kpJtKRYx1+0MIUCafLRVzvXarcXxKNSOTSU9NzRtENZonCMpauaQR8PDx42535KbTgRclEvpe0ukPRsOgfnI88COKncAfQ0imJ2tnVyeo7uzv7/VGoySJsU6ihLnpjGWahwcH/eGw3+9abmamVhUSMIFShtNtjw+7g2YvURRNVGwD0yQIzp2t2Rbptk+dlD27uDyRM7ePjj+430xnspKz/ijshcBIZ13MUSyuX7/QHY0SCUr5fJSMIxaePbPc748hgb0oeL7v5QrVYBzmczT2e0ipTC5bb5xalqOADILYSucVYMSkSMO5LDFNLYqjUqnUbHYV0UfjqFp0nJTDokjHoFyr2Zbz6PkWj8J0wTZ07czq0u7u/t7OScq1p8qVZ08fT9YqX3vzzY3NZztbWwLiYm0aiKjXbjNA948bQRTOL0xnHb3RPPg73/rKZCX36Qcff/jO3WvX56kGpUCaoefTpueNKNHW155FYfDqK688e7a2v7fVbPXD4bhSyCzMzrrpzN7+SeKNCYyBnT575Wo06DdODtL54sBPyuUSAdQgpN0ZVibniWlnSuViqUyQiMJRsThz4/atmYnSqy9dJ5QEHn/n3fdn52ZffuWlrfXnlWLm6YN7q+fOnVlZTMaDg6e/HniDc4sLBIfBqP2D7/+laaS++72/oVh/8HDt1+/en56aRhBFXN17+LA/ZK+9cPPuo+d37m65KXtxaT4MvfmFldn51QdrW+HAX1leHPu+YDLqNsspvDI3x7RU2tZswznYrzd7wcFpa/+0+Wy3UalNlysTs0szx/vb//a/+5+69QGhVqVQ+eqXbm83+UnLZ+N+KW3Mz0zPLayMvdHFi+cN0/W90DJMJWIokkqhkLHt1fnZw7bHo/DapcW9g7WFpbnFpdU3vvQFIGLdIDKBSczn5pcmZhcOD08fPHzy7gd3DvZ3L567kETskzsfZTJu2rFTtsESns0UNc2oTE2++uprbjbbbnv7e/u9blOzUobhvP/2+z/9+XsSq/Wt3c2dk8mJ6fHIHwxH2ztbu9t7pVKpNlklWPeDwM5a2VwOm+Y4kINeGIaxZVhCgbHHup147PNcuayQsb+zz1m4NF967ZXPXX/hcsTF4XF9fnHetOz1ZxuHB3Vv0AtjX2Kt2e5PzUwjFZZKuZPmMQ+iC6tLEGvbm5uMiw8+vm9a+szc0u7ejkLMyaWYUomSpm2vPXl2vLv37NnGSbMzGI8SGWk6On9udWXxzP3HDzRqurl8q9V++mTd90ZxGF25enZxviQFWVt78v4v33nv17/++S9+3WwP3Ey51xkHXvfFF1/IFvLINCwrdXx0fHy4G/uD1ZXFVC77/NnW9vZWIVuYmZu+ceWmrrutVssPg92d9e2t9U6/2Wt3g8DP5XOS4dHYtyzHsbNXr6yKZKg55tUrtywnO/aTsecVS5V2u9Xv15dmcnOTNlRKBxKLJBaSI21j/+jJ42e5Uk6jWiZTKFXK+dIU0fT373z0G19+pd5svPnChTPLRc9jm5trmZTx9d/4kmVYT55vzMxPXLo49fBB4+io+Qd/+F+uTOHWyV6j0dnda9/56L1XX3/lD37/jTt3t6CRO22MNUpMQ/f63nAwWpydlopvbD2amV9ZWj3bHfb/+N/+h/1nD7/4+RuFTNZ0s79892MNgaX5+dP64er587969/5ho7mytOpF6qQV/A//5o9/8uOfvXDjuqZjQm0vVu8/3PnhTz89PW39o7/3lYPj4+rCkpPJQE0fNBpev/3FV18eDcTh7r6bTw+HI12zc242jJIg9GyHbq/tIaXiMMzly1dvXFJc3Ltz91c//Fsg4n/w977pOI5JtcWFBZ4Ex/2o1W6fmamd7G/ZFrp05WaxUuv3WkOP7Wzv3//kk4vnL0Rh1Bp4gdR7Q69UqZ5Znd3fPfJGwfRE+ezq9J9+9292j1u/950vzxWtHE4yjpkvTe0dt0aDoZvWBYSfPN6klnH75jJgQRJ6RooMRuHpabvbG1RqNYLRxXMLrg7GoxFSqFxM5Yv6033+fOegWnBKZZjLECaMdqMrJE4USGdoPOrnHJIg9Pykn8/lM44+CngQKo2kKCH144RBeHNxdmXasSl9ut49Oe1kXSevJ7W8iUy9NeZbO91Ob5TPuAsT5aJN5/KQx3Hij5UQAMBSJccZQ5LXKm5vEPWGQ4TloDf0meDYNA2zUrJPjk4Ei3XTJFhL/HjU7kjGMk4mSPRWq4NxsLKQm12YgkZ20Be7a89GXjwzMxOFQb/Ttyyb89CwyJmVJWIax6fdJPYq5Voqnb1z50HoRbl0qlDIZtMZBbW93ZOj0ybBZr5Q8z0vCdq1spMvG0BPjVX2wwfHf/y9t9Y21udmq7evr9y+OLVas2wMWBQKzhSkPlOxUpIJzhVUABM7YixOEiljQyMEY4WgkkoKnxBMyGe+LarpJov4yBsTzTBNg0dMJgkmhmbaGGsyTiCRFAElE13XAFChNxYKQAAhJpAaEGtCEj+MpZRKYcM0FReUyJSBTKiyaTufc4UQQRJghHVCDAAE0nym+mGUSKwktAnJ2SkAFPgMbq+AriFbpynrs3M8ilncHwWxFIzLMAqwpgGIKKE519Yo7Qf+KAioRvNpp5K1MxkDKRgkUaPX7/jxYOTFvm8ZWj6dMqhOCIIAR34QhJEfh2EUBXHijUdCypjHAEgpgYZpIiRjDEOENSI5gxJiBBFSkCBCqIRKQokIBggrCSSQSsogjACSknMJgIBAIgGANDRDcYkRxAgRrHGpNEoVAIpzBbCUikCIKFZACsU/+wECdYOYXHAllOIQE6KbFkGYYB0jSilECkZhIqUAAEEogRAIACiUAhgpIJVUQCkgBVAIA4wUAEJhDKSEnDPT0BGECkBCtIQljMdCKSgAJoRSyqXABBKsEUi4EFwAQrGQnGINIiJlkjAJICAQQgIpoQIIIbhiTCHGJEcEQiKD2IMQSAgohhIoIaXgDEAFlaJYFxJChQkiSgGWREBBhCCHn3WdoECS8UQqEMci4VwAABTUdI0JGYZJzJU/jvtDL2YCQBJzlrAkjJjnxWHMIYJcCKA4hdDQcBKFhmkggDRKGJAaMaRA3cHI8+OAo94oDhIUJlIBjDE0iXCIymccw9QZDwLGAEKAEMc0TAI1CnkSUgQtnWgYO9SwLDeddk1NN02DQIIhV4yxJFRQJkIMR5EfJkEke37kxwnVLAQhIVgwGUSBrmvTtUw+ZWDEGY9YEhbzeV0nUgjfZ0EQSSkwlN44KJQLFy+cyTraYDTmLNFt3TENCVSjWW/X24IzQiCLeK81ePzsqYI05RgGJplsGiHeHzQ7raZpWdVqVdP0+lGdM0UI0jH1PP/0pM0Yd9JWrZwTkh2dtFzXLmazYRRv7R4Lxs+uzFu6nvBECBgxHAZ8HASaoUOJex6Tgjg6EnGf6tZBo9vsJpNZY3GqpLiUCCGoHeztBQF33UIYKT9IhvEgYn4upfdGo9OOr0haIJw2Dc3Wo6BVypJ8xvCHo0q5lnGdJPb2TsZb2z2qUcHRUb0PMHHcDNWEYuGtS1MsYevbrWqpqMmACOUWq43jA4xxqjB35363UiuO+j1/xBdmJ+PxMF8stft+px8KIYiGi4Vip9v3IxCNQxbzSs4dtjuuYbYbAxbLdLrYafepRQKvPVnJ5NKpKOQbOweMi7mZCRX3JyYrw0gdHHcWZgqzM7V3P74XJfCVV196eP/Oo8dbmp1JmDo63Au5Om63RSKU8EvFXD7t1Ov7F8+uIIy/+2c/bnR9CdCVCxcGrfao18y7GQlIp9UbjocaJmk3fXx8sv7s+ZlzF2YXZixLs00wVZtkgZcvZp5vHJ87v3ru8s1PPrp7ctSYn56jGBhU67S7R+3+vSdr0xOTlbxDlDw8qu8eNpZXV/JZ88VXXv3orV94g265Nvmnf/POf/V/+o+WYdQm83/yVz+Ymiz5YVyrZJbnZx89fW7nzFyxJlWSyVpz09PdTiuWAmlkbqY2Ozf35NnOhesXa+X8vbuf5rPFUtE9s5QNevXv//hX6awzvzh7vLcx6NYrldqHnzxvdSMnlVpbW98/Ok6ntVqtUs6nt7e3dk5arUG0f3QYSR4q7dHuyXGzVys4EKn379z/5XvrRyfHX3zzc+cvnYskfOMrX9ncaf70g63AD1+5fubCUi1h8cHhEUV0NIr2TuqR0mIhgmAk4wADOT1dUYl/9/7j6tTM+tq24pxiZZrGzubmgycblWqxUMykMu7h4dGvfvZ2vzc4OGomHHz1a29EcbCxszs1vWRaKYQ1Sunpcatd7yAp7JT9bO3po2dP9w5OC6VSpVwZeuHG2tbA865fuVCr1Qjw3/zCVYxUEIwyxXKuWL564/rFSxcHg8HO7sbqhXO9wfjtD+4PhjxhBBpavdHd3j1Z2zjtdjwGlURy77C93+w93zrxRyOLwk77sF0/ARA3et77d5652fKbX3jVyWhc8lK5BIHs9RrBqGOaoFjOZ4vFdMrmMB70TgvF6pjjvs/PX1jY2Xw26LcunF2ydXqwfzIeed1WA1MSYzpRLZuOY1EXQaM+GK1t73Z7I8Eo4/GlMyvpfFoSJAHVNJhz9YnJVKVcYhKOgtHZc2e+/OXPLS5MQBgBXfvJL+7eu7+Wy+YzGpmqZRYXZlLZXKPn7e1vl0uFQjm7tX1099O77//6k4/u3M1WKoiiIE4cqzg/P7+6MlOtZAhWxWL53MWlja2d3sAfDEftRueVV14IBsN3PvowCFgmlRYACYhmZ2si8nbWHk/XapPFNOSRghICEEnQGYu//MX7/Qj7HG3u7Bm21Wo0ZpaX3vrVh8ORl9LxsNf8+m+8MT2ZcVLpX733KUPa5ORcf+gdbh6ePX9pZnXyr/7m19vbWxdWF7LllJ11LDf76b29/8f/7b8rVyevXr/w6Z27ly6e2dvfPHfu/ORExTKM/mgwMTE58qPdzefFXPrFF19CxPgf/vTHByf9UjHjau7S+ct//pMna7tHFy9fPXd2fnEq885bH6TztVY7NnS6eHb13pPG6cne3GzVtmDox4VsaXK2/G/+7G839rsL09XW0eH0ZIVL7mRyf/WzZybofvvrN8YxU9SoTU7GQe/8QnmqYOzvb5cnZi3XCDzfMOnuzuZg0C+UyjOLs17A3n73vfc+fKgS3u50NA02G/0wkHsnvZAxBdWv3314eDxcmp3TuLdX71y/fcsLxh9+fMdIpYZ+kEjYbI1+/e5DojsXrp4jkO2sbVYqlZn5mb/4wduUs3NnlnP5LAFxuZQlFN97tJFOlxZnl5KIH+zuVIu12ZmCo7NaMSuSIAL4uN1vdfzJaj5jqFJBp4Y28iOieClvFmvk2Xpb6cDVsYlEvpDK5VO9sdce+eVSdapiBV6QzWU73ajdqhcL+U4vjrkYjoNcxknn8Ht3j5rjxsp8YSInsmlw0GWcaPPlVBp6VCUQ0UDQ9d3Bx+vN+iicnqlevVBcrRgG5AKobi/o97xcPk2Rcm2jWHSwTCjESqneIDg47j7d3B/4OJ1O9wd+v+9LBe1MKpPOYin9YGjrJtRdbzQMg5Glw2yKGjbFutbp9E4PWnY6J5FicUIBZizIFTLnFqayGfPR0+3N3cNSIVfM2w8ePxxGGgSJbmBdx2EcJZJ++PHTo73DCyvnHZ1m007B1gqOdBxhFbJjAT5aH/zyzlazPTg/XX7jxvz1ZbuSAhpXUIEw4YMxTzDlXA5HvmCMEEo0SBSCAgDOJIsMjE3D0KEyTYMLKRiXSlBK/Ii3+2MAkEYQ0PQgCiPOpZTEtKAilJJcPus4xKA04+YwE0IoAjGWAgEIoeRCAkiFUlJwQyMakraBTQodDaZt09ChEIIDpWFkW4aOsI6gEMALkkQqiKGpqbSlI6i4hOozdD1gKIkJlpZOCcIYYgQJRcggFCuolJCQU4gcg2q60fVYaxBAKMu51HTJnSo6OUcXEB60Wrunw47Pu/2xQogC6LqWm3Y54xgDx7Q5F0wIBADnUnAZhRFjUigEoCL0M/w9ggiIzxg7AoiES6YA+qz9ziEihmVCDABECEPOGMAIEUwQRoBQokGMNfLZYlkqIBQCCUswhBAjCKVm6FwBpRRQGEIAEYIICCmE4gRTRIlCmCciieIwipiIEy6Y4AIp3dABAAAICSTAECkJMMJQAEI0ohGAFMaUSy6BNG0L/uc2DgRAAUAAQlxKqIBQEgCIMVRAJkyEcQQRjKNECsm5QAhBiCSQQErOBEsiAbBQnDPJhcRYl0ASrGONAgyAkBBCxgQQgOqE84RgpABBiEgkuFSUGACghMdCSoghUyoOE0wQlxwixBMpJGNxwiMBhOScAcgJwYqrUHBv7EcRCzn3kmgcx0xgoRCTStM0RA2lkIBQIaxppuASE+JYpgJC8MQgBiI0jAORsHHgJ1IlHCYMDjzfi9Q45lTXTEMruPZ0pVLKZTESfjCOY6WESBJJEOWJkEls6sRyHFM3MBYUQgEhj5PRYCRlJKLQMHRCEICJqRsQ4DhiXhwzpuKESwZ03YIYUaojBSyDTE9N1CqlcsZ1dCwTpri0UykAYRKp/sDz/FAzjazrappuu875MysEJV7rECHdTWcmpqr5fMob9tudHsLQMXUAAab06KShG+7c3DSQPI7jVqPujYfd3ggg3dJSvV57a3MNKKWAcF0bI3Swe4ABKuaKjon39vf2tzc1I13MllrtRrfRpkjL5/OI+4r1lVIsFq3uAGrGxEQNE6PbHwkFIXV0EjgogYK120Mna81OFFU8ajS7EBsxw5TSSqUQhOFgwBJGEYITebdWyJ822u1BcNTsDHrdjE0o1pRCpawL+JgQrCAdDEb9+tHjnUGsiIb0w5MuQzhfKXSavWjUv7BUyuTsO3fXLVOzDQiZn0ThsB9ClZw9f/H52hYk0HLM3e3eypyVtqhpYttNb2zsuNk8lBAjHEdRtx0rgLkSSCRZ13BsDVsG1Ei+Uh16HheJBiDFYG6hypKw3moR3Z2ZnJDhMKUhN53fPznNZZyVhZnG0RET5ObnXt1a33nw4FF1aqIyPZ9J5xK/Bww7ZqpWrtbKkznXhFBgBKbn5x9vHrSGnucH3/mtr8Zh/8Hdu7VqfrqaNWjKD0I3l7NSadM0GvXDcq0cBlG31bYsY+z7ukGxoa8/27l8cerWay9/94//bBwEi+eWAZKJP3QtK4zBcXNILHtqoXK4u5Yy4eOH61OzS/Xm6ery9IcffPzWz999/YXrm1vH//OfvLdyofQ7f/cf/vSvvztRKFy4fA0BeGb57P7Ww6vnzy7MLx02+/NTteXFhVJlYm3jtFabL1fKk9O15xtrhu1CoHY31r/2+RfPrE43G70vff7FpVq1Wi790T/+o0Sifre5Mjd3eLS3f9IxMunPPAzLM9MZ101n3d5gEDKhUUtBe2p2keo6F9KyC/NzCzPV4snhQa83OrM8/Qd/+Efj0eDenY96Y//f/cn3/y//9X9CUP7Dv/+tL7x0rd48uv94vdWLu73gaP9oMPbDJDasVMpxsmknm7ZH/W671ViYmrFMI59P37x1DQBfKba1fXz9xq1SpXhytL27szM5N/PGl16/ePH86srSV7/2jfbJwf7hbuDHoe99+uld3+sPR76iqDaVX1mZ6Xc7reZgYX7xi2+8bOg0DAMZ83Pnz16+csFJW1E8/uY3vry4NNttnUgKozg4Pa0/unv/aGe33Wlms9lRt7e7XT975mqzO366thuPPaybdrpsWW4mk5maqigFB8NIUbs4vWDnyvlszjEMppJYoljS/jg+OT1RiHud4e5ev1opzs3Mriwsnj+3MDM9/fTZAzvlLC5M9VqnlmOVKrXjw7pOtJXFyVrReunq+bla1XHdtOtMTc3qmlGq1JaXV7CMZMwfrx3sHDRtK10qVUaBl3A2GAzvffKeQXGtWIOKxoJbBp6bKmOoUc06f/kKl3I07Hv9YwSicrE0tXj+6HS8u7unWCwjb9Dtdkf8tBd3OsOj+slkbWZqdmphee7y9RtDjx1sbBu6o4B22Gh1e52V+bmV+drB/sEnDx4h4Zs66LeHo/EIKNlqtEqlXDpTCMe9sRckjB/uH5ma8cpLL6ysrnQHg3yKptOOqesSSCKRaacZynQ9GQnj5KSOBDs5bUGsZWvFJ8+2y5Xyg4f3Rp2jmYKTcbRmj336ZGMURyzgx0enf/2jn1rZSm2m+oPv/5AnYxfTbCpdqZZ///d/K1+q/Pn3/jKby0deTJRMOalPPr7Tax2dOb/Q7PTbo0ABHSHsD1tJ3ImRcTyk7T7JuoXn22vNZlvPZv/yh08ePXg+VU198zdu+1H8aO3QzadPGtvF2tz1a0sIIh4HL948e+3MdKu+Q2g6X6j+/L0Hmp3TVHy4sxb6/qXbL2arqe/++d35WqZaLH744aMgjEkSwKS7NFOr5d3WaUfTnDD2U25qemH64Pj4+OQgn0/XFmrZ0kzEyM7+cT6Tq1WLF69d7A/idmfoJ9hMVex8+cGTTYzgpZVi/cR78mz35Vdev3X9Vj7jlgrpfMqREvZC9uDpRr/Xfun2FYXi7Z2dM8sXAi96662fnfTbfS8ul7K9+tbZpfnRWG7v1VdXFmsF2jhpP13byGQL51andDBOGThl5RG2et3g9PigVrSXV2a5UDEXoTeiUkyVbGrQrc09Deszkzk8OiyXi+m802z1R73ewvR0IW8O+72ci0+PPSuVyhXNziBImN/qtCdmJoq51N2HPRuLs5Npy9bGAOwftTMWurmaczWVsMRNW4nCza54uNN+8mhTY/zyysRkJYMxNV2r5yenp60wDtv1lm0SjBQmZG5+PpN2TdcNBal3giBmhmEzroZDr1Fv6wROz5SobpwebwkRm+kq1SxCWDmrs8iLgGml8kMvqJ/2ABNJGBimJYWUwnNsNjczaWWLB6eDQbtbKaYRgjsn7VGnl3PtsysLSGKlgJ3KrO8cdIbD5nDkWNkzk7MlPdaVNxiNOHWwUTrqiHvPDh8/eQjD5guXpq4tl0oZXdMQgDiI5WgYCol1w0CE8kRQJSillAA75WQzaQQl4gIqxVli2AZAkEkppFTEHCewPfKYUFACxsVwOGyNR2MvCFkUByEWTINAo5JgyKGikOtImQRQyTHGiiBvPIrjREGgU5C3bRMbKcuUcUghzzom/cxtqxCQMcYSI0x0TUgVhhFW0MTIJFBwHsbCG/ojz8MAOLbtGoaGScowbdPgTAAJLZOmU7ppaBhBBBOigI4wYNAPeWc4iv1xGspa1qoVM1nX1s1UJ2EnnXF3GI5GfYiUSBiBgCCsuIAQSKQI1XXLhBILoKQAsYgIIYwxP4z8KGY81jAVPIZKAQwIxkwxpCCQACgFIVBcYIQIRoQQzbQoQRgooIASQgrJeSIg0AxD03WuhOBcKi64QBJRCKWQgicIISglgBACmTDBgVRAEYoxUIoJTAmXXALApFRSAYihABARiiGQCiEEPxN+CS65EJx9NgYGLIoQhBDAmDEdEghIHCUSQIwBT7imYUSBoesQA6WA5IISjCGGUugmjUUkJEdIahpCAEkOsIkJJlAJgCBAQEqmIOeMxVESRzHCSEAMEYYISgl4ojTdSoQASDIWK4YQAkowAZRSiisRJbFhEEIQgoQSFCcxUtLQTEAgB0xiIKFUEiWxwIRq2IqZkFIJJmKPUUwgAQAwgjBnIIyScZgQCF2TQiCiJNZMO0wSjSCk4DgIwxD4AU44pLo5TuQwEI1x1PNiX0hN1zUYTZW0ubKVMXjkDwI/iiMQhsKP1cjnfsQEEel8RoMyY1DTpATTQILjlrfX9Voj3h7FEkpN5xlHs0wjYTLiMAiUUBrUTAFwOmVkUtiiUtc0hDFQAEPuYMUFqPd9nwuFiQWQ70cnw7AXCschGQs7ti5RlE1TQlm3O4yQQ7XYdk0e84Ot42F/pGOQcsyUY416/vMnGwSjyVo+8L3BKNINPV/M8NirlSsTE9VWq/P8yaadSuWK2XTKMTTzuHHKeGyn7JE3fnR/ezyMbTszXdODyPO8mJquImToj1rD+KjFOiMAqJZO6dmMPQpHjVbPsknBBkGiBty1KnO9Xk8H0bnZcsDEQU+EUpcI+X7fsFKSkuNmeyIn3rhC3ri4kK4ufu9e+71NKYRayaALswXTMqRklp0aDHsExQtT+SCRz47D3Y6TM2HOgX4S6DaZm55o1Bs8ApfPTU/MLP3io/WeD6oT5XQ6147x6Qgg3VqddYft7YOD0eT0dKMXZLJwomzrcjBRmTo6OlYCVtOmpgNKyV7THwEz66K0RijBo3FINSthMSZk4HvtUBHL0qmcrZVDTz7bboShyBmAR8F+K461wuHBUdJrz8zNHvSizb3m526skvi002vXphdTNs1accy9kx6IB80XblzOlzL9Xj2T1V1Ly2XLm1unPhPFcjEYdfYP9p5vbJdn5nK5Sq/dOaw3dU3POZZDyf7hsVTQTZuUyFp54mCvs3846kZxuzdIEvHmF7/66c++v3M0ylbKQiZARATTBJKRINS0LQz9VsNyXerkMsXS3s56PmNdvXLu/r2n3/m735y5fKl5uvu7X71yZqGy/vQjLtCN2y/96Ce/Phl4D54/qpbTX/rCjedrO9vH0UR1YhR577/9y3Or00uzk3c+fnh6GgNsA6S69aN/+k++cePqwvf+5E+XF5eDxP4P3/th0bWfP3+6/mxDs0q9ENxf341DL62rqxenKQqLOSP2h2G/gzUjX8gFQVislVkcwCQ4N1N44fJcs904aIyG/eEXr0/8wddfXHv6+M6D7cmFC37CH27utyP6z759oeA4f/rTd9tDsTg9MT3lprNuTLBB8Vy1MFW2Vydy5+aKetKrpR3X1dK2MgwtSsCje48gMJIoyrhm5HsP7z+SkJw/v4Kx2tnbf/TwyeHhwQ/+6ntrW3u2k37h5hWp4kJGm5mp2RqqVHKa5mxt149P2y+/dGt6otLv1D1vhLAiWARBNwk8KXnaLt6/v/Mnf/4Tollz1VmvP+50OjNz09VadXFpuVSo1eunc2XHtMYmjX73Oy/+1je/sLhY002EEE+5yjGVgcWNixN5LTDUYNAfrJ16h80QJPjS0sRS1XXT2u5e49OPH7544+pXXj+fBFGj1er2uu12/+DoOJPOP7m/NvR80zIyGSvrYjZuLS/NOtlKexAws/zrT54+eLihSAoRu9Htv/fpfWxmZybpmYsL2WJW8KSUMc8vTdgmrNbSb37p5UI5LeWoWDHPrdZSlD9/8rTdiwpllHWV542a7cHW9onllAAijPua6JxdyUugPn602+iqmCPBma4SQKw40o6aXanQo6ebGLP5+YlhOBwMBvmM45h0NPTeeu+Drf3TpeUzlXzVQAoDnPDx2trR3klPGelBL5quFEr5nALxZKWYdkC5kHr+9N7z9TUrXby1mJvLWxiJJBS+CBClQoFWf0QRzGessTc2Ms7m9v5Ecda1rfFwDPTSXiM87Iva/NL1K/M2YLVCqph3bl5arLnGxx/eJW5hfmV17/BEEPzo6fbDu8/azfrnX75czVMWDDP59M8+eBaZ032OfnnneHd7b6KQbfU6TV8dtJNQpeRomM6VOQN/+aOfXDu7+ju/8cKHv75z6dylKzcufPjx49AbWA5cPVMplIqLC5PAG5eNbtoQUHcShZ8+evKb33r1669cRuHxrWXTteQP3noUu3MzCxfCUWPt01//wbe+Wp6kv3jn7pkzy6ZDj5vNytzcKMaj3mBlcQlIP44Hmo4HvTZScKo2xSRTcfCdL770+tUJkPRShcJpp530m69eLL9w/Vx/6M0WnbNT6UrOEAh9+uDBpTPnX3/95q8+fPKL9z9enKsul7QbqwXHAV4QVgvpGzdvfnT/YON0/IU3vtA56R3uba5cOFcfglx2LgmSOAGAGlB3Xnn1xbUnD/n45POvnIeWs9WMH6wdubZbqzitgU8JS5j0Y9kdoadrewsLS+fPVGASpnXN5/ygB7sBGgTCMZz5iUoYiNbe+mSxhC19rz3e3N09vzDlakohElqpu+uHs6ViwQCRUPUItzr9ixPoRNn3nh+vTqa+uJpazdNRoD5+PsC6e3a+IFlcysJzy5lKnmYy+u5AfveT3ubpUFBayttnpwpTaTSKQITsgBjRaMwVPBpFjV40U8mt5OOKkYyCIQcgVnDosUiYTNnHrX6r7U1O1FwjfXjUf/Bgp9GOKLEt1ykW8jlbhsN6bxy1+tFBM6p3o8ForGEt8JgfybHXubxcuXphrjPymz1pmWkcjQSHXCk3beezWPndlcUJhbTH6/X94/G9h5sCyXKxPJ3SSpQHI6/leR6yeiDzwQH5wSPv/npncbLw1ZuV2zU1l4lThgi56gUxkxBCpmHJFRSMGQhnDJh3tHSKGjrBUBgEGYC7JrYMQCnIGSCnSQREFMmAs0jACJBIaj0vGo9U2+ONXhgwwCUKeBREsuPxXijGsTRMXSMASxUrmEiQMAUlUkjpumScJxKPRz7VcCWT0nGsE2loGAAgRQQkg4RwjMdxjDHOOVoh7dgmFEBGQg2CxItDxjgmiAChEYAB8Fnc7HlxzCay6Ym0XskaaQdZBuZADL2oMWCn/ajZF0rEeceYzNrTWc2UUkLQj9k4xFwgQlAiuWnZQZIwxlKmKXnAIoY1TAlBGNmGqZSyNANIgAlmTHEEIdGZAFIpoBRWEGHKuBRAYYS44rFgQkkphUw4QgAhpCDAFFANKyCgkkpKyZStGYauY6QTjSoguVQIQEp1qQDC8DMAKEVYw1RDRHzWkkdQKIkJ0SjVEZFKQgUSJoGSTCEAJQRYMYEAkOT/D+vnnFONEMM0NV0IgYGKWCKVoBrSKRKJ+Iy+CgGQUlKMlVKYEiUlhIjqBkSYImoaJkAIACWVFEopLhlXGOpAAoiwrpsIYAQRQgABBYEiECgulQIQKqRhxrlSSkqMIIYIAAUlVFAIiSGBGAAQRAlQAAgBESYEY0riOAZAGUQnhBiagakmgRoHnh8OCf5MKgBNw8CEIACZUkxwAABAgEBhOyaFBEgghEgCP4lDhADRtTBJRl7oJcJjol7ve/2xhIgljGKStnHRxguTlWo+CwjuRnEUSSaAVDhSjIkEAwYgo5gKwQSXCCOpYM9LTurdIPIQUKaBXENzU05KN/0kZJxFTAxHo1gwILmIfduUGoFQQa5IkiRxFLFoPBx4w5ifdIOxFyihoBKJlEoiyZhOiCAaVyhgzDKcUrF6ctIFSFk6se00gejw4NhPQghVoZArFwuCwcG47ziGbdJub+DHDBmaaVsAiGy27OZTjfrJceNwYWV1dnpe102qo2arbup6rVqVQo5GEdE0hFGpUrKt7GjsA4SUZDpVOtEgpgTTSl7X5Ni1camUh8JwUtmlpRUOmC46lbwx7vdG4yiTzYWC7p+OvVFim0CGXqnkpB2Mw/75M5Wrt66mi4uP9rt//b1f722eLk7Ys0tzOF096fbHow7CcdoI5qaLK6vXH+wFP/jF3rAZzxeBVVlsdAPBZH6i1myNAg/cPqevTNfe/+R+o40vny1NlVLdTmdnuw8lzpq8OHt576iTMoBOwLDbK2QNIJJ0Idfx2L1n43QpTTQlI48LeXjaN03N0UnMGBNC03WWsN7Q6/V7nc5AhEHo+RlH0y26t7dr2c7E5KRAJoz7E9VS/aR5ctqxUy7RwfrjZ+dX5rLlwuPnu92enyRMJkkQBXsHR4ORvzIzWa0Utzc3Hdd13ZJQ5MGjpyenBy7FvX5vemmlUq5OTJYmy/l6/VgQXTNNJSUA4v2PPzo6Ov3il35jPOiXS7Xn6xt3Hz0iWFkUDka9l157+eDg+PGzw2q1gLksZTNUR4gYXCKv34rG9ctXLuu64ZoWhkokPsDka9/4zkfvvnXt5rkzS+fufvzJ8cHRydFBuZg/c+bCxNTC3/7wx7t7nbRjXDy7dPHC5Xaj+e7bH1azduTxD9/9NJutVSYXnj7fsGwbwrh+cJrL2b//e9/OZUr/9v/75xevX52cn/mbv/ox0t16szHo9WZmJxFGp83W6uJyLm9evni+3493dg/9gAEl682+lXKHo5An4f72+sbzx/lcCmC5v7tuwMS1+Ve+/IppOPee7b79/oO5hUUo/JqLZOR//vVbUojv/+zn9boXCsxEQpAxikW7MyBUg1DVT+qNThshnM3nFMGF4iyh6Gh/f2NjUyqcTrlePzg6aQ/8cHJy8uziIhdgY21TKFYo527fvvqFL77yja99w7XsZ882XDt984VXB544bo73dhu9Yb/ZrNfKuf6g/cH7H0RxZOpup5e4rrN89sLS6mrC5cnpYa/brRWLYcQfPn7qpNOXr17tD0aPnz1r1uvt1uFrL13jWq7f89547QXIgydPHz19+CjxuoUcdkyDYCOIeLcblqtTtWoVI9482RsNfQb09z99kCuXv/LGG0srUwLhMAooZp3m7s7OjumW3UweCpBOp+cXV7kQe3snYcQgwuOY2072xz/6xc5eq3583Ki3OOc89gEbllI0qwG/22bKCsZBMaubBjxp1O89eIyE1CTY390p5CedVPn4uCMxuXLtxsXr1w3bdFK5hYU5U9MRlEmizFS2Wp40MEy5acfJvfjiS8vzE1EwdF23lEuXirmJao6FwcFB/bTeo7o7jhPHMVUcTc9NCyE8P5hfOjtZnS7nS8Oed3zaSGfcTD5z+9bnvvSF15rNZr/V6A0bg0HTSumVWslNWSJmjmlMVCYoJVKA/sgDBOkYcsnH48TzhumMzZL42d5RcWppdmJiqVqIg/H+aXPEY4rA0WDkR8nWxrPnTx8V8gU75fAw8n1falZpYubyhfn+8bqMvQsXL+dz2XOTJdNJPXy+k0CZz6TY4OiVFy7ymA379Ws3Xq5WUwf14fUbV1+8sJqRw92to3s7/XKuNOh3X3vlC8W09e//3b+fqlVfe/XavTsffe72xZHvf/cHb09NX3z55s2Nh+8sT5eWVs5/8mjLybgHm9sWVF4k/uoHP5+cnb92fokL+u1vfjOjq+1HdxWIC6XK4UnnRz/+29ufe6VacoPTnYmMPfbjcYhXZxbDwB8Mm5ptQKVVy5NTtdrh7q4Gw5Jrb262//r7P5yq5c+uzBwf1QcjcNgMtg6G8/PVdi/62c/fHSbKC3lahwSAo6GcL8G5WuqTB0ff//n7UHNmarPZdNYg4Gh/Z9DrTVfLv/rFz2ZnZq5dm9vY3M3lyyL0jg53lhanD/Y2BYv3t9eUSIrl9Kd3H01PrazMl9V4MPLYk43Dc+cvLVdR6DPN0A6b/YDBMBJrT+5ePLOQySJq6/m0o2M2GsSNetAYJIho185ldlrA1M3FSiZvw+2jpDMUZ5YmUzTRRLyzE+yedmbmFmSiUCxUHI+CqJh3Wz1w71kzVyqtzmbOL1dbY/nJxkEum6uZ4GCjY5iuo6GUTpbmazqS+3ud1nFnZ6vZGQ5mV5dnJrMkHBcMZKZcOzsZjeK9rcODw6NI2dW5hVqxCKXCKii5nIf9Yb/j+7zZaHQaTSNrT0yU3VTq6Oj0tFHvdrqEqHShMDu/euXKYiVr6BoajkPGSMhjy9Qzbsa13f3DXcsgpVLh6Oio1x9xoAlIh/1+NOrNLcwPgmTsjc8sTQvu98Zxbxw8W9+OoxCl3KUzq+cXy1NmMGz2vH5fiSQYJmu7jZ++/Wyv7ju16bnZuaXpatklJlIAijhREmsSwnESBywJQjYajQWXGsEp2zQN3dF1DWIL6wbVKCKua5cL6VSKUCk0rFTMEFQIYgElojSIWHc0YgnXIbYtYlDCkzgM44EvlCJCIaBUECWeH4cJj4SSktiW7pjEoIhHnLPYsS3b1KmEhk5cw6BKSZaEMWsP/Z4XebFQPDI1jVJNASIlCmPhRUnIYoA1ABQigBAEAAwYG3mBphvVUinv2q5JUibhSnoJ82Pp8WQcxhhptqEVCplS0UnZuk7oOPBavZFkmHAJENeJLpgSChm6AZGUkCEAY8a8cYARiAVTUFFMFJCKM4og5xwKBQgSEEEAEAb/uRgkgKYIwQRD+tkqFihJIYqSJOEKQ4ooVUIKyZOEKyAgUkoqCDEAilAilZRccI6EEIoDBQEXPEoiIRVFgAKkFOCcC4m4EhBCpQAhwI9CxaWUiLNEKYAI1hKRKAQMQhAEgjMpOOexRqkUDGMKMcSQxkmSSAYUUFxKqbiQSmIIMABQQYyhEpwDqaSCnAvJFGM8jkOKoJIKaYjxRErFEp4wJqRECFOqQaIlQioMEUYE4jhiIkkggowrKIUCAACFEJaCCyVZwBhjBCMguIJQAJDEXAGAMSYUYYylFBBAIQFXAmOMEKJYBxhTSm3L0AwCJANSEoCxhiCEGCrHNU1KBBBMKNs2oRJZ13FMI45YEPCYy9N2tzcOA8V1Q1eSmxQ7Oqik6NJkupIxIQRxyFjIlGEkEsaMs4glidSJriNomVgniGg4imV/6Dc64zABGsbltFXKWNmMmbZsJRVCZhyJIPQJQpZJTQ3k804pk0EKcyaiOOmPRwARgbQE4H4/6A6iIJBQKYhpIuDYD6hSBgRJFA79AAieyWTqjVMJcb5QxkgqQHa2d31/nMtkc4V8vliSCh4dHSIBs/kMQkQnGiGGN/YFYGnXdVOpfrM3HA3OnTlbKpV3treHwy5UsJgrWKZdP+k2TnvjURDHvFQpQCierW31u6N0OuM6NiVQAmhoejnvFvJutpCu1cpJJI4bfS+O3vnwaaMXXjxTyRoQS+m6WV+QTx7sbOyPMRIZi5w7P3nlzKJryqla5uzC9GgYvX1n7WcfNaIYXFkwJ0tu83TwfPfYdu3aZM4kyexkZbo8+/Z7T//mZ/scg4tn8ksV67DDj1qAmOZ4ODw4GMzV8I3Lq+vrTw+PwxvncrWyGY9Hg+5YUGDYcL5mH/dxvQXmZrL9fh0jOVktU6p1BvH65p5EsFgs8sQzLUPEQKeqmNHdTFYkiUGUYSCJIY9Q7Iu5qfLqfG4iR1aXFgZ9T0KMqdbu9rresFQs6FQ0Tvu67ZRqtVG3m9ZlbbK8vX34ZKNrODaFwPdjz5dBGK2szJqp3J0PPggTb2pq9rQ5vHv/6Wg8fv3Vz4373SsXz507d6Hda5xdWgiDQS6T7/vs6dPnI8+bKJcJAG9+/cvPnm8EIYuS6MnzNULUhXMzZ1dmDU3L5/LPdg+pnc8Uctm07hrEovYwSNY2dhamq1fPLViW/njteX/UZ4E3NVk7f271Fz/9iR/x3/nON370s5/2+8H2zsH588tXLl98+mzj+bNNKZMvvnHhhRvnZBxFXPzoZ7/O58wXb1/51a9+ZWbSkWG9/9H9VMpZXJp4/uzB5z734vzi/OHB0b/793/2ymuvTs3M/Phn71y7dZsnyPeZbqWOjk4VtYNY7ezsLczPjnu9zc11N53xfY8Qw82lB71OuZI/Oe5ylrz2yisEcFtHqysrizPVl66drRTMR8823rv7pB/wbrPTadartdLqwlTWNnZ29zO5ml4o7u6ctLrjMVPrO0f9UAFIut2O5CyfLlhuut7pm6YVJEnCWbU2efXihYlqzdCsTDE3PTOfy2dmZ6oLi1OWrl+7dmtxdtGgOJe105b20ccf7+wflCdm/Uj99V//6K13P907HQxH0elpL5NNZ9Juu9HQbVwqTZ40ul4gs6lUo95676P7B/unHILLly7kSsXhYFwplSulcr/bbLebXMDj42bGzWpYNHrhoO+/+9av9g8OTNs+d27h937v66Wi2+q2Eq6g0sZ+dP+TxwlHL71w8/qFBS78gRftn0Tv/PrDKB6zID45aScAZl36udvXdY0eHh0rRLj0Ay/wYzk1UbUta2f/hCtKEG12B63uqFSbNG3z9u0rq8tLC0tTk9OFC2fnzp8/c3B48un9zcDz8jl7Yb5WKJQVMkbj0LJ0jPHG3m4Uge29g3ffeff5xvrR0dHJYf358y3Pj4qVnG2aYcx+/rN3j+t1BMSYJZtbm4Ned3KqXC5nKeaGpummLphwU2Yh42iEsCRsHtcTlly8dCZtUQQEQrDZbIRhBCDIFjOd3lB3UrplfXj/kem4X3j1JR75OduYn58bjvqGjimBlpvudFqVqepMbcKyCVMS8ThjkEwqRSj2/NDRNQ2TrZPBj371PiQkZchizhVQ2bo7HnW9GBsEfPmVW43jzsFxRwg0NV3VdPjo+f7mSQ8q/NXPv5ok/tFJAyhZzRu5gtPsdu7eff7yy2+ks7kwGl2/trD2aCvsHk/NVdqDZG3z6Mufu/zixZKFwXHHx4gVi5m7n75/9dptlMrcu//o1RcuZwz04Xu/fPlzr7798fH/69/8yee/+Npv/saNx3feJaa5cxyapv1bX3v5tH5kGinGyC/e+aDT7Byedjud7t//zhcswuNhECZoYnaxVCn+8oNntcna0tJEytbtVOru42cBCydmKqenJ4Ba7XF8fHycTpmFvJlJ2QaVmmNlc6X33/3oxVu3lyaqa5tbn64dvH1vy3bMf/yd63fWuj//aC3lFgwNeMP+9kl9ruysLFZ7vni+3/v+T+7cu/esVnJfeeXW5ETxcHd7bm7K0NB3v/u9SzduFLOZ+uHezSuXmvUTqVQ2l5qdmZuZX9g92FlYWOl7/Pn6+sUzixM1G0B12PTGQ//yfGG/0bfNnGE6B3vHpVLJGwfDXuPCmQV/7FuWM1mwpvLgqMEOB/x0MF6eLfWFsbW1W00bMxPVQtb+8O4uQKqcxkUXVEvwoBlv1ocTUxMOlTxWvjJQ1MqX3c1GsrZdh4G/NGktzGW2jtSzrZNLl8/YDhwOBhOVgtfy41Hj5oX5fEEHGrRtunUwerJ2WC4VF6ZLmmKnjQBB8cLN5YVpXUK41xhuHJwUS5nJcrVayM1MlWo1F1MSxiyTK1FK45gBxaolJ5+3KU656Uy/N/AHXW/sEQDKlQLFQArQHYfBgMs4TqLAcc2J6ekgCWyLVkuFTm8YRGDoRWEQAJFAqXTT7Pb6gItyJm0amp3LxQlv9MZHrXH9tDtTzX3j5ZWLK/lcGmPIAOSE6oGkD3Z7D7aa9dYQQTFdTk8VHV0jXIFIKKEQIGYkUMyZEFJHREgGkTQNBSFARCjJMJQIxUoxAkRK052UQTGmGsZYAaUQhFIqDiDnNJYqSYSj67ZJEABRIseRGPsRxJRQTULAIBomYjCMvTBSSFmaYRkmNbUgYZIpBLluwLQOUhZNWfQ/s98V6nohl8C2DaoSHUPFJdaoYeoEYZlILwgVRLZJTUIgUEEsBl4QJ4kX+jYhBZtMV103ZUZ+EnIwCNjAF+MkUVIRAEoZJ6VjyyYJF+Mobo29oR/5fsgR8HkSR5EUSgiBBAZAaggjAsMwTFiCJFQYEES4FEIIjVKkYQIJVOqzdSrR9DhhQjChgJRSyFgjkFCKEZZAIYkAUkpKIJWhEYIhxp/lbPzZOZ5inTMhuESfvSBs2gZXUkpAiA4BCuIkllxxAYGSPEHqs+aRVApKxrniACOlkBQASSAxBAooKaWu6QhAAKEQgFKqACQEYAgVkEAqjVCia4lkQgjdoAAIiCCEBAKWsIRoREpJEMRYQQQgQVTTheIKQ6w+O/kjoARQUEkhlFRIAQUgAHGUMMEhJvCzVM4TirBCkDGGEE1YxBIBpMIaoZQoAXTDAFJyziGUQAIhuIRIcMmVwhAKnkABJOOEUs65oVEu5NgPozhOGBeSMcYQxEABipBBiRCyN/YlVABAoukYav44kEBxKYM4AVBDAKcsFwKoEeSkcNakM+VCNqVJKQNvLJQUkIxHw3a3G6kE6xpCQjNAOmVnbNO1TFO3Ih4PB76QyrCNbNpNu3o+p7uupUDChQBSRH5sUcvSqaFpBjUggIPRgCU+YxxKRIkuARGCAEL9IAzjEdGpYRiKiziJDVPXDUMAqBhPmVYhn/VHQx5HtYkSpfqgP7r36V3LdM6sLucKmYzrKqAOjo6ikLvpzHjgj4fDIAxOT/bK5XypUAmZ6vS6YRQur5zNZnO7OxuYgqmZ6Vw6Mx4Onj58undY7/V8TaO1WlGnaDTqA6BVq9UgHJ3WT5NI2pYFeOJ5AwgRJPqjtYOP7q5FXIzGvmbBL77+eSM9NRiFXKluf3Rw3EmEXFksLC0WKgXbIKrRrLM4oZq9f3T6ZH3j2eagmLeXLswaunlSPxkO+yvThVrGxok3P3MGaKk7zzYfPGsVM/CVl87Nzk8eHh4+Xd/RKdA1kIy8iSI4t7p61OqtbfiFvGGY2O90u8NejI1Q6MWsVXTJu3ce+BFwUukoSHKuPvJGg/6g2ez7icjnNB4zoWQ6X2wPhmkdVbOkH6mRL3IpE1N7MI4DiceM2xopOvjqmfl2v7W9V3fsQuiPg7FfKZaMTKHdbBSLZrFcAlp6d7e+ujLn5tKf3N82rNS1y1dcS5kG0m3HTaXnqplxRDc2Dy6eO9fpdJ5s7B0cta9dvdZptViSGBQ+e3IHshhQ00/IcaP9+OHj6YnS5QvnIMa3X37x9ODg129/8OXf+PIv33ln5dyFF1+4+YUvvr61thYF3d2Nta2NzXZvsLW2eevqFYiJHyWbW7tnLyzfvn2DhdHhycnC0uL5i2evXLsiWPT8+dPxaHjr5Tfefe9XrpNJInnpymUCwQ++/1fj/rA8UV5anDl/dmHUPV5cWewP/Y3Nwbd+8/eePL7zuS+9cvHizT//4x8rDCfnZp88fJYvToy9oHHc+OGP34oTZjvmv/7X/+v5i2eH49HTte3qVK3eqHd7XsI4QDCddc6uLD55/PzG1bOzM1VKaMhiwFnadaAC6az9wquvHx8fpG3z8oXLG+ub3UEQhtHT+/dLufRvfv3LF8+tHrY6qVzpwsUbSvF+Y+/ajeuUku3t7WEQTM0ttfteP2CW6Vi2nXKMxdlp3TQ++OjhyI929/dOj58nQSRZcP/+J37g7R4evPPO+5/e+2h/f1vT9F/88pfvf/h+s3n06NG9fq8zGgw/+eQuJuL27Rd77f7PfvmBrtnnzp5lTIyCpFAqmbppGUahmLetfKM9fLK+N/LG9db+8clpp5swhomU3c5hr9VgMklYsPH8oZJgYmLm7PLitetXTMc4qndHvSOg9OULL1UXL5022jk3s/bkERJIN92TkyaEYSFHZ5drSeytrT33g+H8XM3NpK/euFmdnN3eWM/nspblBJGEGBqanJud03RSzGVtDY9HnWfrT1rN+sTMXCZd3No5GoxDy7EnJychhN1mfTjoDIa9vc39yGeH9W6376ULlcmpyvLS4kQpV6sUx4OeZZoMSarbxUK5X++2G62UZp0/f3Z1dblcKOi6NVGuHe8fN+qns7PT3/zWF65cWtnbO8xX5qenZjqt4a9++eHh0SnnYtAdbG3v1puDVtePuXJMuDDlzteyk5PVZnvcbnVMDU3Uiovzk1HodzotTGA+4+o6fOft95KA7+0c/+2Pf7y/u1Mplax0CkBy6dyFk+MjJfnS0szeweHuxtY4HI4HHdvRr10+k9KBgbmm2cMgUFA52XS1mGkP/J39YyZIOuViwGYny0uzs4enoyhh1WrtjS9+/ui0u7V/dHx4cv3CpSj2xmF41Or+6sOPK9W5KGF+EHKiTZUzn7t5Zffg+KNPPylU5/qj4cXVxVsXa+3jjaxh+gl/+Hy91+tOljIr887MZGnv5HC6nEsb7N1336nVJrxE3nn33Uze9GO5fXB89uyZu/cO/uX//f/5pS9//faty7kUPrNQe++DO/0w/t3f/Tu5jBEnQbEyvXbYG3nBW79+N4zjmalamPi6YW5u7JpuRrLgb/72o/bAW15cmpvOMQ7e+fhRyDQ/lDMTUzPV7GDQS8JxytBYkhipfDjq9Ubjgy7bODj95ne+Nj2RyVVq3TH/V//qL8qV6r/8v/5XCVPbOzvVXC6XSbWODg5OW7/9W7/5rS9d1XWT6anTgH344Sfdxv6X3nyzPDmZJNF3vvmVjY3m4cHxhStXnq5tN1qt2zdvSagc197fXBsHXqsTDIbJmaWVw/2DvcOtyckyQRpVaO/gwE2VatOlw6P9qXIxjtXuYTdfmtjbPcznUsWy0++eOFRWyllJwWk3PDps+1549tzi2k7QbHYCb3x2dcJNqUFnkMmks462MF3KOaR10um0Wvli0Rei3Qu4UhICw3WO2sNhxGJ/eHYyn3HA8wP/yV4zlcoBLk0DXj5fogr0e42pcillacjUSlP5dje4d3fTj6OJyWohZzx5tN88Osyl0+WCMz9ZolA+fby1tXe4uXU49nm5Njk9McMFb9cb3X4fKRj6Q92EOoWPN497Y1yqTEdxiCnrtBuJYvl8IZ3SI384DIMkURCAXrdv6XYmk7Es7cKVRWrgk3ojCNXxaVdgi8Usb9vVWu243WNKmhoetLt2tqqlcrau9Vvtp0/Wnu83porW8lR5tlKqVPOGrlmmDQD1xsFg5J+cdoajoaVThxCqOOQsiaKYsVEQBFILAfUThQnFCMcRS7iIE55IKRHVzJTiPPLCiAVREEkhIACCq1hyRKFpYAgk1NE44lEswoQTANOuk07bhIAgTnzfV0xSTKJYtXrjQRAOvTjwI4AkxggpgCGIeYIJ1gk1KNEodAw9a1mmjgilLOFBzKVSlOopx9RMGPgjJQQ1NEgUVzL0Q6ygaRBdRzrRmAJjnxGAUpZhW5RCmU/pKVMfhaEAsO/Hnd7Qi1gQhYInFAJbo4ZGhVReooY+jyUJIx4FSRizIGQEG0JyzgWGUCnJhdSprtm6EopLhhD+z0BLDoLQxxggDDDEUiYYA0woApIgKgVQSjIuOGOMJ5hgpKCGCUt4KDhTCkEipGJxhCWIQwaQlJJrGkUIIIKEVCKRBAKCAZNcSYAk0DWdMSEFlFImQkABCdEBBFinQnAuBMJIAYgEZwAQKCWXkgshAcBIEQKjMMIUQwilUhhAy7YxUgBIDLFGKAKYSc6lVEophTVD54xLBZRSEiDFgRQAI6xrBufMCz1EESEIE6qgAghzKUHChOBKSYQRwZAlISSUYCoEgAgBAAFEAEigMNY0TCDGKIoZQEBBaegaAggAxBVHCGOpKDUIxDEXlFKuIAAISkmoljAmhEAYSiEhJpqm67qmBIcICAmDIO4NAyFEyLjnh5hQzpJEKaJZCeeMMUqJY5oYKEqRpZNS2pmeLOkaDLyAJTHAOEmkTBRj0HFSRGKkUNrSTUPHWBKIoyQZRuHYixhAjkXTJk2ZKGXpJtFUxEdDPwZqNPAwxbZrKYwTzodhUD/tc44hpoZtIgwwop4X9QdeMI4QQXnLytm6rlsEIR0BrBATDABlO0aplEOKA8KXFxZEFDfrJ3tHJ9msc+32FWpQFjKp4NrTtfF4XJ6qtJudg6MmpFqcBEuzM9Vqqd3q9Psjw4Bzc3MYk0cPH+omPnNuedwfPnz45NHjzaHH06m86ZgACYzgaOyJRFJDb/Za42F/ZnKyWC77vt/qdwCmJ61gc7c7HEvdtLFU+ZT+9Tdfj8Lezz56ctD1h36sMEqn3TPL5YvLhaIjUo5JIOr0hqMQHraDe4+PwgguTTvz09nED/dOBzGDF1Zzy1XdAsP52ZVQ4u//6tmnz3r5HHjpcnYqJ/cOjj/ZUZpimYyBlagW9LOzBW88Xt8+lRqEMFGMOW5p7NPuIOIyLpbz7Va/cRRMTKYCDsIQBokM4zhXqvhj3zBMolRKU1Cg+oDv1f1czigicVT3JAO1SqbbaTeHcXPAun2hEem6ZhgHT57sp7MZJhkGcnE2W0nhRp+3Wr6lq2LWPa4fVarZcqXy9OFTLmTEmE5R2jVrk1WdaICHt64uH3SGSNMnSgXFha1p8/MLEArDUH4Y9oejc8uLszNzn9x/fufhVhDwC8u1V1+85KYMN5fe23z+6N4nX/rCy+3OmBJ64+qlRLLjk+PNre1+f5QrZK7fvORH8R/93tXZ2am33rvf92ID8ZdvXb7/6MFJs2sbmkng+TPnH66t/dXP167ffOH2S6/tbu+sr+9pph4FgYj4/sHJmbMXStWSDjGQPPDGL7zwim1l/qf/+fuvv/G5dz/+CCi8cv7af/wP/yGlw9/59jebx6fHrUGjPz7t9O49XMdU+4M/+gdPHz9eWlzs9qOP7j0pTZUOT3pHR80w5lMTRcV9BZK33vrljcsrC1MVf9x1XN2yUtO12tVzc1sbzy9dvXR4ePp847hYmnzvvQ+erdcN0wJKT7mppYVKwSYGVkJyM5373t/85PGj7TMrM95geHR4aBK8srwsFGq02hhjXUMUSShEFIetZjtluxqh0+XUF25fmpia0Ij6xje+OD01eXhUv3Dp3JfefO0bX/uyHwVKkXMXViulwtR0rVStPn66GSeiVCg9X9/82VufGiadmZvKmnhpOv3Vz79o6hgTks+6lpUKmNg76q2s/P9Y+s9mXbMzP+xb6c5PzmnndPY+OXZOABo5TuBwRiTlkWnZVVSV/crlL+Aq27JKKpdLtqpoWfSIpGY4nIABMEA30OhudPfp090n7312zvvJ+XnuuKJfgB9hvVt1Xf/r/1u4cWXB1mIYW5aTCCiPfGpbztrKSjqRCCN3aXkRATWdjncPD6WK/EB9eH8noYnabO3Bs5Pffrw1HCN34k2Gk95gXL/oAciWluZmKqmN1bnZWhnpesBEwjEWatnI6wkhj08a/dHItM0X23udEds7unB0DEQQ+u6lS6u12VkNCKloJV/M54tuwD0vsi0znU8+e75zdNbC2EqnCwenzc8eHZy3ot5UDX1fUW88GZ3Ve0+ePA18v5DNEEh2j4500/zhj791987arbtXlmZrpkaCwJ+Mh4mUnsrkGvX2yckBc4fr62vTaXhydmHp5Gtff23j5koQMtf3HScjiXlcH5+3e5FUpmXUKhkDA4JgbzSaegrreszUZ2qFTNKKx0yM0XTce+O1G4VMkkp2eWPFjlv5XDaeSRzunPz6w08fPPhypjJLORt7o6997bW5xdm1pYVqqdI4O9OxSpuKqEAIHwD9pN4LmUrHnJlSbhLyeKpQKGSr2cT50Z6Tii8slO9/sfPL9z/SY46VTkUCKqI1u80rKzOVVOy8MTrt+FunF1CpZ9sn93fb/f7o2pW1f/5PfvDLXz//1Ye/KxQW3cGgkIoXklY+m8mkjVZv8MFnX0XSvHZto2iJ47PJ8dHh97/5WjpvH5ydc4Zz+bznBa3eWFAdYrq4mPr4Uf3/+a//plBdjenRP/+Td2zH+Yt/+Orf/NXf37q9bmF2//7D/Nz6/MIcZ+CDT7+6GIadrhtOu+Ws0To9ny0WPQZ+9dke0fG1WnWxZIwjuHvWunrtdutk6/a1pVo1O/XcdCoTSbh33L1zfbXb9Z4d9f/m73/TbVyszNa+/tK1P/z+20sryf/H//D3X3z5xR//wXe45KE03/7W1zfmC3//0dYX9z999VotqwvX55pd1mOZxsXF++//KmZaz59tMqH/wU++sfnkabGUTTh4//iE2OmIqmq+mM7FXd8LBXjy9EU6m8ukEmfnTWQ4q8uzE3fauujVJ+ry6rzgIQ3G8/Ol3YPhRXuKzWS/3ZgtlxYKGQuG4+nIScY7Q28ayJ39xqsbJcsGw4C6glwcnyzMpD2fQ4gIgpqipbxeypLRcCqwnkylWMRbYzwZBToQ00A1xyLixNb4UpFAAh7tD87HYXfoHZ00IILFfAxC1O02YpZNpLChWl9KIgTOWpNBwC8vV966XRyNA59G7mTU6zQySSufS+RKaTuT++Rh/ZP7m5oFXn35ZiwZ55wryQzNcEfjXLZomeSTz7c7w6BaW6wWU7FUotceAsVq5YyhqfFg1B37lDMNk3qzaTnJTCqVzaReuXs98IZbLw6InTnvDELqWzpfW17xOWj03WImD1h4Xm9NApqIx2ulVMjB03p4fnahFIdYcEpjtp7K2vk0ySetVCrBAaj3vI4bIQLiju5gEbP0GEEIQz/wAiqnlPmB9ALhhQIiEXEQUDn1w9E0oBRKrPu/jzwDRTDRCNE1Q4SRjoClk8inEQNDKkfTKORAKGEQnLB1x4mZGoCCWYZhmJoAKKQokmDssYgKBaWSHGDMJWBMBkxOA09KJBQkmkjHrFzKjNs6AIIJKKDUAE+Zes6xCJYaUA5BlqFRwbpjV9etmB1DkCmAfCYnAaVA2qajQ0A4S8fMhK3zkHKJpj6NGBVc0JAbmmERmE4YCIkwDIcR7/THXkgBwpTSkEeRkAJIIZEbhhIC0zA454JyRoVSUHAeBUxCoDBEmGhYBxJyIKREGCApFBdCAAkQlAhSGimMEdE0rCmpJBSmjglAQChKI4wh0XQBIEQgDAMFIGcUKMBCKqSUCvCICwEIIhApAYESQnABAEQaQUAhrEIWMc4AgIZmEKAJJhCGSAGFMNSIBhDQdIIxURBRxiCEAGKpJNKIQopFEUAIKPSfsktAYaIhABAGEEEsIQCKaAhrSMNII0QjGuMipAwqgiFUTAohlFAAYyZCCIEAikCIEAIQRRGTACghhJACKCaY4EwjmAtFNB0AEDHJGdcwFlIpjqgSCGOllFRIckY5iyLfo2Hg+24QYYTCiPohlYJLIRFBUkImpFJCUIEgFFxJIZkUPpMSYqU0FlEICWd06vueF4ynU8oEAAoBKaKABj5UKpk047pm6RBCIBWREE0DMQ6ikIcYIYAgUMrSUNwyDA0ZGBFNH0z84cjzKNQItnScTWqWgSPfHw5Hw7FHJeIU6KaFCBmOh+PxeOpFVCjNRHHbMg0LY8kp9cMpl1QzsWFqaSdRyNmGgUfjviAQEX3iTdxIEJ0QiGkwARDM1WYZlK32eX/QzxSzr7x0W7Ao9Hwp2dHRiTsep9JxRlm3P7ItCxGyuLhQrpTOzi7c6aQ6U61USpKL/e2deMyaqc2dHp0eHZzwQBpaPJPJRf40CiZOzAwljyIJiSOEtHV7ZXkDGfj89HA4duPpquujVnvguj6U0raNfM5eXiwdHz3/9XsPCRNxK2aZSYwRC/pJC6Qdmk0XgBB7h/Xj88CTxmnbx9icXVzSDW1/66zT63IFcvn4UrVk6iyeTLeG4W9++3QwlotL+VeuLS0WrEm9ft6YTqCdTDoSQYKwDsFk3D+rN6YcKQALMatSzl30WvVuEFCadrSUkzyptyGhWjLdaI9cqnQMc5lkfzAkRpJAaWmqUi5TTprdiTJQMlXwgsl4GpRn4kLp9c6kN+WajRdnrIW5GSHQQXPKAakUC+PRKJFOm5buTTqjiavpSEG91WmcnZ4butnreSyMEqZ29dJcNp85OWv4nntxenDj+gbg4v7nj21L6/ZauVwsCILDwz3HjGfTWYjEd7/9rhNPnba6z7a2Jefr1y5dv37teH9XAWaaVrM7eumlly9dWfvw0w9v37lTr591GheFdKFQqL75xls//MMfXZwdpVOxd155+W/+9j9m8yWNxDLpeP1wV4oonc1BxS5tXHm6uffe+w+/8drqa2+89r/85b//4nefF/OV0dA1bSOiXqFU8CN2eHQqOAUyCibTbm/0r//iL6rFmISR74dTSv7f/9+/TCecP/nDdywb7R4cZAuljbW1yaCra+AHP/j2zuHB093TRm/QaHdK1RpVhittK5Gdr2WaZwcIyJdvvlwqFlaWF549f1IuFgKFDw6ObUP/1c8/sEwCAGg2zmoztd40PGsOlxazV69eMwyDhnTQ79bPD8bugHH59Mnzw+POt959rZjPDEOayiYVhD6NLurt0cjTsUrZBEkacxJcoZEXWSZMWfJb33j99Vfulio5x7FbzdaTp09WlhcXZmZVGO7svNh9cZgpVoqlaiYTTyYTGGg3rl6fq9a6g+DJw61r15ffeufr7XZHqTBmktOz/ZOjI90yzxv18/Pzw70T0zQT8fjp0YuHm2fTSTDuN5Gil69dTyTy543TTueiWq5VZmdTmXzouZmUlUpn9g6P7Xj6pXsv9Ya93b3jo5Nuu9ODmqGZiZnZlfWVlfnZuVw2mcqkh/3OYDiwDatSXkBSHG4/G/XrpqE2rt5IZXOlTOry6kw6W2FCW1lZsg3ts/tfXbT8dCL/ve9+XQr78Oz84Pi8fdFKJTM8CDUNXb+++vV3v55IZs8vLvKFMjKd09b44dN9SUEqWww5BFqCMmRaTjyVnFtccqf+k2cPx6Nuf9jrd9ubu3tfPXxCKZTEPNjZR0i/cfNlJsDjp8/G0/7G5UuHh8dPHz7a292mPie2GVKYK2bteMLUnUSqEDIwnESuK3yOeuNACKve7Bwf1z/6+LPJcLC8OJvNpEzdPD07vXFt49133zQBK2QScd08Oj2NJLh0ffWVuzfmZhemU2/10ur50eHz548RlsPhdH/vhR7LOHb8zt3bV5fLGZsABDqjsNkccKF0JBKm/uirL5qnBzYB2Vxma2vfipsbG4t7R63Hz3Y0BevtnhIqZRuD9slPvvvWnVsbAuAojDKlWeJk+mPxeK/3y3/85Xyl+v3v3ts96T/b3I84iMf044uREYtdW1nLpRx/4pN4bOyGpyeHV195x3Dsraeb337z3mwm5XlDpRmX1tanE7V9erC0tNTuDGsz1ce7Z0ft3tHm9q/+4R//2T//07WluYNT9/5HX/5v/+V/cW298virJ45Ovvvj73AGBMJGOl2aWbx9+9ZCLZ3LxpeXq5Vi/sFnD9r9diyeTsadVnvMRFBMmOPGQTFlakTzfR6PpU6PL7q9/ne+/72rq8uh0D57+DTyRs8/+3Vz94ubV6/fvHP1L//2s/r+9ku3rv36owe/+PlvlhYvVWfmT05b2YT11mvr3rD15aOvKDQW1q/XatXRqOkG9OmLbYBwIqHFbHTjxupJMzo5r3PKnj19fO3y8u2r12OJRGcQtnudQrEUc9JPN/cqpVS5QBp9dt51CQ/mc4lOt2/EbDOh7x10o8gMJrI3GKbj4ur6Ui6VvOhOp6HouaIXkDhqrM3FTrpMdxwIIRRSRLTZDiE2Az8iCMZsmEo6Ihov1LK5hAo5gkhJqexssjGUne7IJCxroplKQunmyOWDqehOVXssfa6nHFLKJ2O6imlCC6YmEnNzWYnR7nH78bNdaJrJdNqnoLqwury6YuuGDsnZSZvoxuUrawQZzx7tnew/y+Xs1bWlWDKRzGYgglzJV16+Uys7e/uH58fnAOlLMwsz89XTk9Pp1F9ZWYunclHAJICxRFxG0eHBroJqOBpUSrlvvnPPdKwoYsPOmGhGqzNoNS9u3b7U6nov9k5q82sa0U8Ohxf1trD0THWWaNpJD551pmEkeeRPhsPI75mYpyzs6DKTSph2YjT1ehNKNMOwHCtmJxJmNhazMQKKBTSaRBEFBGCEkGYSDUDg+tHECyQ2FNKk/E9l/BgIHUkdSoXA72e8AKGQiYjxCRfdoTsZeUIICHjCJIZmQKKUiCwCbAx9GvlMTiPuUaEUtE0zZju6BsMo4lISQLhSAEo/YCxiGgSWoREgQj9UQDIpHMNIJZyYSZIJ07YJgkrXnKkbdMZTggjGMApCLuF04o+n04DTVCpTyqeTDs6nHMNAVCkBoCJE120rniA60S0zm0pUc+mUoydsi0vBBI+YIERXEk0mk4gKqYRpmYoB3w+jkDPOEIFKCIUI1rEQjAYBozxk1BeBoFwKroCSSggIaBhpWCMKE4CUBIJzLiUTTCgANSK4UkBgBASXQggIgK7rmmYKyYUCQghNJwRhqRTQCISKMgYUhABwKQ1LR0BIpRDBXCmEICYESMWZ4IpTTpVQSCO6EkIiBBHmVCmlMIS6YRDDgEoJLmTICdYJ1rBCQCqMIcRQCoEAUhjSMOA0ijiHAAEBlIQBDQQUEAiDQII1BaRtWgorhJUEjEUhUBgjFDBGf98ZCpRCSEoAgZRCaEQDAEEMhQIISclDKRXRCIJQ0zDRMIZQCAGgkkJJITiEiguMMIFIYaQU9EM/CEMlgVQSQESZEkDohkUIMRydSgURoIyHVI6nQRAGAEqloKZJoaSEEGt2FAqMMdGJpkGuJIfQIEAHPGGhdMxWQCqMJz6LfKaUQphELEJcmDomCOpI2jpCSOuP3anPucQ6IRpS2ZiRsHQluMQ6lYhhwBlzx/5gOvGCaBoyyqCUkEiVS8QsmyCM2t1xxIWmmQnHjhsGFBGANIhE9Pt2TGl0x34oYMwxleLTMBBClmrV6SQ82DtFxFxeXL55bR0SHLheFEbt9mA87Jeq1WQ8HngesXVDU+lkzHFiz7b3XDeozFctQ9QvuucXZ0iD2Vx2OpkGbqTrJiCaUJz600j4xWImbjqAIybheBx605Gu6/3+4OLshOg4ly+PR+H2YVOHoJo380U7lzZTydh0Mm6ddudq9uVLKccBo3GXS3FlfWG+Wj7c7zzaOtg+anVGBMeT4wB77iRTSGoY9bqeslAqRi4vxu9dmfUCtzHiB53ol7/bGwbi1bvFxQzWCW2P+GfPJn2PO4YGoOIhIwYeTf1IgFg6yQJWyOi1mWyn252MEAKgmFCvXFtkUdToqWwMB9OoNfRjJqwU446G213XTsQDz004pk9Zp98PJ95M0kEQtYZB5EexWKo3GkmFatnkQk6vZAkCcr8+PDidzFSLSniRP9HNuOsyjWiGjkOJdDPVqI+mrpQACBDZMS0W59/95itb2zun9XG5tlAupjKpxO8+fxwj9N133758/W5vGO3uXizML2Zy6cPDnR9+/5utdvunv/jklx88xFj7yY/e0ZD84NMHYRQk4snNzX3GdAjN33zwgY7A6qWF6WSwtLQRhB7CZHF17f/2f//vf/PxYO3S8vbTHcCB67rNTl1APPXdfDZVreRL6fLO3un93316eX3mez/83n/z3/53GsLf/u43fG8MhNI1DUKQjNne1I3HkxBJ3514oadpibOTTi5vXV1dzJdyzeFYMQEwWllZOj06Or9ovXz70nI1Xcnb19ZXQt99+vio5woznq7NLu4dHrt+5Ebm3sHp8nzi669fv3vrRn88GI79F7v7XJlDDz55uD03N3vS6MWy2a9/4+ubT57W8mklwidPNxWG9+68fLrzNKYpxhnn1nljenLSurQ4v1hO/G/+xXdSMfL8ydNccanZHLU6Q4+BsRc6tpNNpKAUggW6hiOG3NAv5eKv3b4S+f6nXzw5OtgzdJsLlUym8tn05vMnRwdH5+eNi2a93+oM2t362cXu1papw1an1ex0R0MaT6ar5bnzs9Z0Qs/OO+3eZDwerq6vjsajo+PTxZX5116/kc2Y7nQ8HEkzudAf9nQQvvP2K4OR+6v3PmCBXF9bxaZzfFpnTEw9X0D0YmvPH7u3b24ctINGY/C1l9feuF0p5IzRdOyFdHPrKTAiIenvPvnq0cOjTpcqqXqd7ng4dOxsvpitFdOKUncytTWytJhfXSnt7+5t750eHB4lYzaAoD0Ijy/a0/HESWXr5z3K4Nib6has1QrpRLJWm41C8fDZzmTq26bmjbs3r8y9++Y1LMc7++cn523fp0GEBCTdXm/QbeWz6bOT7rPt7ZBThRTC5ovd1pPnu6NeWyAtovzFzn4yni7PzHp+ODdfe+u1e5qG/UAgjPrdHlRRvdcbj4cIQwBczsO9095//PWLhweD9hgUcmkE1EV76kZ4+6BBOWJcUcrn5+aaF00nqRXydiJJXn7lbj5XapycGQDbMTuZTgjONp9vrSytFYtlbzrRLYINezAKtw+PG616zDLnS1kTS6aE0MiEwuZgslDM3r06pwPaOD8jxIzFctPhRNP1tbVau103TbUymx31m5lU3DKtp1/ef+fWatFSk8HYDbhQMB6zWq7Y2hv+27/+6zdee/XK2uLu3vn23sH67Zfj2ezms2cxE758ZT2etNqdKULGdBT+7Je/mb98r+eyXqv15ivrxaLV6nXdKPrau+8cHY3cyXhpsXZx2ryycfnKpYX5S0ufPG6dH5/duVq+tJh9cd79xa8/+5Mf/3gxLz/5fCfyg5dvX0sYaDwcvziuA4jHzboIPEAnhYwZi8db3amhk0TcAAA9fbI1t3LJsoxJr6MYhQiaGH3/3TuPd3qffPj+1fXyu1+7+WznTBmZH//k2+HE+9kvPosAe+PVG083j2Zr+Vs3ax8/br23NdCQ2j0629pvLMzP/Is/eiNtyEZ7tH3YV0CuLCzkUzEJMUQo9P297Rff+va3L20snpwcp7O5mGV/+PEDM2aur65CCPYPDwDQ06msgcHjxw/XVq9QBvpTjqJusVJmALKIzdUqk0l0fN5WhgMhHPYHSgTvvn03a8OAg3rfm3BLF3SukjIQ8F3KMbEdPZcxW6NpwGgynRz7rOsjiXWkoC0mt9bLjiEUkc1RwKSRymdOWqGUZiKuO8qfSYGYgxjC9SHeroftARUKpROWSWS1mCpXM4pGKhovl2JpG+411OMXDd0m+0fjj786fnEeQCu7uFpcmUtztz/otSrV3KXVCuOyddbt9gecRf1RX0jiTsamzl+6ubY4mz096bz38dNGpztTTM/PF1udbrc3zmcS8ZQ17E/DIFzf2FCCn583oohPx6NqNbe8sqCjSLJppBxk5Z9uPVnKGFeW84+P1GFvfH2t7Gjg6WG411YYwYUEdyFp9sJxEAmhcwE9l/OQcMl4FFkEJW1i67of0vPWZBKKIJKUQh2rUtrJxc2YgSQTruthTAyCMkktZdsYIcmhz0UYcQw1w7QI0Q1iWhoxNagRQimTElg61nUEEOYKUYDciE29KGLSdX1GecggDbmpaTpBGgZeyPxQMgoYFUQBC8OErjumaRsa0XTFORdUQuwGbOSFigtIdKgRKSQWCgCKpdCxrgGABdUwVkoAw/T8yAt9Q9NjMZNFETEwQLg38objCdQ0x9Z1IFOOTjQsQha4oRf4nEs/jNypxyIaM8hyJV9KomRMxxjRiHphiBHSDUPXNCEkVwAQjUuOiZJCQQAhJIpzBRDnEkOMNQAg0ICGCTJ0E0GkY80kJiZISAgQEgAoKQhEGAENI6ikFBRAhTCByMAaAQBRxgLfj6gAkiBMOOMIIim5EIJFkQAIYxRx9ntxS0koJARCsZByJllABYdMCoikjoATMyEGSCKAtN9bXxIC8PuGHwgB832ogKGZum1wRqUSAAgFOeNcScCYlEJgqXTTthxbAikBlFJJJgg2lFJCCqmUVIwDxRgHUnk+h1hDCBEEJVQEE8a5lIorSTDBCCmkIIJSSACg5FJKQRWgHAjJJOdAqYgJBRQVTHAJoEIYWpapIU23Y0gBpYCpGaZlYIR1Q9cMnRAtiCLKBQYYSQakYowpAaKQB1wqABHEmqkrqEzbMjTd1IhtGgpyQyNx28RAQYwQRsm4mcrGcvlMKpOiLIwiNhpNBkPP44xGHCMtbdvxZMxyNAi5YZkKaV4UDgbTgPIoDAxDFtN2JmMjqBQEUgAmFY04QAggaNoxADVTs03TRkgVsvFEwok8OnEnEOnYMA2iQ4IjJpSCGBMh4NSNQoFa48HUCyzDjMcdxXkqac8vLxydnB+fn166vLK4NOvECUCg3xuISCqBfHeSyeWqMzWMjMZFCymZLeRzhdzxWT3wolql5ujGsD/0Q0Ypz+WyUKnJaOS60XAwGg+HCMtYwpqpzTrxlB+y4bjnexNDB8m4ySVXGBcL87adGw2m3d64WoxfvrE0PzeXcJJ+4Jk2zqSdjcurS9fuaCRuITVbSt1cX+Wc33+8OVVYmoVpaA+DsNXujroXyzMZTcnzk3NNx6V8NhM3lsppv9c83j+PQKI+UADJq+tFG4Bw6o59uXUy4XEjnoxTOh4N/Gw6hTRdARTPFabTEUGgUqp1LtqTceBTAQnIpAyNiG5vOFU2xEgJAQFLpmxOZac/ZhHwozBm42KlEniq2wW5JJ4p5TAGHjccWwY0mo5ZQHnAw/EosIjOWHB81oslYulMPBgPKsUkBpIYumaa45EnheTUxYhfu1ybnV/gHBLdvrK+vrO9+eGHn73+5uuW5giuOr1u4LKrG/nl1dWHT56+//4X9+6uvvTK7aHvZoqVw8PDL794wJnKZXNvv/FKOB18+eCLjfWb3/zuD5qt4eOHz3zP88LAgPy733zL1iwMJFCg0x3Nz1V//vc/Pz+u/6s/f/XdN++OQt2jkvGgVMo7idRg4DqWnk8nNg/23//gQSKRXazNvffeP0YT70d/9JNet9tqNe7cuxZxpTSNK9BoN5jvrm5cmp+defuNdyIgHNvMZXOu2/eGAxaIfuPk5p3XRoH8v/zXf7cxV5srV57vbJKYc3iwP5oEg/EknS5Trn732RfrCzPrtaLXP1ucif/oxz/2x5MPP/htu90iOpEQtAfTZqtTrtQEMOrd/uLyygcffEKFWlxcbnd6REO3b91qNE8ZDzK5pABgwsBUokQmu7o4l0s701FPcO770WdfbO6fNrKFWrM9GEUM6poiyA2oacYMTet0mjGk8qnU8fHh3//svdPGsFTM6pYxmgRYR2EwSmfj12/cuHHjxve+9Y1YIq5h4nlBMVcOIjEZTTlVzeYFAOLR06++fPDQo6EeyxSqS5WFq91BUD9v5Suzhm7V6+fuZIyBYdhpCWC3N7x+72YYBEf7O1jTV9YX05ns4c7e+clpq9Nau7SOUDyU2tLKfPN4/+DgGGvK0sXaQuXezUu5fGoynszMVmOGNZkMs/l0oZjzAhdhvHF5Iwzdo/O2rsUL5eLlK5cpm+wdbF2c7cdiMQx4JpOulirFbLJazo3Hw4ODs/ff+13jfC+RjefK2YXluUI+Uz8/+viTTz/79NPfffhxwkLXr1+qldPf/86ri/OZRFwvF/OGGeu0+uf1o2whVS7mep2Lfq/z5tff+ad/9ocvv/J2ubKQzuTn56vf/t43/+iPf3Lzzm1NA+lMjGDWaJwHHu106g8ffMZEVKzNLa6up9JZQ9MzmbRJCEA4njRtI55wkuWZaq6YbXaHO0dn+42uJ4xxIANqHjf9Tz5/Ph75zcbpaDDaPTjsntY1wzrY2dl89lU6E3Ni+mg6ZSx6/OyRZWmzs3NHR4emRpbmFjUA58rZ0cRrNTtJ29FUOJNLFtMJpITvMV/IgRfUO+2FxcUf/vjH3/jOtyB1+aQrAOv3m+Vq6d6d69W5hcGwXy6VJxNvaeVauz/qNE6WZjOWiQJBDUMfdhuJTDI/P7N52PqHf3z/1s3r927OToPp1rPn9+5ePzo9v2i1m4NhKlfZ3tpPONr33r1jwPDnP/2plSo2WqOLi9atG9fnq7nTvR1/1PrO25c+/u1nL9+7l7BJIaXt7m8OJm5uNoeQrDqonDUrlZlPP3/6+MmjN1+7t7pa3N56Ou71derH487ZRefBo+1kvjxbjhHmd/udfG1BWvFWp1XJWtevX6p3+UnHu3b3rdmZuVjMHo2nz/f2a9X0jds3Xc/7x5990O0MK3Nz73/6ZWfs/tn/6j+7dW1+2G553nBxLv3gk89uXb++MJve2j+8trpsJZJ/94+f/eLDzwV3//RPfxgF0629k+3t89EktOz4+fnJZDi4emXjcP9o6+nW1999pX5ar58f50rzADm724cxRy0uxPvdYOCPEwl9aSY77gemY2xcq7a7zWZvaiTihXz5/PRMwyyT1Y8a3vZRPVus6YnYZ/f3DSy+8dqlGAIS4c2zSXPIstlCwjaEkgKik4t+Kl8oFeNHx2PfC/wAHpxHx43xNKDeNKyl9LduzSQxJBpqNLoKcETAs4NzaadK+VRG41WLVJKG4Lw7mQ784KLhdvu02XUPz3tCt2uLi5iI6WRy5er6vZeWECYi8i8vp9xptH9w8cn97UePdpmCsWQiYaGL01PfY5lsLT9XQwojAguFYiqZ8Pzh4f7zdu8smUyuX9moVsonRye9QefK9Sszs+V2u+G6QwOBRCJRPz1tdZqXLq9PJmOETSeRsB2tmIvbOrBs6/DkHBuJyURdnJ+9du9KqWZ88uAkCOibr8wYJthtDE8bI1tFczlHKNUfUo9yZMWxEQuEHI4plQooFddY3IKOQaCud8fTyXg68YKpz1yGYpaVTyUzKQcjoThVghLBUw7KxXXTBFKEGDLBqeA0CH0v9AkimqbrEGkIcMZ0Ddg6VCIMPR8gqFkOlSpgylcgEgAQHEHIpLR0PZeMx22TUjrxAokxV8zAyNQ1x8AEcIilrmsQIMkiTBCCCFsmRkgwrhTEugmh5lFGkHBMPe5YccvQdahDzLnsDqcBVQhh09Q8LxISEU2jlPqTCSGGbaCYrRUzacPWw4gFTLquF4Y0jBiXStOxrqFM0lmolAwEEIRMqEAIJjjGOsSKBqFkFBsmRBhhIIRAUHEhEFAAICa44EJKAJSSCnLFKWVMUCE4i5iUjAOFIGCKSwWEkARpBGMEiYIQKCgklUpKoIiuSQF0BAFSnHGICWNUAaQZRDM0DFUYRRhjjAmVTEqOENAJgUgBKBDWFBAAYCEAZVxxJIREkAkAoJASIox1qAAnGHMmNdMQSnHAOWMKAcklwphAomk6RBgRJIDiXNAwRAJYhiEU55wDDAVnQAIAEEAII6QpyRVHBGMNAKkIgYwrFUkhOEYYSIAVgQooKZDSBRdCMCWZaZlAcAKkpSOkMABAAAERBgwQjImmM6GkUEAIhASEgiNlWyaBkDPBlIqE1AwjpDzkHBMlFZBSQaAQR7pObNvUNCSkBIRMPT+i3DEgJijyWUhpOu7MV+L5rFEtxk2slsrpuK1hIUUYDgbj0SRq9b2RC6YhC5gkUIM8sGxDg4oz7lg2UNDzvMEoZAomHL2cTZUyCceEFkIpRzd1HIUhhhrCmIWMGIaSMgwpQEBEfiJGnLg99CbdycQPgJBwPHH7njf1acB4xJQfcSwlsUyXShGGhYReLCT6Q9cT8sr6ev3krHVRf+fNV5Mxh9NIw2TYnWIhXEZDTmMJe6ZUZGFwcHBMGZ+dr1o2fv5sSzI+Nz9DsOi024Ou647q+XwRQmN376zR6hANC6XiyUS5mIsn4pLgVn/YbDZtU5+bKS8sFGrVWq1StMzEeWe4c3DquqOVxdTy8qLHwJc7x9t7p+WsfW11zrQTg2lwuLO925KPXnQyNhBB62D/VOFy37OfbJ61OqO8Q/IJ3XEcoaxBp2tpzDR15vaymRjQ4+dD3+dwMJxIv786m4MSXvRHKJ5qd0d+4OsQTceubeiLc4mkA71uPx4zoARK4piuN9qDSSRcQcZ+pGsQKuQPe27g9yIaczSDUAujuGO2+t7Q54JAS4n5SoZosj3u6jrIpMxy3m4PhsdjsJAjui5bHgOmGQTcieO1S2tnzVarxW9eWRRCuUwaRoxF/WqpeN7st3o+seKCiYWZUj4V3z85Ozg+ScaTjc742dbxjatLlVJye+dZb+zlchXKp7c3No73dv/up+//5A++fuPaugqHfq8dTPnW7gWy0svL5a+9eUNy+nRz3zS0b3ztduCjLx49S6dSuYJtEPGDH/8RFeT+54+ebe4eHh+NhtPtvUPdJv+v//G/NnS2+eSrr3bqQgGiRDJhV0s513NjVvzRk2dUmqZjmDZRivvjyX/+5/+82zgD3L1666Y/dc+PdmUEW80hp1GllN5YW8oWcxet5qDbXlueGY8GCBGkEUDB/+n/8GdvvXPvv//X/+7H37nyT//Znx4e71DKqI8Kcxt7h8fNdieXcZ48PpybKS+vrfS7J1+7k7mxPtNudT599CKRTs3NzOYy2WGv42C2UE0Hbr9+flAo5BuNfr05vnv7zs8+fwaIeefyGg8nnXYzV6q2Wj1fgOPWaGvrGPEAgeirZ9tKdzyhY6Vcf7y0tjrxqRdQArHv08nER1jZybjv99ZqsT/+0buhYL/89Oz63et/+v2X0unSzs5zoUIhBdGtWDw1HI80Dc3M1ggILy6O5hfnJwH95NMHWNOFitZWK0uLs7ZtfP2br1drNcb4Z/fvP/rq8VmjNbOw3OoO//H9+zGrENHoxd5hLBF3rOjV1146PTrdPzpeWFl0Enav1dzZ2pQymJ+tZXPZicebvbFgjBh2upT55hvXrl27RCXYPzrmYZCJYUtT6VT6+PSk0+ot1WZyhbjhaJBotm0Puu2Hz/be++h5o9GJaWK+FHvp9jXGjcPj1trynKWzRq/uC1iulZfmUokYKs8t39hYNjXkjcfZVGZhfrE8s7C6snL3zrU/+OPvpTMGAYGpq2wqwTifusFgGi0spr/3g2/cuX0rU8xcu3ftrW9+k0O4u7MDoew0Gv7UhViLeKRkf+vZlzT01y5d7rRat29cXt9YSSbjG5c25ufner1B6AeMThDm+Xwp8kME5FwpTwBTSgnAYzqbScPXb81fXq7U672WJzr9aaM/Hrry4LQ/9unqpUsQgtGEvtg54gyXyrXRcNhqncTi6W5/Wq/35+YWdQOP+u1isXRWH+yfnsXi2upKeaY62273oyhI2HoU9AxAbUuPFIrcSSmb67ns5x88/OTBA+GP52vJ77x7PW8b3Z67ubN3dlqvpGNJ0/zqyU5I7J29HRqJ3jiYTqO4Y4XTCRNiNI286difuDMzC48eH33w0SdWovCNN187PXhhSu/G5Zn7z1sPtntnA764OMPD/vpC+U/+yR9lTTQ43XdSmQdPj5892V2YnavNltsXB299/Y1s0uo0TjIp03XHM/mZcDBWlLfP9glRKzOVcacx9tnm8bGrtEq+oJtOe+oZqVwtny3GSKPe3u6IYjp26+ZN24kfHe7mYoaG0cHuWSaVXVstf/DRk/c/+DhbntU0WK7kQ8r/9r2HxZj3zjsvI8fY3N4XgSCY/PbDx88e7r75yq1SEs5WYz/64Y/8IPSm3tpMumKEB3t7pWKFa4Ay9NkXu4jyf/q9NwAX00AMAjgNg6XZshJ+q9nN5vPH+/sW7dy8uvRsr9EYiUQm32nUzw/PXrrzRqla7Da7GApL1w0dX3Rc0zBSWHz+3GsMgsVLc4oqMRnl8kWGwUmXdlv1XDoXS+qbL/auzGVuzAAdC1c6x1snTrowpZSxaGlmZjiUmwcX5VK1Vo6FlJXKOYhgZ6IOe1HbDxtTcals13JGyYG2CV0/MGKGG6jN3ZbPzWI5n01oxay2umhZCE7GdEDRec8TsWLfw0+fH7c7g4XllXQuV69fJGz86s3FfMLMOvIbry+vrxQyucxUxg/rYb/v20RLWObR+fC43p50+gSFtqnTwE3l40tLK0hPjEfi9LxFeTBfysWTidPTXuOsWarklleqQnGIUDYVL5aKJ6dnNFB2pnR4eBx6Pg3YTMVMF6oBsQCPLC0iAHzx5ILosXfvLsch/OLxIdCLt9areSXc0bhOFlbzZq1ouJy3pmAUcCkVF9zjahIgjwHDjBfS8XLKKqRM29A5hEKIUUDbg+FgFAhGi6n4TCFvawgAEEoBEUzbejpmpAxd13RIkMslA9hnchxQKZVlmNgkcYdYUOYcx9YJhoCFLGCMCuWFwSSQIy8MfCY596NA00kyZsQtnMnEJQBRxCPKTQOnUnYyblu2gTFGWOgaTtmWo+OkjgwlZBgqBIOAh5wqBTRCiGaaBsZAIU03IM87JB2zAARByL0gpFS4ERhNmYRA03QGlOf7RDdE4Cnh61hZpi64CDkTUCGs+wENIuH6LlIQK5XPpCwLCsncgHsh9UNfKMCloJQqLiBESiAhpYCA6LoECmGENCKkEJJLCRnjUgDdNDDRIYKaqQMFgQIAIcURQApAwIGKwkgIoBkEQAEg5kwAICVnmGgCAqm4qWsEAQCAAIpRjhGhXFqWgSGmIf39OS9XKmRcIIyABhGAAACkkIYARJJzhBGCCAElIcYASCEkhDqX0jBNBCAhEAKpoCIAYVNXCjEulQIKAQwh0TWEkIY1BlVEmYY1zdAVVAhjgKDgHEIFMEKappk6i6ROdKWk5ABqWEJhGRbCEBEMCWSCAYSFolwyCQHBkHOKMcAQC8gxAQhBzTAAkFIpGjHOuZQCACUZpxFjjGEEqRRMShoxKbllaooLCRQQEkgspQAYaYRgAyvAvcAPQzlxI3fqQ6gcnbAwpFFkx4y4aUDJOWeURiFlGkZUUAxkJKKI85CLZnvgB2Ia+SENDAgTcc00LcojzqmOkKB07IWTQAAoEjEzHTdtEzoGMg3dsPSQ8vGIUYndkI6nrhdGnh+Ox2MhORY0nYwlY5bvTweDQEjChAhCyrhSCmkQ6ghiBBAkFIKQhpahZXPZcq0aBF5Ex/fuXh24kzAKv/vdrzEa9HvdTCI7mQY0CvxQiMDXMMzmi81B7/PPHw2Hw1q1lIw57jgs5vOpTBJKMZ2Mz88u7ERuZfkqROJg94BRurq0pBtGIu5UZ/OGZfTHg2GnE4xH1dnapfUNU9M83x2Ohge7R8+fbbY7w1K5sLi8CBQ8Pdx99miPAPTtb7565erNrzaPHj3ZrzeGjZY3bJ3dvrO0eOXV3lQCwxpMJo1WV9fI7TsblWo+5eB8ymbM0xzbtPS5qvPaS6ulQm5/b5eHslypIal0TadBOOl1Yjrp9cbNvocMg0VB0oIzhXjKkNPhMJWyTSsx6I0VY1CXQcQ9oUWUmZauG1q1lBZIeb6/mLMrC6sX55O4icKQdycUGXqMaMm4JqXqDUahjywHE03rDDrN+jCfijmxfL8fcSaIkjHELy3NA93YPxoszeBcnNTrF5aTBpKmTNufBs2ej0CYddTVjTnKxOdPDlv1bjKRsCwAAXTdYGVpddBpdtrNmdm5TnfU7PT1ePpnv3z/nTfv3Ly1/tFHH/QH43i2tHd62m4NyvlcKZfuNs+O9jfn5mbvvPzaT//u/X/zb/8tlGBxcT6bzc3Pzt6//9Hf/PXfb27vWqlidXYxns6dnh388U++/zf/v//P5pOnqWSq22v6/gjr8NLS/OnJ9uraqhcpNxT9XndxbvaHf/CTbvto1G9lsrFkPIa1RL/T7fcHEqpkynYS5rVLS7Vaud2qf/jBA90wR568//BoZm4VIeP85Pzr3/363OrGf/d//r8WY+i/+C//dz/92S8Oj9udvp/KZMaBf3zc/tH3v1bKp69uFEqF5OnB80uLuWvLhZdv3/ztB5/ZdlJTKpgOoAiy2ezNu/f6w9HyUvXKlRtnzeHDncM33n79+eP7/dZweWXBjsdfPHlezOd1nfTGE4xs2zJrtcL6xtrU8ww93puqh49eSMNJJCyowMSdCoFCxjRTcyyDAMQ5kEIW0o4I+q2Lsz/40au3r13f33/x+KuH12/ceOnOy5VCTgh2cnLqhV6r1To4PoYELK1e8hg8b3TefucbiUQqFo8vLy60W8cZB3NvsL+51euNi9VarlzKxmPeaFBIZ65evRwo2O55tVJ6sRa3Lf3JF18en7ZefeWNMJKccoWMSm1+feN6pVqbjLzj4z1vOkFYFiu5VMzoDc7rx/uR7+VyeTuWkALPLy3oBG1cvfXO2+8EoUewWFicGY/7hycHt16688Mf/mBtrdbuTi+6EybNfLm6sjBPp8Nk2lxanEslEvVGc2fvIFesJFKJQj5OlakZGgHycPfF3//851988Tn13J3N5ztbzw2DJOM5zvGDLx4eHp2d15vddv+rR7sHu9ujQfv5wy//47//98c7myuLq/1u/+hgezho9Nr1fr876g4M4hQL2Vbn/Ohon1G6vb3nef6oXx/2urF02o9ko15PxmODTmvUb6WzqSAK/KkbsxzOqEkwRlKwKGNb2aR558aSBmU6n49n87FEMgL48Xbzou1X51ZLeZs4adOx11bn33jjXq1W0/VYPJk7Pb+AAC3Oz2sY2lZMCTXsD2eqpcW5yhu3q+m0vndyodvJG1fvGkRBytJJGyjNshPziyuD0XT3uPfi8LzVbMwVs//iT76/UMmYVrrVH+6+ePzu114vF5JAyWw+xyTDmq4nE4hYUMByNn1puZpMJNu9EUbom99+u98bfvXoSSTw4vzc8cnh6tzatdW8AvT46Oz57qFmOJ3hpHX0olpJ+1G4srpx/eZVJfjZxX6xVH2277fao298/fXT3b17d67v7x0+fPjkxpWrd++91BtMNre2K7WZP/jJd+dm85Oh3211t3Zf0DBMJcxB+8KE3huvXNFMYhDr0cMvkIEyubIfAQLh8mwxE0enB49L+ey1K7XD0/b9L74MKVtcWnnzrZcAp1vP9hlT19dXZ2ZquqkRzXKB8/nzzcODg5dvbGw92to9ObOSBSOWc2LJQirJlep02uVcPmCsNrN4fnhMJPvWm+uTYX88dbPZSrvZTCeTN+/eyRXzyxtXECRvvPPW+uX5h482AYTxZOL49Hhn83E64YyHw3a9WSkXMrnYeNKEECVtoDnGweZezkLFDDnruIjEKrm8raOTs97Q5YlUOorC4WT41qvXciaoJfl+CwymIJ/LTPuuDvHScvr0QmztnaWyFUeXmA9tUwECQ2B1x6pZb3ebdROGK7X0aiWZ0IjvRaZjAqJ99vTi8V6bY2WoaCahX1lMV4sOkLzf9fuDkWlbCpsHR93PP9+jQRB67uP7ewdnF3ayNPIYG7XyRhgzJiaOFETNvtd1uVWZrczN9fpez+UBVQSZOtH6zbNcJp5OOPFUMh5LDrrji/qJoZlIMw+PGudHJ0nbyGUyfhCO3W62VEEKDwajWrF4dHL66VdPGIrHnUTSgSkdGyZ0fbGwtj6h4v33fr1YSV5eJcMRePR8O+Por9/OSwVOzpvDSX8mn1kspSwbDKZudxoQ3bEQ8gOv0xs2ej0FtYSOkoYopk1DI7pp2rGkQkY/DJvjaaPT5jzCCNumnUo6CELKwphhaJoydGhrSIdQSAWIPglUf+SOwwByJAQQHGIEs4lYJm6aBCKggAIYAUuDpmFgjADU/YBPQ8/3IyKhQwBBPKCcKTh03TDwBQ1ZwDQFpBSCRoap2xoRQJq2pduaFCqkfDR1/YgJKT13EviBYTsE8bjtEIISMTNmmZz6SiGgEAZq6nuexyJGCYKIQChlJpNKmKZu6ABALiRGOlCKS8GFYEJyicIw4sxHBMQSCUM3gOJBQAcTFwHNMCwIgJLK81jEGf49sBuwKIx+//OHAFHKIhHpSFdKTSY+DcMoiljEuJBh4EeRUFByLhHCBCMESRhG1Asw+E8jcsElVFhwCjkXXHEuo0hATDSlmKBMcMkEo1xwrgDEUAMSKCkBBhgAQqBSAijFfBr5XEnIlBBCIS65VAojBBHGGGENKaEEZ5RGQkgEEAZIQgiECjn7/TOUgkIJyThTAmpI13SCEBASCokRAQBJIZUCSkJOheSSUwGxRBBzKZgCSEoIcBRFlEeM0TAMAYACKAQQxpqOCFeQSxlGQkqgBBZCKAAEk4IzhCUxdASlBAoipDAmhBCIhJTUj6auCxHWsYYJ4pw5tm4ZBobStjQbE4QIRopx6PqUCgEQkkrYuukYejoeJxABqAhRSgqlQBTwYEqF/D2mIDWsjT2/3R8HTEANGxAV08lMyrBsDWtYMsmZAAggjINICRbFYlbS1i0NJx0DKhVS1u6EnVFw1h0OxlMpla3ZlmUAAC3NiDtGNpdIZ02g1HgccAYg0RHCEiLb0iyiMJKplB1zDIhFxAEGUMdK10lnOLlo9Fdna5mY4Q6Hq+vrIWOD8RhCeNqsT6eeYHxKo0QyFbONbqv38POngovV1fn5xVnXC7FBNKwBqVzfDVy/VCryKBpPp+cXLc3Eq2tLSMM0CmpzZaXgwdExVDCddlaW5y3DeLG1v39w0euMmp2JH/hEyfXluWQiW281u92ulHRtKfvmy1ddyv/irz/4ze8uzrrytOUKxX/4tcrdW9e+2j373bPOi9NwNAmSJry6PkdpeHJ8nrBJIWFNR6OYbVQreZOI1kA8+HJr6rFUOj2eTvqDqZ1II4ySMS1mkdE4cGKOYaC4BWv5BAGMAFYul7CZqjfaAQ1Ny4jFkkxwl1KIsQ5UwnGGHvW9IEHIejm9f+E2BsDOZC+a42I+JyKaTgBDR0PXG03C8aQnpcREPz3qpmywMWtfDNnW6dQ0rQTi2RhYnM8dnnVbI7V6aeH4cN+fRqlUwvWmqVRy6E46Y5U12ZX1ZWQV3vtov9tjiYR9c32eUTeRjC8sLLQ6nf3dw2qplE/Hd/f3CrXFLx8+nJutfu2d1377/vu27ZjJxKA36vWG1VK2Uk67zBv2x/l85sb1K8+ebf6HXzyuVAuX15d9d+qOvV//9uOLk9bdezdfuXcvZljNZv3ocGt1afHLr76cBP5/9b//V5IFi5U4Aeh73/nhi50Xo85kfXnh/KShYyMZN+bmqw+/uH/c6H7ve69kM7Hzbud/+ennnVbDjlkAiEIutVwt5HNlpBn/+Ivf3Lp7ybLMf/iH39iprJ3Kf/zZR6PhWEf4v/lv/4ex5/3oB+/+h7/+y999vr992spky+1Wb9Rrv/3WbXc4UjQcDYedZvMn3//6ldWFK5dWz087O/sNrMczhdyVtcXpaLCycokYRijg3ML6OGI7R3WKzIjxVFxfmy85lvnhR5/EMolMJu1NQgzNVDJrIVRI2THHvGi14rnSp58/slK5seudXHQGkwmT0jD1RCKuSxT607gTT1lECYow3n6xeePayq311S+/uj+ahMVq6eik/g8//8XJcT2fy9y8tl7MJjUTTUbD8Sg6Pul1u24uWdnf3Xv85VeCiaebO4P+2HFiSpFcqZTOp2uFfMoiIZ/qJkzFIaeTk+Pjaq16aW3WH7c6A5cQ7a037h7s7n744f1pKArFWrPVPDy6eLG7Z1nk6sbifCWTTcR67eZoMEqkUtVqKZNOIAhOL5r3P386HA58PwjCwAvGlE7ijhF3rJlqpVStIKwsS7zy8s1kPL5z3D7shF99/iRhGwu1oueFw/GkVJ65fu36wfGgNwwZQ4Pu8Oi8dXR0Oj9fq1Yq8zOzccfBOirkC2enZ3Y82xn7m3tnEdV0I6Ejo1Yp9sZyc+vIMGNvv/l6IVcYu3Q4HgEoTMvJZTP5XLpayCsIT09Pa7XqlUvrGEnTsiZTvrm1nUxnGOfPvnoOhKwWCxiAykxlaXUuk07mMxnGOCSoOlfqdusG0nUtNong+flZrZiaK8aGw7GCyrZtJ5l0qfz82QGTAAl6dny6+XTzq68enR6fB344HPQVp4lErNPpD/uTZDLphaNsNpFNOVubL+5/+bSQYH/yh+9qSPv5rx+fnZ1vrC0ZmuIR5Vx0291hp1mtZRGGgYCLq+sffvxRSqff+87XEpaVtOIhUy+2t2u1asImtmkszZVdd5rOlAAxxlMXCH6pUr65WtVtdHDWsHRy7/qlYsr68NNP6kPBUbp+cfTua1dfulRcWy4CqN1/utccToXwEdQZMPf2D5OmmUzHhv1xs36eK6X+4ac/J4a+tlg8Ot5PF2bqneGDZ5u2wWcWZi/6wb/9q78/Pj2/cfly6PvJbMGJx6AI1uZruaS+d3A2M3cpl0lfHO0Lic9Pz44OjzsjdxyybDq1sZh56+7V+tnBxtL8u2/cmA6nZ+3BzvHJlUsry0VjFAGILdsgaTvCfFStVI5PzrvTYDx1c/nCjTvL//Cz97dPWh999rmTTPpef6aYu7KykEtlhs1JfzAktt4d9BIx7e7tJW84UMw37PiDB490w+50R8939qYuPTl49H/8V//5nWuFR18+z+VyV6+sX7R6Uz8o1xYDBnTLXl+bY1FYK+UER9XagkGY269f2ViZTIE7Hc+WEoW4ORzSScACShkHXz3eG0zZu2/eysrphIORN1paKcYt9fTJ89X5pUtLieO6/+L4IJPN1jLJrAW4lEwiNxJMEYOYnism7rCUj2djRkyDo747W86XiubuGTgdREZuxjaNtMmrWf3SSmWxYnhTf+R5UEP5QgFiMp3QmZlirWacnYwP6r1Uoabppo7oTCm9MJ+yrHA4FI/3BltbnVDC+eUFf0LPmtHOQSuRzCTjiWGvbsd1y9bjMcvSEMK2FwZM0Hg8PpiEF/WuGbecmDXoD8/PzyvVeSHCYta5fefyzn7nwZMDTnmxkLuyVK6Vc+PpuDOarC4vBQHd3j26dfVKLkumnnfabsZi2WvLuRRmZz3aanXTNsxaStehG4D+dJpOpCvZJMKoNaLHZy0qeDYWjyFgYIWBMLDQdQgRksjwGemNfErVhEXTSegHnEsSSigV5owihEwdIiA9LwgkjyDqTcJREEwjPonk2POB5IaGHFOLmbpjEccwLAxMAhFCQEaGYXAKfc8nGjQJyibjEIgolEIRjDWMCcIAAKgBaOo6BsC2TUfXPc8lGNuGAQCkFE3dUAiBCHIjOnGZgTQFJOVMSO5YeixhQQgtW9cd3TK0iEZBEAml0qZFNBhGoa1jSyNccZ0QSgPGgYJKKOz6IdR0qBGi6YJSSWncNhK6JqSKmAxDiiGSQBGsKagARDSSSgGIAJcqCqkQkmANYQ1hxBUFSkhFFYJKAUiIBFIqiQjQNJ0yFVFKGeVAKgUQ0cOQCqE0pAkpmZAcQAogAIhJTnQihZQQSgWgAjrBEEEFFMRACqGExERTEmgECyEwwhhqhm1ijKAGIURKAaRjTTAlOVNSRIwKxglBQkjDMgnWlAJCCsoYk0yHULMwggAwqusG1omhmVHAXM8NKYUYCqUEpQhJJaVAikuJMBIQQgwAQN7E1TWdAMWFRAhChZAiWMMEAwV45AcKQ4UAlQoBRTDRTUx5yAUFCkKIFVDg95mtIJAKmJppaJqGiZBAQKg4EFJZpo0JxBBZWNcMLIXUNQ0hCABSUErJMEZSIMqUH1KEYSphF1JxjQABJCJQMqoAIESjjEuguFRMcCmVgNj1OOMYEVszHARBzDFituWYukVMwZhmahBBTqHPRBAxIICDdctAEAnKZGcwmkzCfuCfdycAY8M0gBCMU6A4MXClmJ4rZZKO6QfUjZQbQQa1KGS+H2pAOraRcOx0Ko6AUgAAyhDAWCnOI893R8PR7Ex+dW2O+m4iYU3Gg6nvOrYJoNQkrJQLum6mkzbWkDsNR4Oxk0itra/lC7nnW8+n7hAiJXgQ+i4LuZnM9gbTi/OLs7NTHcP1tQXbMgaDQTxuTQej3a2thGOvryxVyyXfD9qt9sT1YvFE3E7GkxmBjNJsDSOwv7tJNGNxaWV2aXG2mjs/3v3gV19EoZpbqiTj6PpG/k/++M1abeGD37z36/ceIolrhXQ+Y6ViiE3ardOjudlSpVIeTJqFTGx+bkZK1G73Xhy0/AgU8vlx4PfHHpeKB57iXqVaIWYSYQgAgVQUkoala54fYGJ3h/7ecUsqVCll47HccDqt172koWVihgGpaUElWS5bcuL6dDT48OFhoapFrhePoWwm1mn5hUIqZlmUIoRNzw/z5aLpxCFBM5UcDN3d9iRSWLPiAMhyBuYzic2t3dmZdKVWPTkfL86XoIoIUkY8eXzc0+3Yeo0UitX3f/dV2wO1Svz62gLCstt1e+2WO3UbzXYinc0W8yenJ5wHqWQykzLeeuP1Rw83XT/K5rLT4XQw7K+vLKUylm3oOjF1y1q9dPurx0++fLDzL//8+5dWF07r+5EIe4NpJlsrz8znUqlB7+Jge08D9K03XiuXZzkzXn7p7afPng7H/Xdf23jllbsQ48+/2Hzlzbuti2YybY8nE9MGjE68IPzu9761sHj5aO/g1+99Or+0+OMffrs/7N+6czebzXb63ee7uwcnbS4QQdbe9v50MinmCwdHx0+3WpX5Ykg9RLT/7M/+CUba863tCIj1jVtCKnfcuXF5bXtzs9Hsh0JxKn703Xf6w/FHnz/aO+794r0PNccOJCvPLvz8V78OwrDdGf5P//PfUg7vP/jsg1/fx0gvZnNxJ0b9sFqIb289jyWdf/bP/oxgvdPrnp7se5M2iPrZbObo6BhLa2d7O591kGQsCmeWVjlHOsZE0yiVQtCEbTiOdn5ylE0kb928cnl9gU57ezvPUonYytrlUj6nYZJKZtavbJSKpV63FfqRgczIE27IDg+Onj5+2hnUg8ibX5yfX1hMpQqpTNly8q3+YNDvJx2nPxg/frI5X5u5fmU9DINqpVwpZJnvUgYhsiMO55dWveGwXj9fWlxxzNjx0elkPAko3Dusu144Nze/uFBLp1OcAWKnMdJy2Xwy7njuOJvNXlpfYVxEYXS6d3C0s5vJZE0jgSRKpOPVXEpGwdbjDz797S945I8H0+ebp5vH7fZo3Bm7h0f1esf/d3/1d7+7/8XCTE4wOhgN/IjGYrpFgGU7vdH48LS1dun2xtq1ges7yUJI4cHB8Vmzfd7o+eMwnUll0vby6mK2kKERbdZ75erS7PwyxricL+i6ls6WFdEODo5cb5pNp+vtZrfbXJqfMy1zdXX+5q0bmWy+Wq0tLC0lYoZlG8PJGCqEON3f3plMJtgxzpsdC6N0KoEh9ag8bw8jRc5PT1dqlbiNOhet05NjKJnpoCgQJ8dnmkZWV1def/P1hdUrUNOnri946Edjx070R9PHz/bDiM6VK1iFmWQ6X6wZZgpCdnl15c03b1+9VBr0m616s5RLJUxNt/VARIyL2Zn52dm5rRcHzU7/0pUb7//ipx+993Nv0otZRqlY1O3Ug0fPTk/rOobMC4e9fqvRChmPItrt1pnkhYQ2k8+ElG2+2M4kYlfXVzA2H+2eMomarbPW4YulYswCIp0uYDPe7Pt37txLZrKvvnz74eMnY8/78vHu4uqVtfXVV+6uMRodHx7NLK1du7w+btUFhsXVu/tPv+p1BnfvvLmwsPjlg8ene88RgmfnzavXbtqmHrj9hcWV0zb4zSefXN24VMjaqXx1fWNjbrZYzscPTs8brY5lpwwDVcrF3/7uk1Ih9c6bNxGKffrpzotnm+src/2Jf9FopBIxbzpO2tZcOfnaS3emQ9/lst0Z3r5y96U7Vx0NP39+0WkPNTPZ6TdK5ULCkNm002h2m51pEIJOq9/tDK9srOay+cmgN/HlSaOdTJd3nr7Y2rmon/c+/uAXf/5Pf3LrxhzEJJ7KrizP9/t9z3chNra2Nov5PGQ+gbRUSJ6cHpo2uWi28vNzhao97DWJnGZzBtJgEIC4k6aBn86WPv5sh0fBvStpTEC/dx4Mx6WMMxiB82YvnU/MV4mIYKPRg1guVbMxJaPAG3hiRLIRF/lyfjAUvucn4zgZN0wLB5PRG9cvLc3JTx759zcbykr5U7/XGvLQr87mri5nCgnbhmw6GY652m9HxyduMlteXC0dn7SfbR7CeEZLVrwJS9j2y7dufe+bt5fn46PB+P6nB2cHx5WFqgRwa2/8+eNdOzVTrS0n7ThBka4hhXhAFQLYIhrScHVmEWuON/aRhJn8zKA3Ho/6UKh28/z21auXr8xc1Ou7x2dRRA1Lm60Wa7X84W6j02nPLyw83z4/PTqpVXKpRGIyZts7+6EflvLpWCZlOXEkorRtxS3TtIjryYvuAGt4rlZ2nJQrYNejHCiNEEszdKg0FmmSScYjyiMumUI+BFjpPpcjn3qRmLhhQCUXACE9bmmpuJ1O2RYBCGGimdOQRxz4HIy80A2YTgyEFFACSAkAsnSi6djQIFAYKSQ4CAUZuwGARNM0xzIBlj4T/YlPOWAKACA13QIQUk5Z6BEEDKwE4xrkuo4QgZKQSGKFMJeKci4FFxJyAKTgQElMdMk5FcyCKB53DKKFQkQBDTkzTAdiHeuYQJ5zbMs0IiYEACwUGGPK5Kg/oSFHEAGAeMSkgolYPJ6MIQmCIIwo1bDOwlAHUDKqEJAKIERM3bBjNkCAcg65kCEVvxe7NE1yCYCkNEIIWnoMAsg5hYABCAXlgglMtCjkQkkqKFNKMgkg1AlGSkEoCURSKSEEZQxCgAmRAAIJFQBSKF3TgUaghARKJQHngkWccyG50DWNMY4AVFISBaGmawhBJaVj2BHjRDc0TUSMEkIEFxASHUMFEMSQMaZpGpAoDEINEwEkhEonujIhjZiiXNMJkAoSZGKdM6q4wARJCTDGRNcEF0ABIYSh24wHGkZQIS4o0U1JAA0ZhAARLJXSARRCYWwCJZWSGjIUoAwAShXCBEgJiKQ8UhwgqHzPlwIjjBBGUAGsaRIjHUohgeCcMY4JcWIOgqDTG7VGAdF0k0hdw44mhaSCCUSQo2tCKl0jUGEoBQbIcQjnIoqkRHjkRkQ3IJBRGFg6UkCDiFDBlHJ1DQJEIIBTlwVhpGsql8lCqCLBgkBQPmEccSEDKkzdMDTdxAopiBGKxc2E5TAV+YHPmIiY9Kh0QxmGAUbQ0KETQzFTBxCPxpOx7yYt27RNKn5vTwjdIDOVbKGYyaWTp/7YMgzHsRdmZ+pnZxAa1dnCYDgcjkYL6UzCiZ2dNT0hZmeLuVzm6ZPNVmewdmk+7jiDTldKaDixTmvQ6Q4tLZbO6itLa7punhzvEYxDn0qo8qVKwtY0Q++0J43OtNcZJuJWoZiGKhpceJpuugE/bzQzWWe2lgU8jCSUQFFlVCp5YhhShlcvLWysLnJ/+t4nu6en3vVlrVKqItsKAy6kq7zJ5fUrAmlnh0cQoMW15bPji0ZrkMqlkaYMpJgQUlgeDTEQST0slwoKa88OWlMP2kaYT6CYnWj2Rm6kIgAb7ZHrycVqwopZZ+eT426UTpnlUloGLifAsWzHSnZbva4LWh0fIlxKZ9i4vVjLT9wJUCCbLQds7HnUyThRKPS0Cr2QSwiw2WhdeBEsZ+K+H1gSzC/O9xoX3Tq7/YPlg/0zbKLZ2fLB3ot0MnNR7/mRyFUS2RQ6OO082BvPltJLc7m5+dyzx5tDVxlxZ9QdX7u8GoWTbrs38cJMIr48WwDQDMLwvNHtj8bZTPbyxsp44u5u7/pRCDX41adflgrVrd3jv/q75z/67uXZHH64eVQp5H2XVpZnhDJ3X2xRJo/2Dm/fub66vi6oODo8Gnvs2Yudajl77drqg08/qcysHOzt1oqpmB1vXjQdJ9ntdt9+/Zu9TqMwm6s3m0+e0W6v4Zjaa6/dHI9GkjIdWwfH9U8/ezI3N+dNz+14st1sX7t6eXkm+fXXbzzZ3n/j1euZdGHQq19bWVISUMnZdPonP/hGsz89PDl46cpVA4J4PK0lc5ubWysLZcD8X7z/21yxtNuIXAFYv/f2leuf/u4+Jsb6tZsH++eepxKZzOHBabUQyxULRFdHR4dQi7NoUkzE/vAn3zk/OXvw8Ek24SwuzSwvLezsHjQb57OVuc+/eLxQKy5dvjTtDwElnx30g8lEEXPqTSMmkJQIYBm4r9zccBBtHO4vzBc0yUzLTMRjnheMBp1Mypqfm6lfNHYGfSdpAKLrhhUEbQTEtRtrlqkBiolppGKJw8P9/dOGTsyTegtAeP3u7Z2trc3N3s1bs5Vifnt3DyjYeLo/ngZxxzk4aQjJrGRx5A57vmfoViKuD8f9IBhWq7Xn28fZXEozYr/64GPGou5wmrDsmG1nM4mpHgbTKYAgnbQdx76oq8ifJGySyuaPT3tKynwpNeg1spmkgZ1LK1fP613fZ2szC27IfvoPn73Y7y0sVqJupCBotVu3b27EY8jEbHFxXsOxG5er9113f3f/oj0ejv0PP/qUaIAQqFvxMGAz87O1WmU8mg6GnSDAZ0HIEbx59dpgOLo4Pvry6V4qEY/HtDu3rtd73Rfbz1J2PGEbk/aQV0Ehk4M61AxnppztDpsQm0f7rW6n/fJLL9mGera5B4herkDHJqatNzsjO5Y0dIMGvkXwRX987crdvZM93+MXwTgZT+YS2ngoAUTpVLqaT1omnk6G7pjZmtvv2qZhYKxBoOZnZia+v7N3FoVK02EYwSDk1Wpta28fQFwq5YMwOD09g3S8Us5ybD7ePuY0UoJYmukHcOe0Pfa8K5dW33jl3s7zhwkNv/31V0/Oxl8+2R8OhwqF80vrlVl3b+c4m4mPXZdynsgnov5pxKkf6X3Xz2Xh+vzMoDd2feYL0Gn2y8WKgN2Rx1JObjSdxnLED1i723/79bsnu8+fbsdPjk/mFxYyWevpzm66OPvJpw9ef+XW5ZUFyP16ffTp51/dvrn8/W+99m/+6lfbevva/MrDL55bdurVV2/P1bKf3X/kC7h9eN4b9tIJZ2f3fGZuef1ydWf3QgOwUModXwwjjueKCRvyaRBu7jcHQ3cmBFBD8Zj9t//44Te/8c033yj8zd/96oNPvvzD77xz8/Lq2enZUiUdMagQH5ydzc8teQvFfn/y+XRreXY8k0/rgq0szn3w8eNMsdDd6/nho3wuYdnYo+jB04NLK+VKqdY4eXFSf/K9r72+tLL2j798+OirnW9/4+VrV6tnXS8fRzs7R+dnF0hBAITn0tFgUK2ULxr1u3dunO23eBDevLL67MXz2zdf7t//SgBw0AyqncnVyxsff/iQUqoUy2TSnXY/ZpcjJislu1zSnj45vnR54eoVE0hqElNZJJ1FZ/WO5phA4HxWFzxsd918JndtiT87GF8MQHoG19yJYcfnZ3P19iBXzABAqKTHbc9yTl6+fpmpFw9fuDQ4uHelFojW/lEvmTKLxexM1QaUdiaj8xOpm9p+N5hQ8cqNmflC+me/2f7bX+7de2khn52djkcHrd3ZfPYPv/kqQNFnX7zY3uqeHh4uLs0LAg+Oxl746PpabePSLBWBH7ByrTqlYa/d0y1HBdynI5sAw4CD/gA79uqlS5NhTwg6HEnc7r52986n9x9ctEaRRxfmKiJy54uJ73/r2j++/zSfL1XmCp1W13NxqlDESk3dqeu7EouEjbFjGYaGIpYA4QQCYBseCw+bvVImaRIZRLjvSQamKZOkYhqCiHHp+QwCEVEgMWKCM1dSXSKgAIKIICwkBJApMI2ihKZZGtQJ9qGIhIwoJ8QAmChBmVQsoFwiW4eMMimg4ZgSciEUFBJrCkJBLEID2p8EEmDb1IBUNOSCCkPTAeAaImHk67YFocRQSYUQgRokkEoNwYxjDVVIhYpCBgF2dA0jyDi3DFPJkAsRMiERZUJxnyNTtzHWNQSx4UWMj1zbC5PJJEQRB1AqoSQwDV0pFUGhPDcRcwCGjEYIa7ZlKMYgBAJwm6DI0ABUE89POXFCpMJQRZKFoTIJ1g3OOPcp0BFn3CCGYZqMSwEkBIhLBiFhNBBCQsAQQpqmIQylggojJAhnXECJlEQIQakIISENIdQjwDSiKQmEEgpKAKAOiWJcAimZQARDBCLBlVQQSwBQFPqaZgEpIs4A5wADBJBCUEmOEIRKSSG5EFJQqbhglFFOwyDiUgBMMAaIIIwhl1yDkHGOINQIQghJITSdYER4yCFSmqFJBDiQAEjJI6UkxlgwQX8vOWMIpFQQSiWEoEoCJijnEgrIqZRSSKEgVkpITpkXUkqFlAxCBRXkLGJMAUSQFAoqTdcY52EoqFQRF0QjUNcwwQBAKRnnIY1C32eMKcqEYZuaaXih3xsH03EkJOCMI8lNDVqGJbnEOkZIKgwwgAgoXVMAKqIRjCGSMmSw1e5HjErFuRIaQaZlEANN/UgK7PuRlEgIGbDQDycIE8fRFOBSsCCSkYR+BH8P4IUs5AHVAbU0ZdvEsjUe+gH1PC/oj9z+hHWHQbM9cQOPEOxYViIZtzSTB2F3MBx7vpNIm6YDOdCQQhgRTSvmkrVScq6Wn3hhvze0LK1WKTcbrSCijqH3+/2z42PHcmYqla3tvUePXgSen8unO+2LkItXX30zYdqdZpuGUkj92dZOrzfAhMyvzF3duJTL5xqNJqVAAayZdjKe0jSNcXB4cPJib//otG3FY0triwqqs3pL8GkU+cF4Ui4XLl9azcQNjBCjfHO3cXLRkZKmbHFrIz9fQufHL54/2zpuiPJ8cWV1FkJ2snvQ752nEul0NjUZDXc3N6ngqVxt/2C/2xsWZ2udvt8bDHQd8Cjq9buKs1uX5+/cuKKZxoMnBy8OwonHHU3lsoX2cNQdMd2MjQI19mU1D1YXZ1uN/kFjYiNQyDgaAO50msnFgPSGnf5FvX8+DHfHajYObDwtJkkmY4/HrpXGgJDt5+cICihgGFLDtDzX1YgmoNmaAkdTtgWVnJRqmVy+dHJ0uL6mGxBvvTien53t9nsIahGV2ztnVowIrzf00NbukYGAo0VzpeS0332235hZWOn2RxAJICmLwngypRl6PBaD3IcKjUajXr+fS+dq1dqDL+7vbL/gvn/v5u3WRePG9WvJVObnv77/Rz9567vf+oaOwnQ8ky/MLq0upxKJnRePvYie11vf/cGPia59dv+Ls/OLk+OLg+Pjl+/cuHfv3t72Xqc3uXbjRv38bGNtadjtSAUGw/78XG06DZ49f364t/vFl5uHh83tnXo+l/J6x9NATEf+lw8fHh4cXL52Y3ZmxjbR9WtrmULq4/sP7Zgz9Ycf/PLBa6+/9uWDTxZnq9PusVL8vN65c/flSb979OLh/EzBSCfcyA0VvP/lY4hxrVoYtts6Im+/9Q6MF86aQTrmTId9CbWljVvNRu+8Wf/Od95qXDTS2eyf/ekf5rPOuNvOZcuBJJxG3/jW1+7ff/QXf/F3hWzue3/wg7XlhSAMdnZO79y5l8oWTCKyMS3vOKlU9vnOEQ1D07EUhFwBRLBGQCJmXF2tSTp1h51qNW8YeiZfwBj3RsPdnefJRM7UrU6ntbd/UKyV43by9Oyi226Ewr91/eblqxsXZ+cPHj6YTsfPNh8f7R8UyyWoo+lkpGlw1Gu6rv/qK+sztcrm5ouQaqOxGgQilU4xzrv9yWAkms2LYWeyemkjXyqenR3ZFp6bW3KnfiYdf/u1l+PxGA3Fa6+89c5br9+6c2NjYy2RiBXy2aWV1aXFlYv6Wf38PJmIJ7OllfWbEUft9rjZnUiuz81thNSY+vKsPbbjmVQ22WnsL9Xs1++UP/ri4MVxGwHJQ2bH48QwstlMr98+PTnYPtg52HkEAJZAra0tvXrvWiafsTS7VJwLg8nO1mb9/Ozw8GCmmr97+/adl1++fO3a3RtrqaSxtDx7+6V7t+/dMm0tEY/1e51Wa2iaSctOzCwsrVxaMbF+dnahGHr8+HHj7DxmOaViIVcozs/PHZ8c5/LpK9evVqoVhPR4LP7G668vLlahgtlCnmBRnZlTHHW7FxvLy0DI3phzbN25fv3K8oITi4/6A9MgKmiXC5lkfr5Yy3neYDQZPXm4Oe71EMGcM0PHukVCN2p1uzvb281Go1Isx0x0sP34L//jh/XmEEMYRsPJqJdKJHKZLIbK1EEiESvOzNTb048+/fLw/Hx+ZRlptu9Gd27feenmjXI160+Dzz59kEum5mYrQ5etbFzTzUTzon55Y72QjumW1XX5g8+fpjPJ23euNdrd3bPG88Oz4XiQice80EtlCuWZxWZ/DIkeCdZtNUyEGNRDHu0+ffIHP/5hbxh1B8PizPzP/uH9v/2Hn6UzOQCkQvbnX25quvbWW688uP+VF6mlK8t7L/b+53/zH4YT/5VXX6vVakKKfs/3fOhLvHOwR7CWzcaDMKLhdDz1W+2263mODSulxDe/+fLzI88LRFw3HB3FYol/95e/OD/fu7SQ4QjttMT6UnW+HPfdaSpdhMhBEJyfbF1dnssk0wrFQsaBotkYef32xo3rq93BiCTK3RFdv3w5k7IKuey1mzfavcHu4WF1dm5tdeHF9tNsJv0Hf/BOMhPbebF598o6l+K8Mb5y9ZV4vDwOo8AdpVJWtzUIJl6hWOv3RhCog+P9Gzev+QE/OTtYmi0nMoUw0h599SSXsFJx5IYckQQEzDDI6XmDc9hvN2YqSSPl/Pr+4aQ/jtuOoQHLsgulnFKRicloTNv9abJSpRDuHBzNVdIvXc3EDdBpDhQyPJ/nC5VixnbbvZijG6ZtW9rh4ahTP3zl8sKNeWswlAf1TrIwm8oXRp54ttMYdUfutHf72tqrt2YUpYzzZmf6fPOFY+EfvnulnNN+98npp589a3amnMY++ezovV+8Nxw2Xnnt8je+d9MwLS+a1JL23FKm03E//GT7t59+kU4ViGZROsykE5Vq0ZtMXXcauW6nO/D8KJ0vaxC6o24xX4zF0048fX50EnqTu9eXdYzanWmjO0ync5PRYKmS+vrXNi4uzoqJWLlc0g006PdMk2QyCSducwGC0O30e61uj0uRjNtxQwjOoK4L4AwnvhA8biAqwHAadoYBYxwAhnU9mXAK+VytkikkLVMDlgEVEJGIJJeMS4EhMQjWNcao5wZuEABJ46aeME1DA4qFSFAMsYE1qEBIWSQlxkQB6Hr+eBSGUaQUURJxzgCnBGJMtO5g0hmMIdFtWyeYMMkDxhRCmqG53pgyziUQEEVcYAUtS8ME2bYed0wN8pCHEWUAAE0HSipNV8mEjjGgnAKpa7oNkT6dBgFlCmq6ptumzZgMKOeUSgmtmEk0DSFgEWJo2NQ0GoqJ71PBJNIVwkFIYwnDIIRzYRvYNoiGCVNg5HsBl0AoXbORYUgBIi9CCmm6gQHBRANAcSYpY4ggySUAUEPAMG1AkJBSAUEZFUopoLiQTDEJhORcAaQA5hIAAA1Lo5CbxJRKhoxjjAnWMcJSKQEAgbqumxggDLFSIgoDJDFCGCGDcyak0AnRNA0CCCAIIyYRRowzBDHCCBPMFCc6EpLTSNiOw6WAACmIOGVCCcE5JsTQiZASIiKFxAARDTHBkIkNw8A6AQpjpCGkAQAJxoFgCENdxwqIMIwkhFIIjIiQAEIlJFZEcYUQhgQbRIMIYV3DuqUTogzLhBgqiDHCAnAABARcAIUFiqSQQhIMEYIIIV03TAJ1hJRQAEJCNIwJQMiPKNEMpRCj0puKTm8yFUoBDSKVtMxkTFdAmJpma5qmEQiAZZnxeAxDBZWABCqkJFTd8fD3LoGmw7ilJ2wnZmiWbmQTFkGI6IYQkkYMShSPOZmEWcg4CctSACkAlVQ04kgCqbgJSdzRHMvEOiEYMUoNO8a5GgR0Gkg3ZF4kTdtwDCPhGIm4yRgfTt3O1AdCxmMxC0HBI6hBAKGu41wmGYs7mGjt/vjF1o6m27lc6fD0qD8caUD3w6DX6RuGUZmtHJ2NP/z4C9tE87NlwfigPVhdWtQwCBidBhE09MPjszBgVswq5vLphIkQOr9oti6ajCuDEEXV3sHhcOSfnHcePz+YDt2N5fK1qys0oidnzalLJ25g6sbiSm1laQZh3B95bqC6nWEU+flMMhnDls0VoJGvpNAATM1VrGy2unk4/fkn9d6EX1qaFXRYb/SmQaSnskh3XG+CkF4oF8ejSRjQuUIsZlsRjUop+MN3b83P5XePLz552L5oy0wOLFeM5blqazDcP2eJdMwEYas7LcbA27fX+r3WRS9IOXBxNl9Ko96gyyHSrYQ/9aGh+Vx5QnN9VI6xaiFvxeKh0Bodms8WR9OwPxTJXHHqjX+vmI9dLxZPDEZhcwQzjqmCMOdoK/N5XRNcgkw293xnW8OgUkwA7ufyueE0NOIpx7Y1xKYg3RuNgFQxA2ULpS++3JwvxC9fnmeBe+vWDaVoKmknHJv6LJ1J1xuN3eNWr90zCF7fuPRka6tx0UukkvlSngbjVLKwtnHj1x9++O23L/+TP3zdm/TOW8GjreeNZotAsv1iDxNczKfj8cTe8cVf//QjBc2IkVEw/dpbt15//e5v3vv14yfn//K//K+2nm+PRv219Q13OrEsq5DPUC7e/9VvgO60OuNCJpvOJvOZ9FxlJmHr+yfTx0+3CQbxdMKJxdvt5vraUixhnbdG9bY3u3r9f/off37zzuy4c1FOx5fnymOfZfIZDRMp4ebW1rfefXNlbrF+3jw4G2zvXcQsfGtjLplM9EaD6zcX54pGt+1SHyzMzwEhIBSDficUfG5+NgqDbCYzO1O+uKg//OL5nTs32/3GWXuyurjw+On23/3qcX428+f/63/hDfuHB8cffvx5PG6F3L+o75VLqTdefwko/td/814iXzXjplKAMoah0nWYtfFiKZaypAH9t996PRbT60dHoesBgmK2sbw43+qPt7b2oDAX5xdZECRS8fXVxWQqfnn1shWzH3/x8PyiUyoUU5m0pulXb1wjhtOqN5Nxs5wv+FM/ZhGdUG88XFm9RMOoPxrk05lkruAxnkzGM0kLKUGBtn9wvrO9m0k4N29fsww9FbPu3VmVcoqlv7I0C2RIgNKA5IzOLZQJgt64V65W5pcv6bH07v7JBx89+Kuf/XrnqMkAse34463tr57sPH52/MXmxW8+Pfz7X3wpFLi2sdw53V9bW4rH5NnREeUME0YD/3D/7MVh6/BstLRw6dbGpalHetMgl8trIpqrxC6vzRoxu9vtZhLmbDU9V63pZux3Dx6//9tPH3zx7NnOyeMXZzuHF/WzRqffY5FXzCYCn91/uD2ZTFKxWLPT+uSzzxlVw6mPIB6NJpOpO5j49Xb3/KJpGXB5dTXumJub2wQRRoNeZ/Toyxcn5w2dWJR6jIvn2+fdgX/r5vWjvbNw0l6cLwYUvjhpjtwQEYShjMdSNAg4U61ejzMxnoa1Uvnm9bU3X79u2ObFxbE3Gd28c+X1O1cCt9dutrDhdPsjysJ4Kja7sHTr9qV+v53OZlZWlkrlNJZ01O0VMznFKQ2mFgb37lzLZ+PNZntvvz5w/Q8+3Tw7PeF0gCNPt6xcPj0cXhQy1kW93h1Hd1+6DaMJDrvlQvKs3lJIGzB4fNbMJ5LplB3RoFotN5pdpvDWQePJUR0QnYWBlEHMsjRNj0J/e3t/frZmJcyd7d3bNzbGo6mM2MzC6k9/1/rVb764ce3STDHZ6fm/e3Q4U5ldyqH7D/c0ot29dTldiH/84MWzra3FSr5WTPiCITNhmPry4pxlgHZrlEza1XIZ6ASZVrfj7e+dGpis1CqvXq8Oe72N1YXFWun65flLqymNB45GY7r91ZPt08bp7NKSE3Mi5hFdZLLJZCKmkJhMhliDTMFWZzB0x188fbxQLV+eiR+3xlvHXaDQ977xTq9Zj9lkZXXe0UT77BDwqFwqdVt1d9LbWF4Ukh82OrPV2W5/+PT5s1Q2Ozc3L0Q4U8qWaqnNw65uxhSAUqB6o++F+JvffLXXahmYGoa2uFLpD/yj471yJXfc8EJAwihKZjKIYAF0y9RBKDMpCxHx5U4ooJZMpZrNtuNYSUtLGDSRQOct3J8EK6urNALbeyepdOGNlyqxcPrswu24UTQd3Lm8lM1ZUAQERJl0slhOjcZhOBhdXUnnkuCiGR4fnVqavryQqxRtKsTQlQd7p+uLtddvVAsxICXYPwO//O12RPkfff/mD95JaQA8fD4UzJ1dLH11qv7y7/eePt9nk8HqRnVhbrFayt6+NPv6vYX5udTx6eh3n27lsoV8PjsYDTQN3r29vrBYSSYNJlizNeKA5EqF8aS3ufNMYoSwlsylHz95HIRwZXFJyrB+MRj7PFcs7e1ubcyUNtbKw+EwlYqvrM4CxbqjUcCUZTqZONEN2zYMTPQojDxvqmtm2sFQcCZ5xAHjikCU0LGJIAdyEvAwAoEX9DzPDcIgooaOc5mEbeCEYyQd08SAMh4FPIy4rpG0pRGNKCkZFVDKmIXzCSObsghSpgYMHcRsU9ehEso0iOXoGgICAYSIEBIAgDFBgEBOIUJQt8Y+G04DqbBpEI0ARtk0CAjBhmb5lAYBjUKOkAYwgBAxLkQUxgyStB2D6JzzgAkVAV3HilICoEmIZuqcMSyZZCJUeOBFlAlBGQbCMAwF8XgahpFAytB1YhkIIQm5kJQBAigTo2nYm3gTz5MKMAaFkpoOWCR0HZmGpoTwIp9SHjIlgEQSQKColFxJLhnAmCDMOGdcOpYT+ZEEUAHlUy6UhEJqRBMKAQAp44xLIYUUAgAIMAIQahAFYRBGkeQYCBDySAhJMGCMMiEo5UoqxSVlgVA8EoxRZmi6ZRtCSuaHCAMEoJBAAQAQllJJxhGGSiiEMVJIIqgBBZQCQkiMkEEwABIBLKSQXBBMEMAYEMokBwpACJUEiGuGLoQCQGEB1O+veDWlhBCCAagUVBggRhmGGCIFMGScCyEIIRArCTFCKAq5hIozATGMIhb6vhAqCinnSjKFAFGSMckRwBARgjTOGLYw//1KCKGIUQQRZ5GOkcLK0LFu6lxwjBBlHGPieW7g++50Og0ZR0ggbJnEMQ1MMA0F5zwIoyikGpCOYZimIRkLGdUIgUIQgIFixLRjqUQmZTm6qQSDBCIMTEOHQAjONIglIETHMdtybDOVtCFHSEgdQBDJyA8FlBhDU4OJhOnEbcPSYpZpaJrt6FTwerM7GERcIUgIRsAwYDJmJwzCQk9xASTUiU4QtjWEJQdKUMp0yynmUhqhg1Gv0WofnzYtJ55Jpzqd3mjgx4y4YZheRAFipf8/S/8VbFuWXmdi0y2/tvdnn7OP99fbzJs+swpZBgUCJEA2CbBFdYvBbuihO6SIZrRMhEKKDnVLEd0SRbURxaAogIBgqlAACiiXVekq3fXu3HOPd/ts75df0+kh+f7HfP7HmP8Y31TOG/ZfbJ3NzdVuXL+eTifciZ8tFpJJrV7fH/b7hmafnnQdLyjlq5VycaZWVDXgRbzX7kJVtQwriNnx6YGh6KqmepTMLq5cu31lYb7YODu6d/exM2aWVS7XZtdWlxAM3GGrfnZ43nJaHXfixwu1udXFwsLC1NzCSrE8f9YYH5/1e6PQPe9+ce/Jve1uuZp99Y2rQEYaoDOztYkLnz5vtTtjWzM1BMb94WTgzE0Xday6njc7n/+1t2/pbPL5h/c+vN/px2qyUkwocLpg0NA7OnKSpZRhJ5xhqFpkbbUK6KTZHFmmUSqmUiYXrk+Fmi9Nt7tdw0hHFIwdQCMKEL80l6GBf9L2dg7qAoBcPnd83jTTKFsoNxq+bekx457PoK6etfqKigxVj4LIxCCJKGCcctAbOoLDCxvzwbBtYRJFYveoJyXCkpdK0yMPtTp0fRq+94139s+aE5/fun21c3aaNODcTFlIYGjW4fEB55Gi6Q4V/QHVbbOQMyfjLqMiX5q2zKRuayGllenyX/313yzUSr/9vffuffKTJ0+ef/Srl5lU4vK1m92hd9psXrpwKZGy6mdHW9v7V69eoAztHzVmqvOvXL/2+OGHX325/3f/g394cLj7p9//yd/7zb8zHnWxjlfXlgb9/tjxXcf/vd/9J0ixrt24PBm0JKK1hdrIj57s1IvVqopwNpF++uCeopBqufTsq3svt8/0dO7p45eLK9W///e+e3Z2+Pqbr3z2+cNSZeH4tOmMhj/78LNrt66XK+WtJw9eu/OaF+gI67/7m+++8+rFwB0EcXTr+o2o9/JX9z5fXyuk0smdvReUBjGNU1ZqMpo8f/bS9yJn4v3kxx9eu7wKBfjiq+Oltc2UlXrweGf5wmJ5ZvkP/uBfffLLX9qpgp2t6qrROj/Npcw7d26Nx94PfvA38wszimZ32+M4jHgcm7qVspIsjpKaeO32lWxKP663Dw8byWxGM0iv2b2wecE0lWanSxkajUa9Xt/3Am8yYTIe9SeHxyefffTxcBCsLV9UsL63vev7brc/HPcm09PFUj6pK0o6k52ZKs2UE9VSst/rDAfdfMakNLx/92GnM6RRlDSJphgJy4RQqZRLlVKufnR6fPQylTZMQz0+2DVMsrY0FwYOAREEsWEoNOLphKkZ2l/84K++/+c/e/RgR1Nylel5XU0oapJS0B+Oc6WKAJqWLMUSbVxcW16ba523ElaqVJrKJpTf+/vvvP3G9UK5sLG68s5br2QK0893mp7AQ8fZ3Xu6czz0Y9Drj2jYd7sno27r2bOXY8dbmJufr83WWy2iJVYvXJlbWQ8YMO2snch3u97RXgMElAaOadv5SunajdvlTE7T0O1rV2tT1ZPj0/HYiWMuEVqcm6vVVhBKts5b7Wa91Twx7MTUdO3oZHc8HM/UasRMf/7pfS6Eahje2FlYXT88OkIQ3rg874wHM9Xpi5szvd7k4dPdw4Oz0PHDYKInk5lCdTga09AduX5z0O51zpHCFhfmcplSJpM72dseO6333nvj1VduEiRv3rhpWVqn2z04PC5OFaZrM8+3Dvb2TwhUS5U8oDRkUTaTVbHeb5wR5s9MT+UK5WSxsn1w3h7y80b9W994/eKVjVbfY5SVMsnZqUoum3zw8PH9rx5c21zUQbi5NJfUtaRlFMvzRyen541zDUrEGQcIEG0YSiNZ7Ix813FyCWO2kpcCSggXF9cCKvsTd2F5lQcOpMGl1eqnn3xmprPXXr3ZdsWL5zvVqdLs/Pz23jlQ5cWVlBPIk6Nevd5cWb36+rvv7B11mqe7a7W58SBudlq5tBl4/Utr86aJt54dF3NZjcDBwAN6Nleea7c7Z/vP1peLphIe7DwzNQmiEDJx5cqlhYWldCY1GDs7Lw6BxEKqk+EwmzAJFLpuVOeXBs6QaEoYgHZ3kios0Bgd7G6tVKZ+/z/5h1KA//5//BOJ0Le/cdub1C1Fn5sqffv9txOWMnImpp3stpuHe08r09VRGGMklmZyzUZj0OspBMUxdce9V+7cAhict9qmaSRSybMW//zuo9rc4o2Lc53uAEYhZrw2k+t0x0Yilc/YZ2c9VTN7w/bMVDWOAwg1IKHXH1TSWoSNrx7uZfKpdCZ7enBi6iQO/dlSWdHB3mEX0GB5Lj3xwGl7mLLJ6+smVVI7R8HTZ/Vuv7ewNF0tWJqgvtNTNCOdz0bxxFLlnRsrhZw6cag77IqgmzUhjbmdnm4O/Uf3P9dl762blW+8u7xxY7UXq7/84OXJ80fXV2befXNlai7TrI8T2err774eC+XwsB36w9Odk4NnTxrnJ6PmYa1qLS/P1OZmXhz1Hj54mkvYhVxhMnSCwK9VUotrs7OL8xKTg4NDbzSenlnI5PLDwSTwxzPlSq22/ODhbsJC66vLvus82z6Eqp0uFbaePdlYqBENt5vHOpbZvBULNPRY6PsJyMMY+D5XNZJMpe1EgkCkKDBp6oYCgURcYglkwiTZVFLFWj+QQcQlVimVrh8OJv7I8QTlloIMTdoaSdqmqutOzJ0gEFGcTSq2oSFFJarKhaQxRYJnErahqbqONYhURG1dAUBwIRUAMZAISUpZxCMBIBKUEKhqOpZESqDomucFEgFdJSrGiooZBZHrKwTpRAEKChkNg4hTIZkgREEYKQTbBrENTdcNRmlAo5AGUEVCAkyQCqCpI4Vg01YEhIOx2x87AKMoDhUkOZQxY14QeMEEIZgwrXQyYeo6AhABHEvg+dxxgsD3GGeUS6xCg2i6oUAhEIZJ00wYyYAKzhmTjPJIAqQgJWYipiL0fMoYVlQueRD4QGIgJZRQSC6ElAAwyVgYsZhLgAkh+GseF0IIQsZZxCjGmCiY0RgQKLmECCAFQ4QA5yomCGKAJMJEQoKxwiWgMVOIKqRUNIVxqahEVQinXHIphUAK4TFHECEAMRAyCnwIMIISAsQFj6TgUgjOIUAIYSaRBAITSDAgAGFAgAQSQCEklBBhADCM/ZAyGlEOAIgpwwgLLgGQhBA/DDkVNBYKIQpGUjBKvy5zohhCIjGEQDCm6aqumEwwjIikXEgqGIeIYEzCIJKMRzSGGEWhjyGUEkIJVKxJCCDEsRCUMiQl5LGCIaexphEJOIaEcRgJGbEgCgIipIpjgwBFRV+LTyYiTUMYYwVjzqQX0MDnkyhWNV1AhQML8RDxyFBUQAMNspxJNKKMJ5OJC2IOvNgnmCc0NWGpaUMxiPApi+OIAhFJEXFACDEN1dYNWyOaIgEQjhP1R+5kwrp9fxJDTICUhEZUUzEWAGI0CERzHA9CTgFCkCsEcwqp5JaF5mvWXCWhYswpwhwpEGU0nYkQMIZVkM7YRNcag7Hrh0Q1EMR9ZwRJnE7amAghYkJobXrWd+HR6URLZl3XIQqen5vNFa1KJWlaSjJpBS70vdAgiCB4ft7FJK2baXc0XF8srs0W4mC8tXV6ctqem5+dWyjmc0rCoM6kNx5HABqGnioVk5YlNpYrM1PJlIYSqlRJ4qO753/ycfPjZ+5XR+NdN54u6//J31v4j747U9E6QSwnZPrB3vju816loL96e90NRudtv+tCoSjdoQPZ4NZG7s6NS/eOOv/1D/Y/OQa1xcKVeVONe4WsUiqm/Mk4kdGIgicOoyp8bzrMEnHmoxGVggYKYLpiOIyXaxVv0EoCVsoU9w86PtFCP97MYaCYXx106y4chfGVBZUMG9IZLC7M9tutiIPiTK1fPyvaKAmViIZWITdmkGgomyVh5A8dPvBho+HMzuZSFvYZ0rLTh6dNyjGFQCLFYM7n2+eKBt66fVF4xy+3H1979XaudvkP//xjDtKNdr/TH408hwowu7jgjibnZ71qNZlNmAkDzk2XpytFwdy1xZquUAyUrWcvTg52rly9vXvQ2dqb3H96QEwlkyGG5sLIuXpprdFr2Tp9/81L/+i3f23vxP/+T+93e507t9a/vPf0yYve5DVcLwABAABJREFUyuZMt7H7//iffnDzlQvZcvqT+59vrl99svVU0XAupX73N18HfJhJcOqOnj87ubBx/eOPn/zRjx9dWzWvXV1ptcfZQs5Qtbm5hYPjbmMiMpZ+fblcTMvf/jvv7e/smFbiy/svthtyadbWdeOPf/pifyTm1m78wR/8SSGfy5fT7e7Rq7cuXtrc3H/66OikkSxebEz0v/7gpcb8dKH40ZdbfY+ur82lTO203do/HyYzaRvHOBreub25sDT/1cN781N4vWJ9evdBNmPcvDgzY/u/8d5rb7z5WsjFaNBeWarMVqba/eDLJ/U//POfrm9umJq2t3NoapaiqykLW9zT3PZvvl77/X/85lzZYky/d//pxfXVxaXpdq8fcHjWaMU0VHDi/PzcNpWpSmk8ic6bvXASEU3pDbz2IMjlc2Hg9p1g4MJmL3p5cOq6DUKMiKlf3L3fPjuWxNo9B3/yk0cPnhzOL9UKuZQiWNIy11bmTR2x2FmZS6dsrOA4nTYMA0nhF0tTz7aPP/7wyWCsThy4tXMcMNgf0R/99PGPfv7kBz/8WaNehxGzDKAocHGxujibKabit15ZT6i+hH4oxc7L/dpM8h/95tX3b+cuVsJ3b5TnytZHH3+wf3T2+Pne7s5LTYkKBvV89+V5b2tvu5bB795Yfr59sN0yfCyJonV7w+z0OknWDo7aqYS2uDTz7Pnx3eeHQ8cdts/OTve6g47rj4rZ1OaitbE2IxXSmLgMKPtHZ1989eRXn391OvK+erj780/uhgIrpnb54oX52cp4PNg/OnGjGGNldm7NSpR6PWcwcnnEL6xtFvOpVIJeXiutXqhqquFRWO+OBDIKhfzx/rYf00Cg+tnu+mxxtZo/6oftUEvlChrgw05LMa1qqaRLBzDujf1BZ5BN5fu93uHRbkgnlanZwAcvXu4mDX1tZflgf+/5s71MsrA4MxN5w5tXLq5vVnUcKdB577XLy1WdOI1yQsaBM6DK5y8bUkkAFnjjMdNTp47244+37n/54NaVpQurBUHU43P38HT/G2+9ntLA9knvi0N3FKlrS6V//O2bvL1HEJsrF2xTFrOJft/pDYZuHCEWzNSqvhMU86mkhXQZLE2lHj3dG3DzYoVkTNuN4tvXN1OqC4CcCHze7N+ZM/7x+xvH7f6//ONPe6GpwPjk+YvX79xJWmLvtDFk4mjnbpK13nt9+bTrbjeac7UCdp1aIWeAeHB++GtvXj1s8b964vzm1dLFWmb38EASLZcrjt3g5LA5jjSXlA46EuoFleCjg/3K9BxGcqmsAmLFMS2WcpByQ1cuXr3yyed7E8e/sblmCVooJKYqU0HgxyIs1Oa3zgbr0aP/8f/8Tz2g/bf/zz9UNbJRq5wfHbQc+MXD7fTU8vLS8oWN+fnZKcHB6dHJ8nRNseHI9yQBIyfq9x3DTL7Yfpm2rFevL0+6w+5ZPZefmlCwXx83zk9ms4IylMolpfRLuWTC0ltnjUq14DmBbqU67fis1VMTiYE30Qw1YEwCa6pSqTfBi+e7F69uTiRsB3ISkoixqSmTBvLl3gmxNF0HPA47zX5A5ZsLerWk3m2RD56Ogwkvp/WL6xlVysjpOR47nJifveiFk9GF6UwkwZ5IY6EmNVjJqZHXQVDPVTeGI7S3dZ5WzZvz2m/cKSRz4K++ZP+fnx4gNXtnBVuF5IPPH6RF41tvrowm/MGer6XnqJLrTdDWkffhJ3shlRdWC+/cLLa7/V/ca6oamV+Y88Pw+c6e8IP1+ezl9alCxrr37PS05S5ML+TS2B/3t3f3C/l8Lg2ePd8u1ean5xfPz09PXm6tzq/W++4v7j5LlxfjiDjDxko1szpTIChuj70Am3mTSSy7o6jv+VICjLiKMY1DnWCMJQUsosKLuUTQsnEKRZ5AEijphGFgoUkecdgd0kEIfE+yiEHIUpqaNFUuYdeLz4dIRVIlkEkZQTWSKBSSiDif0TFnPouZVBjlhoqg4IwLKnkcYyEgwUjFWDMtTcVJC2czKGlJiEAIwWhMfTdUMVKwohkqRogzZhmqAoCuGBGXQ5+PGUcQ0UhEMWWCUxFoKpKQU4lkTKRUCQY6hClbNQ1NUTGN4oSpG5rmx6IzCGOmSKRoEFMJAMeUcUZjFSNTBRqJ0wktm1RLJrF01UdiGIjzzphyxmMeMaooqm6oChAKQYjgMA4BlpqiK4rKKItiKiSDRBWYxDQW/OuoLpBQICSAlARAIDgQAACgWIaQAnAqaIQQUXSdM8gYlxJJLoiqcCkghkBIRVUBAzQWjAmIMEAgFpQJKSWgoRdFIYKSKIhRoREIJFAxDCllXCq6TqVkTAIpiYoJRghwQRSimyaDFCKEECJERQhICBFCAErGmaIAQiAESADBKZeAAoSlREJSCQHCWhzFgGCEMYYIYKzrOhMACMCYFBLqmo6JkrANBDFRNIiJEDyWVAhIIAYECCillIxyLgUAQAgBAIQIcCgEp5QzTdMkFJgAjIEAUEIghYxYDAnmQjBKuZCmqSNDRQgDpAgIKOVSAl3XGOCKohFVy6VSuqFAKbCiIASIphIILU037UREmee5E9cNwgBBxSDG2A0Gg8lgOCFYNc2EH4SIIM00gKKGsYg4h5DqGixl7ELGVjWEMNRNI44B4CwIY88Lg4hKLpCUAggEuG6oGlLCWDAuPD92w2jihQRCIAWLqWQcQSgkiDzfcx0sQVYnuaSaytiJlIlwlEslqlMVw0i0uoN2t80AjyWmHFMeWwRUZ6d0RUNI7Xd7/rgfR0wwEAQCYxtBahomUk3AWT6f9SP/k8/v+iEfdZ1mq5tKJiLm5jJ2KplDROl2nZ0XT1x3LKDWaHTjINQNAhBTDZVyet4+CnxP1fHFzU1DQ6HbtyxTCL3XHeez2SimkR+OR2MoeD6diqK4N/Ja7fHf/u1P/vaX25aKNlfzryypb12bf/P2lbSZfbnX7QV2DBL37z7fetpZWTLv3LrUOm+eN12f4VAwVUWVkvLmmxdnl6/90Q8//Xc/PNZi+erlmbKNn79sAiHKxan+WHZCNuhHajDSYHc+ryXKsxMMn2012j1uJKx0QnMnnbSOTRWcnNGN9Y3WqN1ypc9A3lbmyvrTs6HLgKnDrKnWZqciOspnrJiyXrc7P5NjnA59mckW2+OOFBLzaOwEGhJJDFKZXLvvnDcjTYO1fCJwJ0DKKArGI79SSgNGgWRjP5DUubwxyyV4fHfrwuLM1asXfvK3P0papDpVfLH1aLo2bVlZUzcwBgPHMxMF27acwFlYXDESyacvXmqaASGcTPgkEFu7e+vrF7rt1qNHDzrt5tLK4q9/97333n0nZecv37rjeKGhqBubr1165Ts//OW9+1/t/8/+wa/903/y90ej6NnT59l8OZfNfvjhpzevznzn29/4oz/+wZs33tg/2psMh0sLC1evXKmVaz/487/MZMpPd/d/+x/++qDT3Nndefv1O4uztf39k4hGg16/MpOen62dnLdfHDSNdCqTz0TBhLHIi+nO3uHnX+0v1JJdX/vBX/5Uoew//vvf+/nP/5pjeePOG5/84oPYixdXFj65//IHn+4e1Pu+O/yLP/3DMeMX1/LFTPL2pctXN5aSqlXMp4bt/qWL67dfuaXoWrEym8mlR8P+bKWcy2WHzjjG5vri7IWFcm1uFmjW4eHZ8cHON167tXH54i8+/OLzz5973vBb33gLERBFTqWQiekEEo0By8pY7751M50pffrVyX/93/1/O53G229c6fa6D+6/DHzvG++/nkpmjo56nWb9vW+8na0UEJSmhTTV6PUmzc4gdEYrizPueHje6bS748FwqFnJxfllO1We+FwxslSq+XINItBoHKZ17c4b1wXSu+MAYKVYzOg4glBmi7VYgPGwG8QTVSFWpmjbOTuRSiZSyVx+7LnnrWavW3cdtzfsX7m89vZrV69cv9MbiRiZ1159e2V1wTYMRqDP0fF5ixjG/Pz0Uq04Wy4EXrhz0Bl7jFKNkZSRXy7PXaTEKFSmw1hvtvzOKAjioNfujidhsVbDhl1MJ+fLqYSdcNwRBeLkqH7/2WGfqkauenzWCILJ6uJiMZdfv7g5P1NNoSBBZLvb2t0/JFidX1qgnE88lsxW55fWMvmCRpSZWrVULASue1pvPn72cuQz3UxpuhUzvr21++mvPq83Gpl8IWHaByd7T548VhQsOWIQxG4YxkEpldNg9OXndyfDkZXJUaCmUqUogk+ePffjQFIa03C/0fck6bT6/nBw7eqlZMpKJJLZbB6qCSmV+cWlarVqmknDVHRVozG///Dh1outcqUcx3G721b15PaLfddzl2ZXr924dXxyfnhyvn7peiqbIcSem5/nEnDGdg4OCNFp6KdgZCksEsq9vd6Xd59+551bauR1+2M3wglTMzSYNDXbMg9Pmx9/9EWmkr9681qz3QJEcd3w8vXr7751R9VMBeumTqYzOgzp48fPrexqIMlMrba5VL33xd1qbcEw8ItnW47vl6emV9fWMnZq6/HzTqcvIbqwtpRKGM+fbUOs/fKrR83Q/p//o7+bsnAqnUEINc+bppZYXNpwnGBxvjq/XGz2urfvvO1GkU7I+68vPv38wQePjlY2L9+4ML2/t+3FLJktb1zciL1x93RbQezxk6e6YTeb3cbpoaHwmaUl07ZOz+pJW6tUC189eDEceZuXZp8+vJstVIbjybPH90buSCD96KR5fLA/O5P/w5897E6C3/9f/LaO5MHBeak8ZyXThZwBmP9nf/jjL766f3TULFVnDTtxetJs9oKlQtbAAgqk6trjx1vDSYihsf3ixebK7ExFe3nUx6o1OzM1HnT6zSG0S0RFw2F3uloBIijlUoRgU0FJEzAalktWs+e4QQShipCIY+5zkFXG8xV070XnvNm9vFxDgQcRlwAQBVenLYBR63wYx9DKpAqFnG2Inb3W7FL14ore7bifPTt8etTWzdTFlSnMmUFgKaWGLn241R1GJJu1wm57txe3Al3gRLGQHY7cnZcv8/MLkaJ+9vmTjz9+Pnbi67du3X5tjrvu8fYDLKNXriwvLhlffHnkBeP333hFcPr0+Z4fBBEkU7Nzy0u1xt7L/Ze7yUT+5u1rPOj94pdftZrdcrEiOX76Yt8NYcK21haml+fzB/s7W9sv4hhP1ZY0w+qPepcvX1E06/Bga2V1plqpPNiuNyf0e7/+G5WUMWzshZTVRwjg5I0LswsFHUNw4FBVszLpRNLAsef5YSyEZEwomgKARFDQmAdcDoZefzgCAtqpJAThcNgTTFTLuZm5ctpWIYpD34MACIhdNxq5E00nhqYSAR3f6Ux8TiGgMvJHAADKeHs8iYNY15SkglUEv+6Fx1glGjYxUVQgKA1COvHDvuOOXS+iIqnqCYVYmKiEcMiFqgCkKABAyjRdV3VdQgEklzAGSErGPCcIqJAAQMGl5CogYRBoqqZjGCMR0ziiQCoQQ4AgUxHQVKxDmU0nIAJB4Hsi9v2QcW5pOkcSAcIYjzhjlCmqqhmaqhBNI7mMYRFl4gYh5c7E9wIaRnw88SIGiaYruqICoRLsuXEQRZxLSKCAAkhMYyYpR0iNGQcSSg6lgDEDAkEIJZIAYgIklDQWUmJFEQJ5bhgFAeWhShQMACQICMFjCgCCEDLBIEFISAgBAFAAgAHECAMuFd1QiYIkFFwwEcdcEFVBkCCCOWecMQUjRSMhYwBiLgUCEAgmoESSMgwhlyAMIyywpIByjiCGCEJIOJdACgAgAlDTLAElhAJhzCRnPNZVVVc0zihnQnAeBxFlXCGKpikqIQAASmMpAKVhzCljXFU0VdMRxpBgKQQEgHOBMEIQSAgR5ogABAhCEhGMIMIEcy4l4AQqBCoQSKwSDDGLKZBCVTXLUGJKo8AXEsWMKkSHAGKJueCcMScICUBIBZILXVUIgnFEwyASGGiGjiUwdZ0J5MWUAzUSzA+l69Gh63IAEQKOO4KMqYToCqFRRDlVkJJMGpW0nclamq5DjDmXQcTcIPSZ8CVojwNKBVaxrhEdY03FMopjxkMv7o/9QKBJyBVdV7FCGQeQ6rqGkYRSxhCamlLMaIWskrIUIgSSslLIlIq24wZnzSENOYAQC4UJ7kbM9aN0Mg0EP6k3Ov0+pVzX1aSp2AkbErVx3mHULZSymqYYummp5s7Ofqc3tG1TU4mRMIfjfqWcX15eDIKg0+ifHTUgEqlUrt8bhL5fKKUKxbSglMeMBr6hGVBiK2EIGagKm19Y9oJg76wpMDJMtdNue7E3Uy1urC4wyeqN9llrvLvX4BR+6/WFb7998eKscaWWXpnODtvtF3snfU/fOhj/8K+2BhP2xo3Uylyx2677XpRKFblkKmaVLFmp5ZlI/eGfffxXnwUZi7/z2lTRCBr1VjpLlmdtyPjuWafZ59U8uLpULBlobaF2Fhuf3290hrKQNzSsOv3+uBcXirlu43yqqsBM8cu7Z40R13R1cbk2cWjHJ1BA4Icr1RQPfYWoyUx6NBlFcZzLWCeHddXUgJE42PdSSUJErEhZm0rqphoy+XyvHjAwXc1JHnv+JJcttDod09AxFDTy0pmM49BaQbd1/fmL/ZXNxVu3b7x4/njQ7WxuLusGW1xaAgKctVrZXE5TkRu4lBDbUMuFkhuxr+4+rk5Vkslk3/H6TvTgweNsPre0sgBBLGX0/vvvLtWKK9Viv9Vtdr0vvrw77Pcsg+w2O//F//V/+n/9wdN/8d/9/qvX5+7d/Wzr2bNsrjQajqMounz16q9/+/0PfvTjSrmg6GoU+ssry5l0stdp/fTnP69UZpqdzsL8gobJ0+3nG2vzNzdnMedh6L/59uuh03/n9TtHh/uPtvcNxZwqFh4//CqTtGJfYGQfHrcX5uzrG/P/7z/97OHz8X/wO6+prFs/q9++/bphqvWzxvpSsZBN/tFffbZ1yivV2WFzv1jQb1+7vjI7s7pcaXVOh72JmbJPT44URGuF3Jef/+rg6IzysHl6GkVBImVjRAZOiDDO5RNnxzsJ23qxe7q7X79+9bKdsP/iL34qMXn/m7eX56frpweIxUuLK5R7CADGRavfw5KcHtV/9PMvf/zJy64jkykjk0CPnz3FWF9YWvRHnR/88M+fPD6vTCUBpru7L8ejHmDQ1pJUKApE1y5f0BXVj2W740YcJJK5OJJHJ82nzw/PG/2z86ZpWmPPGQ6HOcsqlpJ7++cvtw5pIAAEKgIQq2bCDqjYfnmUTJqXL6wl7NTzrYOXB0dRECOoPH2xryC5urY4VZ0ejvoAqpigyO9pmh5QfHDe3z9spjKFdD4f+zQIsGYkCvly0kQaCgxbcIp2t4/2jjrPT/v/9s8++Hc//OCDXz2chMDxwcv9zsvd03bPS9jpqYJ5fXOj1R798uOvFBXOlq3QjUxdv7ixCiBsDzyGkwCbupFNZnKcRUCSKKCXVpbu3Lyyvjo/PzOXys1t7+zff7CVyc0xnOhPosG4nzbVuenK3PRUMZ/jAkqBe0N/76ROND2dSk4GQz2ZyuUrXCrjrtMfDJFAUkiElMHQDwIAFf3B/eeB21ldKFWq5e442Duon9bbZ81OIl1OJgoSoEo2g1QsBJj40fRMbTzonR7vB77PpBAKMVKp/ZPzvf0DohBL14KxF0fh1HSRChgGXEL2zjfeOK2fN7qOopuff3Gv223pmn7x8qUHj56cddrpXOG4fhpGUcpQVYw4k44fzC9UE5q0NZEqVtqj+N7D5+3jo3fevK4k9FYv2Nk9LBaKiiaL+VRleuZXD+p/+dcfbly6sLky1+33Gs3RkydbimabimanzKlS5uZa6Y1rpcE4PBk4EUqfN1tvv/1KOad99vDhrVtvbCzPfv9Pf+r7aOJF333/XSB5vdHqdAcs9t+4tXZ1JcdpPInIf/Uvf6Ajsbm5NOx0A26ctEajcXh+fprUlL2dZ8sry6f1zv2nexwlGr3hKzeW/t7ruZ88i7//t3+zvlB975XNbr+3c3AoIf+93/lWPoEsE3MAQw4X52sKDHr9oaboRirX7bSmSnkqRLaYf3lwMjdXqxbz/WEvm7XuvHIbMv/B/fvLy2sQ0ubJ1l5f+df/9s9if3T95uXuyDtpDq9dXoHB8P03b3/rvRXX8375xb3tl3v5bDGfz704OLcwWJivSQZ1w1pYXtjdO9VS6V5n4vTaN66tGwbY3j+uTmUyqtjd3g5g8erVpUG/S7nPKbcNzTYVoshMgvB4Yqft5aWpUc/1XS9fmE7nsp3uGEejQi4bRKDXdlUNVHJKQhMQxNILpIimpwpWwjpryeN6bzQY5zIWNI39Z0e1fHJ9JRMzfHJOH9w/s5OJuemyJibzeWVpWvWZPDhpJQx9JkOO+uyo6Ta6TsKwblytdNroV1++rCxcLEzl2o748ll/e/9kqVb5O9+8WEgT1xdOZ39+pnbpxuKLnbofDb/3rdeWZq3O0Gt0Rt3BiEasXCpBpj59eRR69JXrG1Yi9/DJXqfdLRUrRLEePXoiAFYNpVbNX95YYlScnTSHQ0eK2POcwI83NpcUFoSj1urGascN/+TPf35Sb96+vvHGzdV00miN+JePd0aj/vWNmZQJQ4rOupEf05ShpW0VchYL6UWSM6QgqBEsmZAcSYS5FJ7jx1TkE1axmMYIOZMRkXHWIkmTSMmE4EBISBRb13SCLB3rJoQIeVHsU44ITOg6AVRTdMqQFwoF4ZStJVShqtjj3IvjMI4xhioShqkqmIQxH03CiS/jSERxbKpaSgVpnUhO/TByw1hIYWk4kkJipinYIMjSFY0Q1VAUhKngHAqoQEPXsIo0ReGMYoUAIBzPZUJCSTSiEUgwBBgAAbikgaWrAILADymjcRRRKCDGMaUEIVVRMMK2bRPAkRRScFtTyrlMwlCFhD4XIaWccUrpyHFdz5dUAAISdkJIGX7d4w8gQVjFBEEhAeCCspiNXJcyThklCEMhGZOUMwigkJBzIAFgsQAIKAqRAhKIpeSUMSkkFZwQVdVVxiiUkCAipMASYwSlkAJAzgUHnMYUQCgQZJQjCDkXbhh4QSCZBAAxzhkXACCEkYoEkBIJwRlnlEdQIZRLFWNVIxBKjCGCkktAJKacAgA5oIJKJjjnjEgJscJiISljjHMIKIsQIYRAKAET4mvqsBQipjEXHAHAOVeIAgFUCKaU8igGgscsAhJiQtDX/wcSE4ghVGPOmaRAgMgPheCcM0VRBcVhHEdxTAWIo0ggoCpEIQoVPPRiKKWqqIwxDBGLYhoxAXkYRowDAqFEgMUMK0LFmAnBpTAU1VQ1jATnkeuHThhFEQ95xCLhRQGASFd1XTcVgkxNS2ZSBMkwjBnlnFJNRaVilpim78ehT2MmhmPXdYMoivyQjYYBpYJKoROiYaQRRCBkQMQRFxJQCoIwUqDEQBAMTMtUMbBNpBKUsM2UqeWyiUImYZsmUYmqqpZOFEJGI/es3pqMA8U0ddWaTIYi9JBgtm0Rw3q5dxz5QS6TTaXzqpYghMS+f3p0OAn82cXFfCGLQIyQ3h/TdqebT1m5tMkYgxBeurp55eqFo5PTs+PTYa8PkZxfWHPHE0NH83PVXCrnDkf9bj+dSWeyGV21bFtPpmyIoG1mTo8bzx/vJBPKxuqyoetzs9X5uRoAbDSZ9DoOAFw3E9NzizduXU/Dnuxsl2xdTxd2jvv7526zF7zYPayfd1c37HduLWYSqgLidCopIDk9bSqQ3r64dH1jzR0Of/i39z56Mi6WjFduzhZtFXI3lUpmk0XEQbt9Fjr++nL14pULPg0S2XTTcT/89GVMweZaPpk0IHI5xLXVCkUkctjapSuPtk+fnoOpJKoltGaz+aITUUppKOeq6kylADEKJen0JwpBqkK4iD0/ttMZx48ZBlg1/JAVMyJpa7pm9gZOEMnp6WyhkPXikKgaJupg4CMCGY0sTQm5aDpxoZBunOzZSTSzuHJc7x7tHc9MlzRLm67NnBydHp0eGbo6GI18V5wcN0ql3FRlynHGg95wdnZ24rjNTvv44PTkcD+VTq+tbmia1mic37nzysb6wvRM4fMvP/vZL3+1t7fX7XZK5em9g9N7n39x/8vD/+r/+Lu5FPnTP/iTUrlSmqpyHqmIr20u9VutR/e+VAhYX19vNepLS/O5lO2OR0+e7GTyMwLhbqO5vLzy8PGjdLqomShjCZ/h2tycEO7S6oKeyv7Fjz4xdGvz4jxWJJDQsjJHZ62z8w6Eym985/0vvvh0OPC/+e7FW9cvP372dKpacj2+d9Q/Oh4s1KY+/uCjUa9//dr8G6/fJIhd3Nj0gsm4P9x58vjF1t7C2vp506VAAxhRf3T50mY2mZoqVsrFdMpKUKmbmezh6eGUzXTTmjAydtmP//bDhdXlZnfy13/7c6JpVy9fUAhrnJ4Uivmpmfmjk1MvFKl0NvIdAzFTRYVSIZnODsaD99658c5rr0ZB/PqdO9lcLpPOnZ10ECTXblxZX571ndH1S1d0K31+3u31R85kBKXcP6o/3todeFIxrHwhLWJn0G9ihZRyyVQKuaMOY4E7GiUMY2lpTkhBaZTJpQCSbuCrqspi3u/0e636rZvXLmxustCBKEIQWEbCi8PBsFObyl67dcWyrYjJXLqgKbg6lclm7GeP7rZb9X67u7Pz4sX28x//7IP7j7cmg9GzR0+ePX86HDorK5tTU9Mjt5vO6EnT7gzj6cr07/zG+5Vy5vjo5OXeXr6Qrk5NVcpTrfPz87PTciWZSqVGY2okEoaOg4gKTk0sx5PeaOh32+3HT56dnJzsN1vPj89e7BycN85/+asv/+ajR/efHB4d7o1CtTkKe45/3hlu7518/Ol2HAe5lPHswVdnJ/udbnvk+hLoU9PVpKXv7Oz3mqcIypRhQgSAxGfnDd9n6UwpW5pt9IbNxnn99LhWm718ZbVSLiY1O5FOTk2XqjM1RNROZ3BycuwEMcIKBzGkjElJmYiEsO1MHMt0vhLG9Ghnf/fp08j3Ys6Ho4kUwgnd2J/kM1lD0SozU+3zeuiMpyvFnd2n1ZlFzTDb7fMnj+6lbO312zfHzaY3GWMpAmectG1EiETEiaXn01S24EwcFQsFEyL1IKQb68s3NhaG3cHOweG1a1csEI1bJ4VyIV9Kd8fB3S+/KOTSEGKKVD/kX3z2GaUhiN3dne2zev3a1UuFvHV+cvzwwYOvHu7/+Oe/mJ6b6/bCP/7+X37rO789s1B69Hz74VdfPX3y6JXbl9SEbaQKEYvqhy9fvbm6Mle+cXXDsPX/+7/4tyrRM1mrmMt1HYZtGzKatcHCbHHYa5XLpVZ/MPGC/d2DzunB+krlzqub7Ub8ySdfpNLZQjLNGPvgZx+P3eHFSxd/4zvvX9pcOzw8S2ZyK2tXEgn9+GC3NjPrTVgUOeVy+eGzeiadP9nfK1cKyaSBAFicnbl46WK5mO+0z9//1ndff/udtZXlyST8+Ge/CD162hp/9vDu/NISgrTdOr54+eLm5vr3vvcbuqHt7z2DCHuuM5o4imoSlTx/+mxmakYluNsZpLKZs2bTmYRLC1XXGXvueH6hJrD+5VdPF6ZKpmGenU+Eao+CgEZ+MHF0W439QNAwoWNNQbsn8UnfW11ZVmk8CZlZSMYc9Ny4057k85m56SQCAUIiDumg3Zmdq8xNKwTrrf5k4sEbm7MYgqdbDSTk8nw1k010HPF8v57IJPIpjQbj5aWZG5fKxaJJuZvP6NMZlUWs3fOfbR3Nlauv38k3Wvz5k2e5fHF6Ki2Q3D9sf/XR5yc725l8Sc8vSyqH7Q6UXNHVL+5tU2988+r82nzWxLLXGR23Oi4TiWw6nU5vb7/oj0Zvv/FquZh6ebBvW/bG6qIXhAfHdQhNVcG5bCKTSxOFhJ4fumPJok6vG4ThXG1RwySfSb5y7aJE8u7Dx/sH5xk7cePKiqVEPgfNoSORNl1MVjTf4Wzo0HHAhUSWaSCMIUZeEMSMY4UkbBuqUtMIJFookOv6nh9CARHCEUcTx8WqUsplpnJZQ8NYBQoSmFMoYhBGJlamsglTUaBglAmgqAoihEDTMDjkcUwVVTUN3VCArihRTAUHAiIFQ0PFtkFymYRuGBICj7MgoELKpK1nDFxIW54XDUZBQLlEWCcIMokktBKWZdoqAQqC2FC90PfjOOZCwcjWNMhjCIVgHCGsqRqlgRP4vhchCHXdTCctXUOGhk1dNTQdQBxREEkl8uPAj2LKJ35MWQwIYHEMGUun7HI+ZepERbSQMDQCJBeabkjOBJQEkjjmXhBKKmgYAgxjJgWEnEOCcUxjjLDgnDMpJFcUAjDkQvpxRCXTVAIAiKKQKEBKCSXkkAsmBBNCMoxBFMcQQiEkFAAiFHg+hERIEMQhQkhAQTmnlHHKEIJCSsm/FgIAICAhUghRMJGCY4wMHROVKIREUUiQElAupEACQkVRgJSASQQg5QwICRERkgGOMRAScAVjCKCESNVVBAWlMQdSCKooUFVVQ1WBEBxACAEXgGD89fkQB4AyJoCUXCCCJZAx/fpxKIEgCkEEQAAAApILCYDgnEEJJIASEoQVoiCEVaIBARTFkJJzyAAEuqVySoUAClKEpEAKDX09rUggMAdRFAsAAFIiygdBFDHJJSMcIAiQxJRyyZmhEkVFhgIViXzGqEQxhUyi0I8jQSEBAAAApaaCtK7YhooRRQhCBDCWaUutFFKciuFgFEZyHIT93lDEEkEcMdF3okkQYwXbpmmZJPG1ta8qERWR4JEQEYtsQ7ctQydQVbEKgWnoBENCMNYQhBwLRgV3IzEcBzQKuWC9sdvs+wArukYUTIbjMWM8YWeAFLqe2N2rn540bcvSNe345KTZHRKkj50RBOzylY3pajUKx9z3wih+srXn+fHS0mw6bfaHvdnKVKlQ3nq6W292qYQSoWql1On7/ckoYZuYoEkQdPuTdDajG+ZgMPLDWDUS3igadNzzTm/iDKdnKxeWZ1QdOkEoMaJRSLBGsIYR1o0MgMSJgvv374e+t7G+5kry6aPTz57U9w46/aFjJ/UrG7X5qTQP+wk7WSlP18/7h4e95dXpN+5csFX45Nnu9q4zkmRjRb+8YClBl8dsEuuBL/rjrktDVYUX12qFjHm8dzQKvBBkv3jUTiBw6cKcqSIYDKDg2WIpZsqjx4eAoOFwcP/pwUwJrNeKeQOft30HJqeM8MZK6urlhWb3vNvrOx73fYqkqJbzQTCRECQsYzQJqACTUeREcioJASSCmM1Oj4i4lDM03WjU27adapy3x24oiYoIUjR8ftZUkumEkQkj8fqNi6qMRyMfYn139ySfyQwH3lmjdWF9eWVpASDy8NmOwOT65ZWAyp3j44uXL8RB4AyG+UI5kU2Wi4X5WhkjeXp4cPX61elq9eX+fhzS4di9dPmKqSsqEcfnJ/lCeW4q/X/53/36b33j2n/z3/yL5Y1NpBgRDarT5Xwu2Wkd1uZnNq9cTqayzx4/4zTWFG16ZunJsxfZUiGbT3/5xZff/LXXfa/V7XtYRblsqXl+0ux0++1Gyk6pZuY//+f/twDiyxcvD5qHBIpyeeqLu89Oz+r1471vvvc6lfzZTr2YlMtz004QDsfhk6362A3uPnhcqhbS+fLO9v6NzdR/+rvf6p+fq7oNoLK3s1eYWTs576wuVTrdUdfxB0Hc6fjV2Ur7/DQEcaWc3FyZVQjwQm9/fxdT/tbtq0yS82bwb//4h7/+nddz2exf/+zuXt3DCKUMGI56pXKp70Q//+ir/ZOmaeW9IFAQu3NhZmW+4oe+opBXL0+9dnV+/+C40xlRFnqT1sMHj+rtQTlfrp/tnNUbgvJ2f7Tzcs9IWJGICAQn9UFrGPVG4sXOoRtQFUiEuQJjQ5WXLs1Vcompgn371uWFpQXK4NPtPagYgoU6AYmEQQjZPW20usPqVHltvjA7V/ros0/2dg9Uk1Dat1IGDdnqwtybr97QAa8fnUEIFhZqN65tTFfLswuL168t3by1cu364uX12ZlSzp14fuBcv37t1tXFQi53eNz+5eeP643h4spSbbaaz2q6YeczpJjgd66uFJKqqSrlnGEqHHBINL1arpQKeSRENmf2e+7eQTOd0kwVm4aWz2VqszmdBIYCkomEbld2j93ydLlQLj3f7zw6nBx26ND1283GeOLPzMyMvOFw5FzYKM9OT1Mavv3OHdvSiWZAoltJM5fQkglD1wyEsUqAJCIKPQihlc1NfLp/3h6OXCxJuVyemZmhPMikEqPxxIlk4Ae97tD1gkQykc4ku/3g7LzVG058z83k0sWUFUfhyWnzsNHdOag3Wj0WwnKloila6HuCocDnR6cNyShHUleVjY2F5tlx4IWjyWR+dmZ+rjwaOmdHR6tLC/PztS++eJBOGfMLJeqM8kkjoete4GIFMEZVRX2yva+nyoWZxf55c65iS508PO7tHJ7e3pi9fTHfaI1yudxbb77K/FE0GWDFzJfLrWarcXa6srIUev5gMNAt9crllY3ZeUzw5/eeHJ40F6enXr00uzids1Pk6KxzcHgigDZx3X/5r//N1auvZbI20NWTemvieXEcjwfDxcVlomjdVm/QrHNv8Hu/8WZxZurnv7jbOG0USlNWUj84OZybn8UIzdVmEoY2HA8LxVKxkEqbxA/58Ul/Ia1cubzQC/hxoysgnJ+uFYr5/b3jL+6/+NUXX7z3xsWl2exf/fiTB8+P3nrrG9zpYRFdvbb25ef3bt95fSqlHh/sz88tdLodHSDTsvf2d4u57NtvXX/ztes/+dlPvBi9dnl+c6XWdEKBzdXVpW7HffT46craWhhFALJ8JuEP2pvrK5evXUkkLQyjT+89cn1aLk9N3NFo0p+qlBqtIYW6YqR63U4xbxuEHxydd4ZBZWau02gevdxarM05YeT4lBDDMFVnPILIQEQDVIpgMlVOUgQOTrs0Cq4vgu2WDHxqW7jX8/u+7AzH+VQ6qauhR9PpZKs5kVGQyxIMQ2yonUlsgvHCXELTwHljNBiOFACBSXbrztbOEdRtIJGI3awNZzMkoSKiaYtTxtRUBpjKSVc82HpZncp9+/WsiKJBu55P2xfWavMLBazjgw57stM+bA59qZi2CYVbzuUUFR+dnQEarMymVhaShglbHbfdcQFC+aSZSWrPnh02zvZW1pZTiezxyV46Y924vDkaOHcfPfNDTAWzLaNcyqUyxvT0jKbqUUz3XrwMGJaKpYDg9oXpb717EyDl/rOzB0/35qrF16/N4Zg+2O81PDo7Pb1RgooCRm5UH8ftMfMFgxCoKsG6EcSUccSl0AAwFAUKKAHgkgw8Ue8Oxk5EORx58qwxdN3Y0BWCgakqSUu1k6aGNaRCJDlBsmBZtm3ENPJdnwqAACBIiJi6MfX8iAFg24auYghBEEZ+zIRAXACMoKXJjKmkTANCIDAJKQ1iigkp5ZOlpA6gdKO478aSC5VgAWQYhYxGhmYiRHQVSQn8QAzHoRtSU9dsU8FUOlGsQaRijCChEaAAhFHM4kjTNJUoEOKEihMJgoDwGIsp8+KYSRRJGVI58ShnGFKh6zoXAitEVVFaV/Np09SJ7zmSc01TNawiAgHGUgKAkGEatmmCCHlBpGq6AJwzygUHBCKMMQKCCcY5UQjGCAnss39PsAoDqigKxkhTlZhGSMWaZlAmgIQYI6KoAGPGJURYURWAgJBQSinE1yBcpOkawkQCiBUFY8IAp4zGEWWUKggrhNCIRbHkMZVCEvw1kktIDhAEQnIJINY1BRFEIBAQcE4hIghDhBCDPI5CgICm6JxTAL4u7MQIIsoFhIhxhjHBEEVhLKWIgSAEQwilkEICJDFGCEGgaxrAEAApASIQszBCCKtY51xwTiEEGCGEkRScsghAKbjgDAAAAJBBGDAOpAAASB5xJKFGCGNUSiC5UDSCERAQSoF8wf1I9n03YnFr4EAAEQSWruumihHAGBMEdAXrmgYED2nsxJHrhG4QhlQEkRAAKooGmQQySicTGABDA0mT6ADpqp5KJZK2lc2lAKCj4dhxQ9/xXW+iaQbWte7YG/vMD6VA0laVYsLSMIh5LDhwA+YHrD/x44jZpmkZChA0imOOSCwEhFAIAQECUSxiJjCmER2NRgrBhm76EewMPMeLNUU3NbXb7VOOCuWpketomnZ2Vg8Dd7paLZUrx/sH/X5/YXYaqRoHcGVtZa5a7LU6g+54PPaPD09Ojg6vXLpYKhVevtjViDI7V3v57Gmv283ZqciLavNz4/F47+X2wlxtamrKj/nJSWvkhcl0UgoahdHEdw8PDk7qDYQQgqQ0VVxZmIE86nebUlAopW1aQsJmu+dHDo0jZ9CJ/NGly2t3vvHN/X74ox9vPXzRlRKVp4uL81Oby9MZm8qgXyzlcsX07s5po9uvVbO5pFI/PHj4/OBl06vHqudxU9IUHJuKVe85Z+d+yKChipRlzdamgWTnR8eUhgil7j/bbQzllUUTSm88GUFC0oZGRLC7d1oqldP5zIvnh5WMujJXEli2/GDkgUwqWUrBqYLt9AbnJ8Oxx00rZaiGAgkXcDL2MxYoWmQ8mURAGQVxLqWXkkpEecfhjfNAAFBIJQTjw1GEsNEb9Uxdh0DRFFNC4AV8c23+8LiVzpnFUm5/d/fw8PS83pmfm05lU4+ePZueninkC+N+u9fpByF77803lmvZR/fvzk4vDAbOJ59+lilYa6tL2XReVdWIBhiBy9cuX7ly5enzZ7Zttpo9VU3oqtZonaqq/uYrryZSdqFoXlue/z/9b/83qxtr07O1kTMslnKzU9NQ8GKmXMjn9g/qOzv712/eAkCLY/mTn/6k0R1O1abrrW7C1mfn5p4+3nLH4+nakqLp9a477DcKpWIyOfWv/oc/iiPwT/7DfzgadDv1ERfMDd1cpawlUtdvXokm/Z0Xu5Eww8k4k9CJ1PdP6PLm6v5xvd2s/85vvm9aViqpXVxf+eKjn+y/fFqcmXm29fzGlSuTwO31fKwYzXpdAPD4SfvVV65mUom97cM3X70MRPzgydbd+w9tVVmaLv6X/+t/OgrkB7/89GefPX/39Vtvvvrq4ycPVBV/99e/+cqtq+36AZRyujY/cKPGMNTSGUapqqIra3PzU/lJ/7x9Ws/o/DvffLPbbhwfnwxdB3CoqhpjFAF9Znbh8pXFYrGqW+n60WkmaU1VUorkMSSKXfQ8MQ79cjGXMKGGcSGTvnzpwnS16rmTOIosI7G/f3h6Wq+3Wv2hywUsFop2MuH6oeNISXG1WpypVpIZtd/t1GqzV2/dSVqFuenF6UIlYSudfuP07Ghvf/fg4IBTsb+/+/Tpk2dPn/3ob38BJKxVpxSMZmpT3/jWN//z/9V/+o1vvpvMqIVy/trVS6ub107ro+291t7uURT4COPIb3Wb51svn2tEvHL98oXVuVw6A6AcewMB1Ikbvny5XT87mq3N5EqF1rCvYFks5ACU407n0ursu29c/d77r7361mtnZ+2EqV1cW8ScGqq2urr21lt3lmandE2ZX6yWSnY5qb52fbVaLTfbzijAk0gYVrrTn4wmnu953U7r+Pg8W5jevHRTtZIJ0zZ13XFGOiGGZWJExoOupun97rjT7nbPzyfjQbvX6w49FQIVo+GgG4TubG3OSlu6oedLpUw61e90paAJK6FrZjpTTOdLYy9yXCeMhGkXUoWZscvP6u2kmVWggYGqa6qdtjGGWEtlMzO6Rq6u18rFtB94L3eOaosbm+vrpm59482bd26sGTquVgqYMRbSIPA1U9UM6xe/+rwxcCZeQIBYXl7c3jr98Ocf7+y9+DvfeaeYUj75+JMb1y6nLDUaDyQPREDNdNkJ2GQ8fOvdVxBBjhPXz+q5UrqQTs3PLQSM6gbmoXflwurCzNTMzPLq8uWAk6nZhali6vHdr2amiq/cuLK6urx/0JieXTg/OX70+JFiJmJoVJYu7h2enR0eXLh27farVxvDcDQevnb1cv+sR1mANPvRvcdIMeOA99v1hampUj4BCUlUZ4k7Pj04mrhs5LLzbn/sOHOzCxCrN26/8vj+1s7j+7/+/ttrizP7J8dcsLdvLjx99KUE4sV+/cvPf/V7//i3smkFEmBaye6wb1v2ebs7cienp8eB17+yeeFPvv9j6R6sby5VipWjRnOmkrlzbenzz++3hqGZyve6vWw2ebC//eEHH8zPLVamirdvXa7VZodjh6jKTCnfPD1NJGw/pN1WV1P1yXBoama5WPYpavVCLmQpqx+d9WZn53KW3Wq3HH+STGXNlE0IolChjLIoLJaK6aTaaA79IFqq5Yim9QduOZ3uDh1XiCc70elJo5Qv9VzgUViaKQW+CyhFcUwEQEi6fWemmF1amNZsxfd9Lmg2aSCV9EPSmAQhIv3+MJiMMdJNzQjG4ygIReTnckU9BesN9+TwpFapXFouaJBFtHf08pAIXqjNJ4o5X4ph67zRnkzGIw0J2zYqlfzQiY7q3Vw2t7k6f3G19sqNDT/yjk/OUvnkxc0blanS6enpeDSu1WrO0Hmx9TiTz25eXPcD2mi1NGxyHnih0272Awqmp2eW5iu6ntjd34+4qJ+fADaenUoszlZ7Y/f4vHV8WL+4uvB3v31DZfzLhy+9MFqcmbmxUs6lCWVywsRwwiMqo9ADcRjEMggZZULwmLOYxqGiqJTzEOhCYiY5BziWSiiIF/hRyONY+mHEudRURdOQqhuAEMfxAhZDDiTEFJKA8jiObFPJWLrgYBLEYRAJxnMJI5cyVYUwLkMqQ8qFEDzmGqQaZgqUQRQFVPa8oDfo0yguZtM5S2WcjrxYIggRNhWsIyBZjKHAiMdRBCWRWA1i0eiNho5j2EkrbSUNlRDMaYwggipywyAWIGY8cF0kRULToGSWrmWStopIzCIpIRAcKURK4LhRd+wxiQVWVIxYxKGUpqYkLX2uXCwX8iymDACiqZALyJimK2EU+1FEOReY0lgMRmMukKpbQAKCVSGRhBhiAgSgnEkOEYaSUwYBJogQxACTAgAhFEWTXEggvk4IxzGPo4hHsRAUSRn7AaMxFiCmAkAMAQCS0ZgywSAEUAIaRZqq6ZpFFAwkYIJKCSQCEArGgUASI4SARESDCCEECBMMABbFEadhzBgGUAIpmcQKiaNIBbqqaUBILgSTEkHIpWAxwxBjhDinEkIJBQBAV1RdUWREMUKMUs6ZQtC/R5FRFguuEBUiQMMAYCQghBALJAnEggEEEYWAhTEHknMhJABCIgAhkABJQoAQTNU0ISTlDBLIITB0YhiGZZtMSCZhFFDXDeNYCIgdN3SCyNYMQrCmKKqChWCQynTSUjQEAWFcTHzqRzJmKGK4O3RiKiBCUggIuKKIhG0ldMVWkW1qKtawqgjOEaC6TngcTVyfc4EQ8gJPRQqGyHHDztB1AqkCmrVMW9Mo9UTMMVEjJjw38KiAHBBFVRAUjEchoBK5AfdiyiAyNFNVcMz5131VEIB80kwlUhPGG/1RQEHCsAxCvChAUE8nE2cn50HEIyp1la8vVefnp3oDb+wGl9aXLQ0022eWlZqqzJweHZ8cnUBo9Mfx0BnfvnnZNK2f/PjTvhMtri/FoTscjKrTs54/zuaSkTPZ2z1dWClWytWz0+69x3utVr9WLZmm0e/0vCjACKXSudm5WiJr5jLWxsrc1Ezei4nnR57nOV5wdN57vrOPELCSehjH2XTi8uaKZSb+5K+++Ouf7wKN37pg376Sn6tYhoq9iTcae4qVsFPJk6OTcTCqzZcK5cRwFLuRMgzweRcct70kjxZm85VyIeKe47mpLCxVlPlSbqpUYhE+bXQjiVQzOe5P/DGozVUUTadhyCAGWINEiSfD5YpWzul7e20EZS5heFH49GD45fZEAjCfCFQiKQvb3UDTSCab8SPPtlUFKWN3aKtqNZcOw7g7jjgCKUtZq9pQQQigsecTDS/VjFyKUH9UKuURUcOQJRI6klQx9U5nvLFQBF77fDh+/bVXX748Pj3rAshmZqaqpVL79CTwaCFXGfZHo1F4fNZOpNKb64uHB/vphCGB9mDrcOJE7779RqWQb/VHnYFn6nqllOv1O59++oVtJpAkJ0fHlmF43rBULE1P1aI4GpyfTZUW//Uf/mkyWfit3/pO7Pu2ZoRe+PDpVn/kYFV7+uLZX/7gw29/77u9sf9oe0/R9Zc7x8VSwXeCcbf52ptvHJy1v3xw8vobt0XQ+9UnH/uRMOTkzitv/MFf/KTe53/vt9/onR20GgfTC7VBf7A0v1guFwALD4/2Ixrl8qUoErMzidp08ejo+cJc6t1X37h7r/7arVe6vfbf/uLDam1W1czhwF9cWPb6zlx1plxIH+8/f+Otm/V6V1Hx2d6LC4up995948/+5Psr89YbVzaCkD3ePitXK9Ml453b17unp3/ww8877cn3Xl+8efXyH/7xX4QxePXm2kw5/fGHv+QCb1y4MBlODk7qmmXSGETMT5s48KJ6x5k4zvpi6c1XbzbqvS8+20om9EqhlEonuJS1ucVSOQMwHY49KUmj1R5NxvlChkUcqqoXK18+3Ts4b89UyhmbFJNJK226ftTrjULP3T8enja8xy9PTs+HfsxTqdTi/ByPwziWp2dNxxVOFOWLGYToaNjq9f1mu7m5vo6xenBwetZuSRRXKvmp6RoG4cLS7Ftv36lW8rMzsyvLG4lETleMesP5+c8+bTa641H4s598+MmnXz15/PzPfvCzvaPGx5/cbdXrd25fvH7tom7nnmwdDj3++s2FRDLb6Ck/++zF0Vk9ZSspO3HU7I2dwE4lzUx+97huWNp8Ja2wyfrKnAGdTCKTzM23x3Rr58Dx4pcvDz7++B6MnVsb0zSatFonKgxrWUwn52fNOqNONZfi4/Ppgn776vJ44tx9evrw5eCrh7vHjcnECy07kSpWYmhpdj4U2pODxqOto+FgjLEmpWh1243zs8Z5IwpC33NGfiyAef32K+l0slwqGKaGRJRMqaVCMg7i0I+KuRQmkPpOwjJzWct1Yk0xsilrNGzwOJZA973Qo8phs//sZT2MoUDaJGB2KtfpO7t7B7Zhzi/MD0YDzgXnyAuclYWppcXlH31w///3pz/dPmj99GcfdIbj6bmZpGnTKMjnMzyOFQASinZxZQHzEEaRbuD7T/c0or92e+n6jcv17uSjLx7/43/w689eHH346cfLK+tJE7/xysbxWQvqqWwp9fz5ntPvvfnK9YsXF48OG3t7h0tLi4YOfXd8fNZuj71mq5E1DRENEUaZSvXuF48uLS9trE4/+OphJZvbXF/WdRy5k4sXL2qQ9DrDz+4/P2z0tWThpD358MOfO75fLZcG3fNiNl3MGfv750hJSKwFQbB+YXUyHg96fU0zGGcx9ecXa+WcVkjKtGVGQTx2A0UjvuMomHzr26/1et12+8y28M3rl4bd03ylkEumx4PR8sraw/vPP/nlx9cuXz2r1weTiZ1IAALX11c++fSzbDY/Ho5yGe07v3bx5cHJqNWulKbCyaB1dlJKZ2q1qXajWymXbQ3lkuaFixsHp5P7j3cwNH71+d2VuWnLVPv9nhQin00GfpDJ5s+bbU0zFCB7zbNCpZRKZydjL/LcbFKp91h/0L+wVKrlNB7HAEGdaCIMpOQQkrETaao6XcyGLj87PGGqPlNN9rpDRQFJGyqIayZ48DLgpmUktZeHLlYNDSu+ywxLsW3NgPzc51zQxTJerJoQgSDmpVRyPm9wCUe+FFLYmnrUCkJIFufKNmITl07CqN0dIE1XDdt14fnJIWcxIjyfSs9M243zwc7LYyiVhanc4pRJ7GTXi4OApnSR0DBG+Lzl7x2fSYgw9OfK5tXNBd/nX9zdHriTpbny3Nys54y4COeXlwNP2Xq2jSB749UrKdM4PD0IQlrIlQZesP1yz3U8XVMK2STGctwfWnYuFiidNBZnC5trVceLDpv9+092wsD/jXcup6V48uLg3v5ktWK8ulktmJjFMuSYM4wlxhhIKUNOYyEAJhAjXdcQ5poKII8E5zSOgMQYAiQFhyIWgkvsetEkCPteMHEDySQUMopYw4kGvicpR0BQiSJOpECaSRQFaJoZcBjGjEBpEpE2dAVDSqkQwo9YEMYQq4am6ArUFcxlhCGOpNofTxQoi3nTVhTfCzu9CY+FrmumZdi2qSpQUbCGFEPFEHJV16mAvaE/ngSMS8syJRCUib7nMYogQgrGseQupfzfG/Ya5NQ0VVMXGiEAEt3QEAISSScIx77fGLvjcRBTQRkPojikImZM0ZFtG0RHEiLJIlVRDVNTNQIQ+NpxllJIKKKIDh0nZkwCRGlMkBRCEIIBElBKhCFlVFEUETNGJZBCxJJKGjMggIzo17f6ECIMgBRAQAULACmAkhDTMBiQECMIIeRIQIIgkYwjiBiXEBJGBadUcgAQEowLKDAmUSQIgASSkMUAfG06AySlVIgCIMJE0XVLUTWCMEQSAAmkgIRIxDDGEkAuGOQCYIAJ0VSMEdIVjUvIowhBIoCMWMgEFxACATBGEgDOARdSSkAg5JQhKbkUABMAhG6qSIgojAASRFUk5yIWEEMIIMbEUDQmpZCMCsG4BAAqhEgmpAREUTRNJ1IwCTzPgxDEAZ0EHoeQShEJGcWxgoiqqkzENGaqCqMojBmjUrgTX0oUxLHnRVIgxiCTIKARl2gSOEwyoqgYQwAIhCD0PIIRQSQIgzhiVMaIA8A55yCdymEEGeeGmUBQGbn+eOIJKRAEtkXStgGhkBBhTZ84ruP5EiDEEcB65MdOEPshd+MISEgjX3IhGA8CPwo8IKWdtAgClqUDSIZjt37WnTiRrhBdhz6NgohzQf3xyLTUQiaPACiVC4mkuf1i5+mz5+lsSTMNz5kkdL1SKba77bPzk3yh6EZRt9ufypWShra19ZQidOHC+kwuf3R8aqWTcexDhNLJZLvdnV9cqBSrD54/Ozht5NOpK5c3yjOVXq+DDH1laW1+dt4wlCAamxqen5t2vODho61Wu6/rCQXBXCYlucgmMsViPo4hwjrSMwf73R/9zT135Ny+Of3unY1rqwspE03GbXc0kDKyddOwU53+5KQ+0lQjkyq2uqODk253HLBI5JPi+oXq6sU0gEqz1fdCNFXJz04VYBRAojU6ra3DI84FhypVEn2Paja2FT4YjScBk24AwkiVYHp2ulQtHZ6eZPOJmfmZMAT1xrjnx9lCar5mVVI8aWV81/PjIDk11+75vh8YCX04HGBi6rZlJ+2z3igMpaEZhbwtuT/ywyimWNJCmtRqM6eHB4EzIaoyGk4UjImUqgKHw4Fl6aVK+cmT8805zRuP+gN/bn4hXyhwGVAWTEYjIvn8XK0/Cc6a7YkX3nntOhTg9PBIAN4dDMeOv7q2gBn/+NOPfvzXP+uNvItXrrmeE7rhs2dbbuB2uqc3bl4npnLlwkUahjsHu+3j07ffefdnv/zosBX//j//51uPv0pauuf6z7d23Si+ev2a4LI/iv/ZP/3ezPT8v/lXf5LJ5jFSDB1fWFvkzEc8nJmqffrJw/n5Sj6T7p0ff/P9d4ql4je++95Zu/PFve31i7Wg1zUwzWTy2WJJCC4Fa54e5Avm/Nz89PTi8xe7lXIKqJgxce/Bk2vX1+9++dGljYQg4c8/vO97DAJ+dHxqJtP7R4fFcqpazf/8Zx/duHoxiqWazAisKIr6+//sP/qjP/7zejv6nd/+HYzRzu6hqifnazURee1W40c//dRKwP/iP/uHv/727QePvnSY4sfkePdw/+mX73/rW6+89t5XD7d/8sHHhqrrmsZZYFu2puDDs9ZoNL6wcSGbTx8eHXz14BEyEuuXrvj+aDRwMum07/QE9z3HebF3+mJ7i/rhxdULmm71euPhJDqtnwvB5+em0ilL0TXdNgf9MSLSD/yj46YfADOZKper5WJZQuSHstPpehMviIUAyGeeBsO0zfNZ205lfabKOH7+7On9+w/29nZs09BVLYqDbCZtJrKEWEDIo5OTT7/48u6DJ7vbR9QPVdtwAt5pj84arWZ/0OmMytXaxoVLXGgAQYzjp88ffv7VF8NB/6wx5NwQYawnMs1hvHcy8kLJJWgNmwAACBRnPIRClHKJSrU4HvQlQBCRw3N62J7cf/6CAgkBPjppHbXco5PTq9c20tl0vze6dOXW7TuvmDqSPCxmMql0NgqDldULy7XlZ9svtvcPTAOn0qppJYUUpUI5CnzHi7Z2T3cPGgcvntfPO5ZhEt2EgM3WZjY2VjcvX7xx89qt125P12aKpeKzlzs/+fEH48GgVEirJIKQp5P5TCKrKXj/YLfZOFcAonFwdnSWMqxEGvlOW1NgQtOH49HA9xUNR4Hj+9HRWc/1nMEo+PizRyeN3tTM9Hmnv/XsRbmck4AeHe+1e73T0+HOzstKNf/eG7cWl2qlUlo39Ptf3PvsV19gLHrdpq6QQjoVx8wN4/mZ8o3laYv4Vy5f0Yzco2fPJ/1hGIi1zZsvd48kVN95++LR4dlg7J6c9cr5wlQ5e3rWeOf1V+fm80cnpx9+9AmJ/W99443usPd0a5t6wcLS0iQSfgSGw3Exb/c6wy++/BJjSoj88NNfJfIFU9fP663uoE9Dn0s6dEal6ZlqbTGfSw5H9dE4PO962XTOdwJL1zSVnB3vz9Smc7l0vXkUCnxQ78dRZKr208P60BX9/iSjqZ1+Z3G1Ol0qTs9USlNTAZ0kTJxOWz/74BdPXhxmK7MzxSoInGjcMazUWdddWp52KVXM1Mz8XBCHbjBenK+Ouw3K6KDXmZ5euHPrzmA42rhwicbh4sLC9VffNjNZJNx8gliWXpwp5POZIOh7biCBpLFXKpUvXV1udVqKZXXa0f7BXmFmjuh2b9DPJa0w8GIAHD9yxqPL1y4dnfR8ly4tzHu+CF0vmy56XP3oV/dUE66uLGoq6fWGuq4JIDWCVFU6Afccp1xK5jKg1fcaI7JRSREBGv2xbZpZS12YzocC9LqDm1eXpeStk1PTTlhJXWITY5y0FAq07e3GsNWwEa8W06aGBu26wmMDhc4o9CNupkuqrhwf1dut5s1rtURKqeQznMXDbtDoe+cOm0grljiTLyMIq7P5y5dnEqY8P2v5Yy9pGKWMpSAchiCkMpPRE5aOoRZG0oloHIaPHz4yDP3mzSuapuy8eD7s93VVLRfyzJ8EnqsnTQSVyWgCZLCyuTA1XaOMZ/K5tZXF0AtPjo85xaaNLF1tN7vtntMaeJpqlwrWylx+an6uNQqAnn90f2c0GL71zq25hamzbv9Xn+1YurW5ks+biItwGAUca6l0ppBJJjRFwxJKIAAMYjaeRJqqZdIp29BUrJmmYuqKZNR1Yi8IEZaKoU08EVIUMSkgRxpRk5aKCIZIMXQIYRzHEY17jss4Tpp6HPqMMyeiXhBBiBXEDQ1ZFoFEiQR0Y/D1Wq4qqmaoBCFdVRIJG0J1HIwBEPlyOp9NODEfhNQLGaehAiUCCHGJVYClNAlGWAAuGcReEI0n3mQyAUAomoohCmM3ZsLxfckBUXQh+cQdc8Zt07RVmDRUABkHIo5CxgCGClQJBMp44k+CaBIEMROcAcalF/ijwVCTAHDgeT5lHCJAYxr6oUawpiqGquqKwinFBKma7k0cRmNVIxIABEEQ+AhAgpU4joVAjAmkYAmlZIAoGELMORVSYgUzyoAAAnCMFYwIAFDVVCSFECIIIiQBhJxxyaUgUgBJEYIQSiAlQABBDjESQgAEYiYIIhJIjCHRiKBUkQAI/jWDAEkIAUZACoIULgRjlEqOpCoglEIShCiTQIKvE8eQECGBYDQOWcxoEEWqiomiiZjrEEkAKWMQAcYplxIRoGAihZBQcgSFlAxCABHEJI6ZZIgDQBBkAiKIIIKKAjFWEQAIY8o5JkglGEBhaKoUECBIaYQgEkxIxiSSNIoJQn4USwh1hEMaAgARkBhwVdelkAQilSiMci6AYJwySVkcUsY5RwDqCjY0hTEWhFRRDQmJoZu6rnEuiUIYo5pGTF3pT8KR5419B0gUcyElNyw9DH0IgakZfsC7I3c4CQSUhqLaKsmpiqkT01IUBflxSDnVVI1zALEMaRgCOfSjzjgMmQBAWBpIWVrSNrgQQBKMIBKRgiSAysCjnd5ESpG0tZSlYow5ZZwSwMeaZcwuLEgSJSxZKpWOzvphyGdmqum02e50JmGYymcNFTnDXrlUwkr26LBeLGSMlNXoDBBSN5amMxZp93vFQgEI2O90OOVjb7ywOGMb+rMXxyAGM9NTtZlCJm2x0AUElwq5yPcPj08Yp1PV/MJ81Qvch49394+GodvHhOSKpSAOpirZTD51cHji9v0gCOr19tZJO1eyb9+aX69aCqAPdppnR30AFKLidFIvlW2Fi1ajVShli0m7ddp6sjP2uECKXKqiNy4Vr05L1wOtVluoFsEigQR1RgFF5yO/1ZkkdDNXLkqin7RG+13ZHXFNYVwhIo5KOXV9bWp6sXpwPvzo7qkXq4qiNFvtxjCKAMgl9I1aEoaeYMKNYj8SRNGdyejwJEyXphhXJz5LYGiq5Kg92auPBYfz07oC5OG5j6BOMOexPzU9zbly3goCJiFjSTvJJRYAYAlY4C/Oz331bH9C1PX5SqvTKORLzW5r2GkZpkYMo9Ho5MvJMHIP987PO+7yYqlW0be3H/YdqFt528CcjirVam8cjp3w9Nz7rb/769Xp8mm9lStOBRFDBC4tLhmWQkQ88SMh4lIxdeXOrU8+v9toDv4P//v/zB2dcqDmioXnz56tbqz95ne+/ejxo7v37i9MT6Xz0z/84fdzOfXtN1+bON7IYaqW7HXqb7/3Tvt0L2mK69cuHZ4cvvn2W6Hjceq6fvbHf/nXr1wo/torlxNJuzQ9O+o3bJ3ohoEwMwC7ffN6143+6PsfpDLZYkLljuMEsZ4qMD8a9RpXL27sH09aAz+h4Yyp1mZndo/ruXxubqr82edf9SjRVbXeaAhiHZ41X3/1xsOvPq4ftf7j//C7bjD+xS9+zrBcXZtaXZkfD8L7j18cjfg/+96tcNL8s7/+WWeivjhu7x7Xy7XqjVdeYVH4F3/5l4HUUuVaLl+adNq5dHLsusf1PhBUM5Dj+BOXNwYjUwPvvnmp22h4EXz+4sVoOJybq81MV/v9werc3OWLa5evX+5Mxh98/OSk5R0cnpXSxvfev7o6m5c8Ho+jnaMzLkQuV/SiOAJoeiZdSpk8GvluX1B5sH8ycIKQC4yApcOpFP7m29dv3bpFKTmvn5kKqFRmCvlitVr8xjffeO/X3kpn0hwqg3HgerTdG+wcNc5Om5RyRcOpbFIqRGHxW6/drs3NVGem7ty4Zuvw6PDUmXjdTss2lflaca5aBlClUltYXZZ8MnZhGIyzKThfKzd7g/3TFoI4oWvr6ysJ3TBAdGFtGUvpc1yYXn/47LQ1Co7OWzvbu6VCtVieshRWstnv/oNvR4z/7Jdftdod35s83dr97O5uOjO1Vk0FbhT53v7h0WfPdp7sOVa6fOXKxuZKOaWrCUsFbOz7Xr3R9WKRsPW5mYSts4SlECKo5J4fdFqNaOJ448HR/lGz3S0UzffeuByxsDUce75vgiASpD/shpTZpmlb5nS1mrC0lJ3QTUyIuLm2cOvKTDVvCoixnkAQJ7N5nSjFfGJ+KhHzKJk0Ctnk6Xmr1excWNsslHInp6dLc7X5uSoALJnK66a1tbW7sbl8+8ba7esrr766uby0UCkVK8VEOWON+00tmcRWcjSebG3vbF68+muvX22f7htJ7bwznlBtd2eLjttrS7M/+vnPkNQurSwkDEW1Ex99+WTzwmqGtw+3ti5vrr/75h0m2NOXB8kkuXPjxtANP3t6kiwuaJY9HvQKCW1xbnZt80LTkRkuc0nDY2Br+5gqxHF7UwmNBbLVHtUW1wdO+PGvvlybm3vl4pVrm3M8mtx72dCSJiKMICCQ7Hda2ZSWslMCoMCPwjCOAGx3JltnY0cmFS2bzJrD/kjVtbOznUsr1beuXd7b3rJThdri6vZO51efv+j70aWbtwDkw5Gz9fJ8fbl8cWPxpHHqByKXr3I3CAbOd95/v1queG70+Wcf53JpnSjd7rhUmf2r7//tyVH94sriymr52o0Lnj9RVPXK5rquEinj0cTrDwNdM6rFlIYCdzh6681Xn+/Ujw/rqmH6Iff9oFwquWM34OTp1iEy0leurLx4+jBrxrMlcvdZtwkLlzbn/Qg8eHDIoDI1VRr2hywcp1IJCVEgNd3QWRgrSKoYYAya5z2F9dY35jyBBhPX6Q+TlpFJ6sdn7YT0X51HkwDUYyUUijfoC3/U7PsKQlo2MYwJk6hg8vWlbG02F4TR4nS6aMPDutdw8dxsJWnIw6Phg2M6l1fWF3KXFlJcgpjg+gAcNJ3eOBj2HUSpxnnGgJfX51dXqkPfPWpPNO5kk3YI0HGz60c4lcoUy7ZklIejfCnvUXzv4f5g0FldKCdszWdau9eNeKBZBiAyk8pUqlNx7B2cnO3vnmSSyXw26w661XLqxisbI899cXjMQ57JZgVUwoBNusPhoGVbpmIohaSqYyrjYXlx+vP7p0+fH5aKxVfXcy/H+PGLA53TpZlUxjCIAF7oRb6vEWQoIGMYiiKl4BDIKKJdj0ERQUURCLAotHSYTmi2oWCETVWkdKiiOI4jxgCQBAIJGbcNoiDCAQWQm4SYuqoQ3BuNfCohVASHUBIvFgBiouKMTrK6kVIhBpIDOomikRsywXVIEoqqK8gkCAnuO5HnUBzzvIUEVruDcc8JPUqEhEHkKwrBEkkoEUIWRIWMrSuSAhlRGQlVRYqpaxlDUxXCYubHwmc8iqmiJyBQRhOXca4TTUUwmbCgoGHMgRAsjg2EFQXqhkIIcN3YjxiTOKKxAjUhQCwFgAhLHNOvcVkKUrFCFCBYGIa6qpm6CSQOg1BgiBVVcgmBkAjpuiqllFIAjL4WWwAgIaVEkFKBgFAwIQhhIBCWiqYCyDlnoe8LSqMglkBCAICAACEOsZCScRZLIQAUXNKYQywh+BrjJRWFSAEQxkJyKYSUDEAOMJAQYKIAgAiACGIhGJUCxCwOKAUSIggFYAqASCIWRQjDmMYYQkwQJiQKQ4CIpkKMBdFAFMYQQQAElRIhBROCMSGqIoDgVMSAmaaFCcaQKApGCBGFIMAFhBJIFWGEia4SxrgUSEScUwYwQhBTwaCQQRRjoAgOBWBUMEVVpJAEEgBALCBCKoIwCimjsaJoGtbiKBRQqppKIEdcYIhVBRGMFEVBCGu6YpgGwSpEgKhINZSI0SgSGKuMhUnLMFQAGAWAyziyNDWTtFQCuAIDPzRV3TIMTVcVXQvDaNAfO2500ugMhkPKGcbQ0kg2oVkWxBpRAA79cDKcMMENzSJEjWPqenHMpOtzgKCiIIKhbRsJOyEp9QMXAK6beiqZwEgLY9DqjoZuFDNpGkbGtmIeD4ZjxoSi4VKpvDBfG3Sb7rA7VZ2q1xuUyWyuAKjfbTWZoLZlpWxdRGEilQ9j7e6j+5al5bLZQX+oG/rMTCmbTBi6DgGqnzfHozGS0LI1S0/0B85RfU9QvzpbUwgcjEfnzbMoZpVSUdWQT4P5ucryfK2YLtc7zpMnR0LiG5fWXr1zO5O2A28S+3GnNz47OZcSKIYVBNL1qYrh5sq8bWid3mh792TQH5VKpUw6VawUk5l8bxQentajmFuG3gvZ/nl7bsp49fL81Y2VQqHAInHe6J61HYyV2J9wAV2QPO1Rx6ded1SZqmbz6X57fHTUPTmd5FPkwmoyl9INS69VU1NTGRaHO9svWx2vVEoYltFpDYaOcDgOQ2RgGrTPUgQuLKwkdQWphiutF9t9w5azU9l2+6RSTqYzVr0zPumGECnlPEpjNOiOkJHUVT1ja4Ukni6kzhsNrOBUIp3O2vXmPuc0ny0EUWAnkp3euFH3X712gQahQvSj07Nhe5wr5nO5ohCgWJ4mUL//aKfj+Nlc6ru/9i6Nw1Q2VS6WVaSMR6PZclFTyNh1h/3ud97fuHp98/nTx4tzC812p1qtlHJFBLDrhli36q3W+trVbCr//PkLYqD/8p//L7PZxJeffnz92tWzen1pZXmhNnPv3pf9fvudt19P2uqzZ3u/+OXT6bwxP1356ONf1GZLvUH35rWb+WLh8Pjo0uWLzrgnaRgEfqNxmi0Uf3X/aX80vnb14snZfj6T+clP/2Z1YSFpEk2z7917/M67b50d7f3i5w+Ks/NUoO2jk4Wl2eOTerfTd93xdD6XziQ7nWEQcCuRKU9VXjx+ViwX5uZXPv3yq2Hf/8Zbb+w839EN+/DgQNPUmML7D5//3u9+c9ht1+u9VGHhk88OC6UZLpSTszpQtVdvXi1Nz3meSKTyje5IAJJOWzNTxb29nU/vPS7NzA07XdtUKIuEoo4dDwM6Mz+/MDeDeSTpsNs8KqT0b3/3O+3zpkJQ1jI03Xj1zbfmFpZOjo9sSylXC/1u7+G9u0+fbpWqs8XSrKmplYIV9XutRvP0pO67/Ysrc6mkfe/uo3Hfu7C+Eo7d3rhpmHh5aTaTSy+v1pYXF5FgNA6SCfP129dzafP543tPt7bNREG3U91RfzgZ5dI5O2G3G82D/f1+p7e9s/f8+ctBvwsZffud937re39neWEREYGBBBB++Mknz7ZePn/6/MWLl/1+v9/pN+stgVSJCRN8Y219fa6UT5rTxcxo0O0OB9lMrpRNpmzNttJMYC5Vzbbave79J1uONB9vHR6dd/eP6n/0/Z+OQnVpdVNIKAWfX6yamhKFwfLSgjdotOpn1an0yuZlz5fdvpPPFTXDPGi2xpOJmcroelZRVF0FmAXPHz6K/YlKBJBxOp16++3XlueqxbS+vlbbWJramKsU0raksW2Zuo7Ttp22zX5/jAgCnI9arUI+9/adt2vVOdtOzy2tZ9KJQd89PG32x76m6SoSWMUqBrmUibnnj3rjXr8/GDFO/fFEx/LkpN7pdZOGtrq4kEunLDuZzucBVrwgfvrsSRRJzjhCEkLguuFgMKmUS6Ne56tfffTowef7+3u2bpi6YllaqZTbWF3EEADmW4ba6o33T1o//fjzZCI9M1clQKSTCUU3K7W50B+lbbOYzh2eHJ3UG1MLC6FAL/brgRdeubxxeNo7OzzsNQ5vX78BkHp23mHh6PJSWVfI3/z4x5l0rt8fHJ42t3ZPr1xeSaJ45I+XL2zmM1NCNdPpUrfvUiHeeOfq+Wnv+Yud5ZVlXUe//Oir8aT76s1Lv/ebN/Mpq9sZZNPF/nBSypcXF2Z5HCVMCwIeeL5KYDaTyJeynLO9vaOf/PKjo4P9fKHyys3rg2Z7d/upRGymVhu7fufs+PL1TTtlf/DLj3xvGDpB0rKFDP/Nv/vx6vqG9F1n1HLHI4AVK5c5aTTMhHH9yiXd0vuTUW/oHh2elqemU4XMLz55uXOwT53+TKmcL818+eW9YiV78eJyFMWZbGnv4Ljb6yzMz5Rz+d2dJ9svHs8vzEvqVUsFP5Qv986IZhbyGSBkayyf7x4trqxlTNRtH88u1AwLHbzYHox6uqlMInHvwdbK8no+pUogLD056Do0EAQpRFMmY0fVETFUM1fe2h8nbfLmlTkRgfoYdDy6uFzjbtQ6a8wtVFgkRu2WmTAxAdlySbNtEYxViFQz4QZ8EkQJO1nKFXIZPXKcKzcuEwwePtz1/HBlfbk0nR1PvG4v5IHz2tW1C1NY+AwR1HGj3Wawc+o9PRo3u46GGR+d6qw7Nz2lGeYo5oqKLdOAXLQbZ+3Wme8GufwUjTGLwcbaQsIkjUY7jnmhVGbUn56eVXWbYAsheHi47frOzMzC4vyq4Pz08CgIYj8MhpNxbap88dImlHI4mjBOEwnj5KQeS9xojQdDt5TJWjpJWeRg70RV1Nr6YqM33H72QgXs5nolZvy4PVYNo5BXMwkFcOFT4Yc0DGnEuYJ1Fau2YaZtIwz8ycShjAsuIxrzmCcTdtLWVQKgwJlksloq2iqBiFNKGeUKkRqWiaSKBORUQsiQBJqm66ZF//8s/dezbVt+2PeNOPNcOe61cz453By6b0d0N4AGCBIACYIyKRWksqiyLJf96v/AL3pQlW1StEgxCACRG2h07r45nHz2OTvnvXJeM8+R/HD5PqrGeP1Vje/vE4YICoqVlIBxGUQxFwoRLHmEsNINqmECAJZCBT4b+17CBYaSpT4hEusWxzCMEy541jQQAKPJNEwjroBt6Epw3TA1AhCQEU8hVq5puDpVSvhxMpxOBRNY13KWpZualDJkzPcDfzLDECEM0jjmPHUtq5pxiw5hEjDBJUIJExgAyAWAGCqRMD4Lw0QICZSCWHKpUV2jSkIYJUmSppwBoQDjAEghgDQsHQGl6QQCJIVIGaNEJ0rJlMsv+V/ONUJSzpWUCEGogAQKQIAAhgAAiDAgIk0IpAAoTdcAolhKIATnHGIIMOBJAiRHhAKEEIQQQ0JRGCdCAYhAkkZSSogBAEICrITECEsm0ySBECScKcklhggoLKVACCuotC8TYwARQFwKgSTSKMEaUohxybniKdcNQwoeCyUlkinAiEiBUiERgEokUALBUqggUhBiggGKEyaUhF/Wv1JiAKM40TGEEAsAFJJJmkioIAKAQM3ECMI4iQjETH35XwoJJAGDkBOlEEVEIgEQwgAhySUXECIulZBKQQWJZmiGBIIzDhS3LOQ4uq5pEAiFpEEgxZBgrFNCIUkYG89CP0kVxDrVdSQlSyEQOdsuZK1C1pBc+HHEYpbNOcVCxtQAxHLq+Z4XpakIUkYIdC2z6GgL1WyjXs7aukhVFMZ+6Is4zWYzOqGMocE0nISxF6dRwlMWSy4NCrKW5ugGgooSqCOSz7oGBXHEBl7SGoZjL5YSGDqxTXvmBdNJyATXDVwuupqbO21edruXa+tb04A3ewGXsNVqzfwwm3PzrpVxTAOCOE3PrtpPnu45OlxdWox5IpRiknuehzTFWNofjBRQ9VqlOl9OEzb1Z3EQuYa1tbGSzbqj2eyi2XKymfp8JZd1TMOeb5RzjomJ1uvPXjw/H0winSCDxkGYXl21ZhNPAgAlyGYyxWIpZRFCnMh4sW7nCsbID86ak0Ti7WvzkHzZreuXzdFFayYVmi/ndapNZ5Gla9srJQPFrbPmccvrx+T8KslndcuAGR05ufnjTvD8NAkTVqs5OmBXF53BIHRscPd65qs3K3dWskVbYRaYhuEFojmYxQlZXa1RBEIvKFYKxLTH0xgTtTqXWaqQRglPh93hcMqFGs+8WKnX7zbi8YUK0pX1zeOrfpRCJiAXoFjI9fu9NAWNuUKUsihKbNPyptPJKDQtC0HJk3Q2jba31/vj0SxFicAv9puv3c5uVsDVIB6NpqeX7Xtv3rctE0EgueBJGKdiPBoUXXp9fY5S1OoMNaJPxp3Hz54Xy+X5Rh1CNptNNrfWvvfdb+88/CwKWK/fPzzc31heyrjueav713/zw2cv9u7dujH1vUfP989OL+/cuNXp9Z4//qKSrzUvTpVSGsG7O8+KxcLr91+JEh8j2BsNbt4o/fpvfq/dPLF0fGt7a9A5W1xaePzk8WQWBH48Hg3nG+XxdFwolE8Ozlnir62u7rw8NAzn+OCwXijeu38rCv3m5Xm53jCy1Q8/ebSylF+YLx5dnLuZwr3XvvrjH/3KtZ1CIVesVw+PThVFAEJkmn/21z83LTtjZ57tPD9vTr/7m78+mw0lsgQPBEvffuVmMGmvLFTjKElE9Mqrb/zg73+lO8Zkmvzw73/hZPP5TMYi8Y8/erh/eOFHSbVYUmk0v1A7PTphHCytb+8939d0dXNz5arVsiy7UizlbCtlotPvmxr+xpt3fuvXXv3me+/2++Ojk1Y+X24sV27fvimT5C//8q84lwtzCxRKpXA+V3r1lfsY8OOT3XqjriCajCcKglzGvLbSWFmsl/LO0lLj7XfuDduXp82uZWayuUKa8MiP4yDud85WluYqJYtq2uHJ5bOdl7plf+0rb84vNtqt3snp1Wg8G4wHB3tHL/ePTs8vWZLO18orawuFUr5Qye28fPHs5YvIj8u54tr6YqaQW1tb3VxbzzoZCPj91+7fvH/j5o2NpZWl6XTS6o5G4/G9ezddW7HIu3H7fmO+EYfRdOYXS9UgTfaOzn/+8dP9k95w4hfKtd4o7Hkca6bkzDYIIHDUa1aKhfW15cDz2/12Io2do9ZHX+xWis7K8mIYpcdXHYhQreamgh1fhv3RZDiZSckJjDcXc++9fev2zc0oUn4qxyN/PAl6g0EhQxdqudgbn1z2mWCmqReLmVzRCaOw1blots5XlhqIGl7MT5qDx09390+PB/6s2x94fkShIDqGiEqBecq7nf6kP0kZg0CWKsWZ5w9GXhTzzbXles3WpJ/N5pSChydnzfa5SGUQRH7Ik5Trui4kGowmIkEAarNxICUwDKt52bpxa7tQyum6s/Py5OHzl0kahp7/6OGOgGBxrgZY6pi6rpMEwVmK/uSHH7zcPdQt7fr2ehwlOy93t7a2DcQ0Aubr85897T54dpIr15UAg9FwEjFkZoMoubrqGk6msbr9xWfPKln7zvWtajGTBP7aSnl1dbE9Ex/tHPVb7T/89e2Ly+4sSo4uW5PBpJzPEKr+9G9+Rt3Sa+/cffro4PJo97/+g9/KZLTPnhwfHB1ixb/z9t2cSZjipqUhiITguqUxFtzeWmGRf3RyvrrYWCrn3n11c3M+JzlsDcXzp7uA8eXVumW6V6dXo8H0zvUt16b7L3be+uorGytzlqGHUWgS8c//8fcvW0kY8a9+5fWlWt4ywfOXu4+fHUmltZqdZztPEqEMLW857qef7R3s7f0Pf/TfrGxvf/LJZ5joI2982ezsnXS++OwLlYLLy2ahVF7ZXNl9+izxg0ypVK8taDrlCoA0XKy71Zpz3uUzPymXMroO/VCdtX3fH9++VgeC+1FUrbgVW6dUj33eWFybeMnx2YvaXHnQn5YqhVLJ9cNwOAkYQLMowoR6UxZDTLPZlw+Obi7ll+f09gwfXU0VC+cremccI6PeaJggmCzV8nEEjk771CpnHE1FYeyFs4hd9OLdo9bJ6aWby4+nLPaD737lWtkGey+6F92oWC4tlkmIrae7V/3e5Vff2lir6zoKXVNPYtSZiZEwz3tpEMFyveGH6dVVExLqxXg4iTOGsbFaq1RyuUy+N5h+8egF1izbNTQCF+eLrm0cn7SiWAwGvf6gb1vZ8XhUr8yVq5X9vcPPvnh2ddVaXl6sVkvj6ajZGQz60153XC7klhcqgBixH1YLtobQ4XmnNYyump2sRTc3Vkt5c2khOxgM42i2uLhYz2e7l8OGnTQqlfZEHJ21a3m3UdRcCylApmE0S+FwEvgcSqUIVE7OruRcAGCSiFTIOFITP55MpqlIoySehaEfxEQjjknylu7Y1NGojhVCmEpQcIyspUGBvSRNgggykc1ZpoaUUggqQKAAIEnFLGICEQSJQ4kOFJIK0y/Xz0svFVGqDKqbtqWwCoOYSwAJNLFwTaoRI45EksQYQqUEUiLr6rZjIgBZnNqmYRFs2QbWsZ/Kdm/kBYEUyiTYMggBAADsx7GAUiNGIlSQJApD19YKlm7oOI4ZVyAIEiYhQPjLXJcxDhSImfDTVAoBMSRAQIhMjBFUjHGecs65UCnSNIggQhBTpLhUQEmlMNGUYizlCBNEKAAASACAIhgAKIEEQnKdQgQBV5wlieRCIcEYJ5AgACFBSgpAsFSAYMw5gwBARKSCggugRCoE4FJ8KQ0rmAqJicaY4FxBgJGCAEIJAAcQY8K4JBARRLCSCCoFCZEQUIqV4FEQcC4gAlSjQIqUJWkaKoQwhlIJSiBEkBBKNU0pJBUghCCqCCFCKU0zsU4VgEykgEKCoUICU8STlDNOCOUsYSzVNQMgIiVHBBBIMMIUYoIRJlQyzlJu6IZGiJJKQaSkBEIBogAUYRIKySEgSqGUMYGUxFBJhBX0gyhNmVTKD3wogWkYxWKeYp0niU4VhThrOa7rEqQE4xhCKYHnx7MoppgKHuVMsjSXq+bNQta2Ld2yCIaQSS4BKthGIefoBEGk0ihBDBFKMSUmIeVSvpDV6/VC0TV57I+nM0iApjtY0zXHURD5QdwdDsa+JxRBhBoUVwrZWsktlbK2aUTRNElSU9Ns24AK+kk6mE5nfpKkIuUSy9Si1DSgAtK1zZX5huO4gou9/fOrdvvGrVelwh998nw68QM/5oIvzC/pGjUNVK2Uxn58cdXvdbrlavH11+4zlo5GM93QDF3LZZx8sQgRYCxeXFh2s9mL46vJzBNcZTNZx803O5fPHjwI/Pj+vVdu373lWmacRuPxRAIUpOr5i4NPvnh02e0UK86N2+vZjNludQSXtlPgXKY8Tjjzp76S0rW19c369WtLw0G32+6X6vWVxblawaG6pmPj4vz08KgZzkJKqEaMXrcbTHxLo6EfTb3ovD0ZjKKpF7mOWTCIZVoEa8enR4fHnUqRbq01LE2bRTGDtNyorC7NrRaoKb3pMDg56jBGZtPgqjnA0Mjk89PReNT3G5VqkIDTi4mh8aUSWaqUbJsalh4HXgKNqcfG/XB7s2br9GR/uLBQ7fR6IQOpgKZicyU3YrI3k7WGo2bDJAkTIYlmtzsjDSs35zIlB6PRylJdSHbV6SLdafW9rKu9cmurd3mkO8VJGJWLdsbWTk8O45kfJ1FvOO6PgvrcYqNeLZeyrVaTYjOV6PnLi+pCI5t3MEZQisXFuVdfuRkGw3y2QDX95Phwe3Nl8/rGaOT/9Ce/5AL80z/8g4jJX73/8XgS3rh1B0F5dnpQzBWrtXq70wu82aPHj2/eu1+pFNr9ZhT4hVx2sVFav7ZquNneqP/2G28lqe/Y2WZ7yBJ++97ty9OzhfqckEacwOHQTwV/9e72ZNJfXVudzEKnWLh+59W9vYMoTRrV7Buvvfm3P/pw99R/5f6dztHLSWfyxhuv/uSXj2xHrzcqEy+4uOwIgRwivv2V20ywXLFoWIatEdNy6o05z09+8LcfLF6/c3XV3150lhvFa9e2TFd/+PTx9vVb/+E//OVVN7p7be3Jw8/KtbKiuU5vZEI0Gk8PW72Q02e7zzmLSsWaJzHRiu3m1UIj8/u//ds/+fmv+v2wXq8pxYLZaNq9KDra3a0NDBJMrFan9+OfvT+N0ste66p51W9dfPrZ56VSubG0qJmmiKPOYOqn6uNPHo563axrEWoMRz4x8wuNedPAQiZn58ft5jmQottqj/2oVKtLha6u2scnZ0KhMAxz2eziwhxS2A8iSG0mtNksGo/6H3/4gTeb3X/l/j/6nX+wvLkJsYYAffPdr1y/dYcQuLGxWshnLQ3bFLcuLo7PTs/OznZ391+82B8OZmESV8sl0zQCz1MSnh7vBdOhhjDj6vnu/pOHTySL5+drhwcHF+dXSoLJeHJxeqoEuH/nzhuv3q1UK4ZuIYL63bbiaSGT2752/dqt5YWaO1/WU3/W6Q7PLjrNthdLOgpVfe22nas9f3nY6s/agyGCfKFRaTbbkugbayv5TM7OO6VCDgPeu7xwbT1N0iCKrVxVIPuzh0/39w+219eo5npMHwdiOJo0Lzqj3sx1sq+//dXXXruTy7kXJ+ejwTifKVWq9cSPQZJOx+OD3cNiwVlsVCCPCEaZfMkp1d1cXgIJAdSAubq6vriyYDu6NxusLi/VqlUEUKZYMjJlYrmWZUdRcNls94aeF8QIGcNeb+rPkiTyfM/zpyln0zCsVqvbG1tbWze/+u57POZA4XqjZNrG2cmZYWIgkjjwbctBmDq54urSSiyMnefHKY++8uZtxcEHnz2sNpZv39isl+xf+8ad/cPzVrMPoBhPJkzAWRBpdr4yt/Sjn/6qXHBsRzs8O48lsi2jmM0Jz+Oxn3HccnHu5LKZz1lfeW1t2p/qTubyst28upyfX/Fi+Nc/el9wcufOUjAZXJ5dfu/7v5XLZrr97snZGeXDel7TQNLvzx69OD7vT2fjIO8YWZPcf+2uadjdQZfzmYxG3/rGOzevL33tW99zbPfgYAciw09EY/l6rZ4bDy+/8t7XJWd7jx9vbq9vrV+bW6gcHB8tb6zdvjP/xacf3d5edzPWvfv3vvXt7wZ++Pjx4431TcN0Pv7wCba0X//+b29sF//tv/+rB09efP87b59dsqcvjvOlpWtb10o5F1JDd/Pzc5VHjx+tLm0sra882XkhhVRAFfIFgWzX0jThLSzMRRJMwxiwYGU+o1n05cngxe5xuVIv5vPD0SSNOWNxLqszJif9bqWQ2d89X1heCGLY6fdyhVxGR0KAIJI5NweFABSeXLQ3NtaCBDz87PH9e7c3arTTiw7Pe3q2OIrAo+dPNzevsZTL2Fufd8Yj8XDn0Jdmvloule04SqeBjJXOIZlOPDOXOdw7IkB8873NfAGdnF2dHJ+Hk1GpWMZY+/zx+Xg2urtdu95w5ou5+lxGM8Bk4nVG0cuLacDR5rVb9bk5Fo5n49nMj89b7f7YZ2mysrr8tW++DQj45OMXk7FHNJHJWplcLlssRVGyuLB4edH0PU8zzYPj/Xp97u6dO45ttZutp893EMbbW1uNpaXLZu/g8HQ6nUDIS6VKGPhCsK3r61etSbs9gch+urNrIXLz+vXFxflGPR/MBt3WmeZmcwvLwWyyvFhZXZqbTXnrqu/aBdc2NZUSrKUARwBPp/4simdBEk6mjoatjI0wTIWUmCpClKIIW7pmAUgmXjSZeoxFCCEdYaIRCgmGkstUx9CyTWphJUXChBACSaXpVNMIpJinyg/TRKiRn4ZcsJQjBBUBWENAAUQJ0kwM8CxMvYjxVBGpdEKkkmnM846uG9i0iFQgjBlXkGpaGvsYKqKYaetcqOnMIxSXHNsUieu62Nb9iAdpZBq6a+qEQIyxYRsIQ4qophGW8iTlURJZVJUyTsYxkjiOuZj4SZQKz4skIhRhSvSUCcakgDCJIi4EQJQp9uW6njhJOBOOmYWI8jSRQBAMCcYGpYynGKqEK83UIMRKCgiBQgAqSaieMi6BpJqBIAIQUwwwxQpCIBCAJE1jhDGCCCEIMaJUk1IChASTCAAIpZICAiSEkABhrDREIQCCKwAUIQRIIKRMkpglCUYIQsUYNw2LCyGw4gAg9eURziWTACNKNaWUACqNYyGQTk0lAE8TqBREAADMuRBKSMYhhFCKNGVKApakQvAwTpMoxgibhk2ABiQAEisJCEFAAS4lJpjqpk4NigChWDDOeEwJxQgICYgCOoYUYQwR4wmE8ktMTHApOaREw4QACCRnQCqlpFKQcSCUAJgijOM45SymhNqWiRAUjCUslVLFSYopokQhBIWSmgalUmEUCaU0qgmeZDRUymlZXbMoNiklAEKIfM8XLNWJpaTQMUwFj+KEIIywxAA4jp3JaDoRlqlHfjqYTIKUEdvUdFOqhDPhJ6w/9nyfK6QJQBEFrg7KBaOY0bOmxuIQKoEpNXVDKRgn6WjshRHnCgOAqK5ZNnFcVzOJ7/tZ1ypXsiyN4iieeJFM483V614E/v6nn6QpL5ULhMBCzg2SmPFkfX07YuLhk73L7sS13a2FUsr4xfm5hnEh62TzdqMxRyDq9TuObUZxtHd07CXR3Nx8LpcdTMZnrT5LVK1eeeetuxsb895wwDgQElqmO5skP/v5FzsHl0qqjaXyG/e2DcPs9EPPmxmaOZl6s7FPkaMkijiLo0AnMpe1wsALPW+hVi64tFDMMC4ggGfnl8EsWqjnqyW9kMskUIRCVisZy8btfrSzO7wag3zebFQLJsVURURDPS8BXLxz3fid9zYcXYUJ7EcwVDgIwuZlq9cZtwbR4xcjYmXdhe0Xh7OMAVYWa6ZjT6ZJsZLnmJ03R7oJVmr2alV3aBBzMg64l4Jhoo2DuF4Gtbx2cdk0M4Rja2/vQrddP4iLWVLJmv3+BJpGY3nZH415lGZzeWLnz1pjSjFBWAHDm3EASOuqq+vuoD/lMdtYrQzGs0DQaSx6o6henTs9PMhn3UKxCIEexjLwvWzW1ExguXahWMpkMr/8+SdGNtuolhRnCWfZXH5jbWnmeWnK5hcX9w8O6o25Qr54cXb5s5//opgv/Mt/+UcsGX/wi08o1RWLF+drWDOq2Xyv13/4+NlwFDRb3e/9+ncm06GUCiqSzxQuW1feuFfN15gERLP3Dnar5TkltT/9s7/Ol0ppGLo5qza3+OjpiyiFveGkXKzvH52ur62p0F9erZZKi//xz/5WIuvy7GxtdUnx2V/99OX69ft9T56cTL717vUc0V7sHmdLtTQR/YGfKiOIwmo5X8hkw0F7vlKNIpHySCXR9bXlv//bH927s/754/3JLL5zY9PUgJTpydHF+kKj1eo/3jt549W1jfnMt79yd3t7/fTy3HTyk9k49eJSvsI4gkBlM9lSIReG/K9+9LNHnz7VTPt/+0//6arl37mzIWKvdXFm5cpfubV0a6kWeuPPH+39+z/92f/2H34+m0Z3b2/Ozc3bTtZwM+V8hhKAAaAKP3364qo9/ukHO0zKSr1WLZWms9FpczCYeJ1m0zb0mONud+ZkS1ijz/cuZxFQCFxd9rq9qZ2vRGmaigRh8v5Hn+4eXTi2Wy44xZxdKFaCQCAFtjcX1hfnX+zufP7hR6PxqFLOa4S0Wh0ulEgliyOl5NLyUm2uWqkWF5ZXr127X1tcGk0nKRdTf+b5k8tW+8Xz3e4kMHQzTMPZaLy5sVZfnB95we7+gZtxLUvHUG5srlaq+fGoN54OLcOsVnJz83UTqjvXyt969w7BWLdcA6M8CaMo7g36xHLGfqQUKpXndMvpjqYfvWzttaLjk3Yul5UAPnzwmEWz5Ua9WrR44p8enV00h7NQ+BGbjgfT2ejk+GoSBCeXzXw+XyyU9s8unu+ePz3uxsCIOJmmSbvfb3f7x8cnw6nHYfLGG3dee/328upC1s5ev7VaKZp3b2/X5+eChCkeFHNmzMIXe8fD6czNZSRTs5BdtZoYynKpsLq6GEb+p1880vU8/BId740CTxFKIj8wDcKZao/DKE0M26mWC/lsJpd3CIJRFCSh37xsjUaTXrtZzDlb11e5iHJuYXWhYhlo0B/qdoboBmNcw3rgB1RNvv7eW7lK+fmzF4DJV1+9/fJo+Cd/86vKfLVe0ms5sr1STRmnhm3q+nytWi3lL656VqZULmehSKq14oPnved7bWq5BOOp5zPOKBLVQoZH0YOXV2/cXs3ZVCdoMPK6w5kENFepdIdxZxREqXDKc7sXV4NBf75RNqjm5sqluYatQxvGG9urjw7bs5DkCqWCjS9a3SRJMrn8VWccBvDpy6vDw2Osab3BqeNii5rL69dOLoePd3c3Vlf3X7x88uTZ937j11oD/+mLs4t2+43X3tg/6L3Ya/73//Xvq9g/Pju7cffuf/7LH02ms3/+X/3+YqPxg7/5u1u37q5fW/lf/9W/aTcP/unv/t5v/eZXPvriyVxW/t4/fK3d9x4/erS2VC1l7F5rcnnVcTIZCtXzZ09XVjdL+WytlHddo9/vDmaRlKDdumxUa5pBBoOJTJLVevnWjVrWAoennf4kWl2ZL9pQ05UnFAWyWrO6oyk1nf7Id5251bXq/sGJhhSiqljSp7NhLqPpSBVcwwvSdNzcvF7Zu0zDYf833165MQ+eH6jLgX7t7vrerpp6o1KlcnzQvHlt+7VbZZSCs0501uxnNP2Nu9fLFTvypghDCJVjUcOhn3x+oKS2sVIru5hi5Mek272qzzcqldLZRU9EQTFvYTZzKby5PjdXMgHCpx3/2cv+cDKpFqy7N1fW51wo+STkfgz7o2jn+dNwGrxx/9bKWu34uDmepXPzS65rcBYQohmW42ayo1F/ZX6ukMtcnJwioBaX60uLiwjrOy8OWq1W1nVv39w2HLPb7HU7M01DS2srE8+vNKqv3lsfjYbD6ZSjzO7xQSaT1ZBcbhS/+fYdDcIvnh12fHY6o71e5+ZSZnPZuBqGrXFYdHImFiaRBdfMmYaSMk5kAmHCwDgKlZSEEoVQKhkXgEHImCKGITEFhCoIPV+Mg2ScyDARklKuANYIk1Kx1LGoSREHUkHihQFSQEopWKoRAiAKk1SjhKWAYKIAsC1LQhXHKZBSJrFQPE75jLEwiSWEhk50jSAIGQcEQYykgjLhKkplKZ+1DRJFKWfS1UxKacIEY5xqMGtRLBMMCURACslYjIFyLRsAqDiHAAnJuBBYo7pOgYIAEhMr16BZ06QIpCmLGJz6aW/o+Qn34zhJuJRIcBAL4EUpSwWECgBCCdF0CjAajUdpFAFEoACUkJQxziSEJEmFTLiSGEKpU+3L2SDlQkiecbIEEQCUSIVSIuWSSY4xEooRJIlNMcFJmgAIEVBAAYSxrmkKAiZTpRCkBCgAIOAsVRIwyREEACuWcgEkxkgKQA3NdCwAIWOCEo2lseT8yw1ACECEEVFKMS4JxAAgBaEQ6kufVwqBEUIYcwU4lwgBwYUQSgIFkQKEKKCUEoRiBKGuaZqmIQLDKGE8ZULwlDEeCQkwJRAACLGSXMlEKqikgphgQhhLFYSYAA4lhwhRpIAiCBNFlOBIw1QnUkmlJNU0QgmgUPBYQ5qQQKMaVzyKI4QQhEQ3HIPgNE4RUAICgARC0DZtS9ORhLHv6VTDVAMQM6kA0igErqMt1st5y4ZAIIQwFJwHcZwgQiEkURoIhEZeMhlHQlAINAgxixNLp4ahEapFcRxGUcQgoiaL2XQ2TbkIwmTmxaMgCVORCGBoxDWNct7KGVQmfhSESqI4TSWHYRzFaSgASCUMoxRiYtvEpMDUKEQwSliSck2jkKvZZOz5oRekC/VyEAQ7L3cLhcz9+1tztZxjG5CQ2B+ur60ooD74+cdJLBuN+vraom2bR3tHGiH1+YqTcSzT6A+Hvf5IpwaGuNlsDnq99e1t13JePnvZ7Q3mF+dXN7eWV5ei2N/beUQQZCm7POscHJ88+uwZS8Wdm9ffevv+m2/dzWasF0+f7x2dZWw3StIwjBhH09nEi3yhwNLa0sLSou5aUSpz1bphUKzS9vn50+fHl+ftQrl++84rjUYll3WyOavbG/FIEIoopolklUblm28tbixWWNQCipl2YepLJmGtmr22vtg9Pz7ab150J72rGU8SA4lauVhf27yYQWCAa5vLH79sIwJqpZxkycFhsx+BaRCenXUpwcuNmgmYa2mOZfhB0h0lAVMz389auFHOy2BsEmRmSyOfpwzEYZjRQKlQ6I/7vZ5aXpiDAgw9kDFRrbEw8nl/DDCGtmn4QZhwLjhAlPJUCJ5sruXzljkezxQpdC6ai5V8MZ8zNLzYqE1m00Th9ihcWF4ZdC5q1eLK6rpG7X/zb/+Py6vBu2+/IVkSpxGCwNANQIzL89bB8ckvf/YLjIFt2J9++vnFxcWtG9e/+2vfGvS6f/eXf53Jl7gIX339+tbWUjDtFSqV44ODIJwSom9tbbMknk1HpXJJ1w2JEUtZpeDWa5Vuq//BBx+WSvnF1cX+ZFqoFDOGblu0Vq395JfvD0ZTqMS9V24dnhwuL611202DgEq+8n/82Q8tp1ip1h2Lbq6v/c3f/2y+jriKv/j0wde+fqfUmPv3f/wXUIlipdiZ+L3JLIpDjaSFvPPJ559Ui9mN9Ypla950dm1zqd08un1vI1uqHRydvfPWvW7rgvHo+PR4e2PLtOzpLM7aJJexX331lqnhP/3jPymXGouLC0kabt+9QXDKWJDN5d2M9ekH77Ng+oe/9/0/+qN/ki1Ut+68/vo79ylUV2dXxVJxZX4B88C2KDazh+dBkGrbN7a/+fV3oWCdThMIqWsaoAhDBSQ/PjljnC4tbi8uVN3i3HQWjCZjHnPXNgmR2bwNiNXpzoiZ4RLNptNitWJnKybVDMukhjsNeZKKnFugBm7M1ebrpVLBDoPJoN9rDlqJTK/funHn5vbRyfHTZzvvvvP1V1+9d3528XJnfzSedPv98XQ6nUXHx51Ot1sqFvI56/MHnz/aeQGUwVOeBGGtNj8/v1ksLS0uLJcrc16oFhY2rt+6TgGejqaMYULdfLGSzWS4UoIncRLdvbPlmtbRyf5HHz59/OQ5R3KuUXVtebD38unDByYixYJbmV965a2vJHGcyxcTluzu7gwHnZcvDk8uejHQsGWtrl27+9pbihqV2rymqWF/YDuZbL6QKi1Osa5pqysL3/3m12/eXL52bWN+vmZbVAI0Cxm2MzqluwdX573h+tq1jY1bWNeTkCGFuxeX3rjXvjz86IOPv3j6iUqYrtGrswvP92xiuIZ5bWNtc3s9U7B835+MxkkaIWQUC5XptNc93ydYWI6LsX7Z6czPVR1Tg0JctVuU0nI5Z2l6Lp91nNzMSw6PW53BzI+SnG0tLc5Vq4VMxiZY8wK/271sNQ8tghBCVxdnOde9du2awlCjJgUwDWfT6VgKdvjybDIJb995ZTZLHj74Vc7V33v3zSgG/+p//eNMrihjz0KskCNhEE5n4fl5EzLW7k0+efiQYDUdXOXcgpFzB7MwjT3fm7WHw9v33yxlnXh0sry81BoEo1lUzpnlfLZUK593Y2zntxbmDSkGo/5g6F1e9tOEP3v0eNRpjnu9rZWtk4srrOlRyr/5jW/P14vH553LTl8QFxJ6dX5FddxqDYDhLCwvXXT7wWykizjwoy+e7D95vnvz9vW93U53NHnvG98+PjmPQm95cWXvxfEnH37GU/nKKzf/P//6LybD9u/89nd/9rOfKKz/w9/7rR/83d/+6O/+4u2vvPbV9776qw8/+Nqb72xub/7Hf/+fT3d3bmyvv/PG/RcvPl5cqH7zm1+FIGo3j1577db+0f5kEhweHBXzlU8/eXJ+1awvLJ6cHmQce7mxCBXcO++2uuOV5aWN5eKwO7CsDOes7JDFkkNMe++0gzExiSzlrUTi6Sg0DZMinMQyjsGznSfb12+4GWs8nSURT5lASmlQISDcjKUB2b7oUDOrFXJXvZ4BZ995a/36Jtk9Pc1lrNtb4Cc/O7PyuQjizx4931xfu7asUZ3ymO28uBiPLiuOlnU0Q4eMidFo4rgOMqznhydz1VLFEYLHemXNROr84sKwLdfJT4MQKuTknCAYj7rNjI4rBSdja93x7Ko5uLzqjUfTRj2/tlI3NDjyAqcyFynt5HjvcP+FaeqlSmN/r/nyxe5ctaxrsnV13uv3asXisDN+uvNiaWWzMd/o95qzSZ9o2MlkNdMaDsZXV+dR6OsYum4Wa1YSjzOZXMTY8e7ezc3l69fmXuwcJAk4bQ47w+HW1nr38sI2yTe+9U6pZF+2m72QPHvRGTTPtjc35mqFy2Z3HPnFjGVRQFlgG8B1dEIpB0iZugR6nIo0YQhJIZUfJ37C2sNJuzf0I5amAkEDaVYY89HEG8/8KOQEoyRBsVCcSchVMZ8jmEQiRdRMUq4RhAFMUk4w0nUdSqERoBRXIuWMQ4kJ0RBQmqURhDQMGQOzgAkluOSUIEODjCeEEiWkbToxU94kGE1mRNMBppZGo9iHSmBdH4dxIpTrmARpGYNqhDAFQgYkQEqkGKo4jhBABBOTUKrBOIqUAli3IZQFxyhltGrFLWRtBVKkmRzAsReOvRQA6odBkjKEEOdiFkVCIimZTohONAgh1imhiDMhlYRc6hQTHWEEIITU0KUQKWdCcgUxRAgAqLhK4xgqzgXHGoEQ65ga1AQIEkwgRhBgyQXBGlBSCgggkEIIlmKINV3HCCKpAAAIQkypFJBzyYXAACugkoQJxhECUoAkSqXgOqUKS4UQNDSRJEoqBKSUSiIEMIICKiEYhgpBIACTQiigEEEAgf9CDDCOCRSCU0QlE0JwCARSWEGAEOE8TZIUSmBqhEBMCPryXYRglsZIQQgBlEABQAlGBGKKCaQQIgQVQsjQNCEFAAgIpQAREMovM2ohNR2nSYy4iGOmuEAEEh0wzpIkxpACjMIwIBQiqLiSuk4kUKlgmBOAIGNpGIdMcEM3dI0wxhMuZnE6nk4xAkXXIjpEGvDCJEjSJE00nVq2zoWkBjZ0PWV86gWRlIKJWTgNkxgAqZQEHLKUJ6liSiolg2kQx6kEKFFk6scph0oiDiAEAknp6BQqMJ5O41RQTRNKQoQ4TwmhCGEEACHQ0LBrYBMDDJXkMo5mOsHlckEKORxPYwAJIY16TnfdiTfO2saNrZVixuFh4Id+EIZbm8tZ2/nok0ezSM7PV+aKTugHR6dXCWdr19Yd24ijdNAbxkFgW5ZGtP5w6s2CG9e3q/n846dPIiVu3thaXiyblj71pkE0q5RqRDP2d0/bze5w5M+vL77z7t1a2TYQkgx8+tGjq2Z3ZXkpk62OhqPxdKpbxHQdS9eur89vriwoDC8u2tOZP+qOz6/aXsC6vZnjusvLC5KLB89f/vzDvdOLzqg/iuMoXygIBaeeXy1kFkquBpDn+xA4Tql00PEH3TFAwnSLT45nf/N5MoyAbsByWd9cLm6sFvOlwvPD1sEV295Y5LNzCyTz8wbR9Wan2x2ljUoRKS0IYS5jaDhJUx4mojMMx6MUExIkSFO8VsznMlkWR0BJTSOUaAhCXYWNiiupcXiWFl2wUXeaFxfHM1zLKpOSi8u+AmBpcY7FozQJqGlgCYgkcZTUSvl8zvX9MVJgGvMoTra21+JoUq3PtfvD0Szuj3yFQT5vlIvOQrXiedGvPnl60ZH/zR/93lzBafVG4cRHGGZybrc1+Msf/CqYxQCKd999p9lqTiaTO/df2bq+3u50/vbvfpTNz2kYFHLu9vb6k8ePwoQdH14Ixf/h7/xOLmdPZ9PxdPLqK69wlkIIkigxDMNyrFkwO7u4ZIxvX7t+cXG0ulR76/59TKVpuaNpMJn6b9zf/sZ7r/aal1ISTGQuoy9uLP3Zn/8gVaBeyU9GnRvXtx4+v3jyfLS+unK+++Jbb11bW1n8i797/7Avv/m1N1M//Ojjo1q1sFCyX793yzIdx7HWtlcAYDnXtgznxc4LIeTqynKv2759e+tgd5dxeW3z+tffe0+COIoS35uNx0m51vjsyfn/749/MfWV4H7ran9rZe6oNT2/audctL21Puz1CJb/j3/5u7/za689evTo6LwznATPnzw5OzotVHOLC41h92I4miKjGKQoiJP1tZoBvNOT/ZSnb7528/u/9c2NjaW8ky0US83m1YsXzzVqDQa9OEhZwicTr1rOLi+WSyW7VKicX3Zbre5Co64EG4/G2XwpjmJ/NhvOoiBIYwYoMiRQjKe1Ui7v6rdvbRCKNN2ZejHFuFGvaZR8+vmTzx/sra7eOL5sPX22LwF0bCdfLJdLczo1CCQRF4cX3dFk5k2CQi5fq1R6nVa5nK/UKmdXowfPTx8+fbF1fWt+fn7/5KozZhyYkNAwjSU2/AQdX0y8iDW73eFwxhN+eXnVareYpMCk/Wny8aOLjz4/anbDr3/17jtv38q5mCIYRPDi/ERKUSnlLddZWl+9d/deMUPu3V66tbHY7w8++fzZ7t4BVyCMo27f1y17MJz0+mMNqYWVEjHho+f7Hz54gDAcj3oWlTnHLVbKpVJ1fq4+V87XanWhyGdPjlrd0VJjLpPVh6NRqbasOznLdhcacxY1z1uXABr5clVKftW+Cvyw1Wtjzq+vreZcM4qjYr7iZvTRbJIkLJcvD7qDpcWNG9c3GrW8Uul8rbg2Xyi4+vnFueO4bsbWqZJRFDDZHMYPn58cH1893T3gEqysLC025kulnG4Y3c7gqtkFAK2srniz6fHZ+WefPQICzBWzlZy5WM1RAOOUTwV6ubu78/jT9965+8q928NO0+uf/Vf/7B8KiP/3P/nb2sIaD8Zv31u5d62WCnk1Dg2CNjYXbNuJfIYkYoJtrS5qMP76268uzrvjyezxkxdzC8sEQSXiQjH/aP9yOvOSaX9ldV1S+vHHH24uVb7/9S0ofYEB1rXNldUgCE0n0+xH3b43Gc9Ozscnrene/sHG5pJG1Muj0YfPDk03X6+XJOPLy3PjyTjjGBkTrszXEOdUz4TKOD/rzldzt7acP/7LD6fc+Nq7bz9+9OStV2/cur3ZG47e/+iLbKleK5mffv6hlcv+/j/9J7vPn96+sfI//Hf/dDj2Pv/ooUZEuZz76LOPvvfdX79+bf2Lp0+e7ezU5uZcqzwdjYNJb7Exf7h/PD+/ePfe7cmgbZhmoZC5e//O3//NT2ZeVKssBlNfqLRezIU+aHbCNIw2VuqWCUaT6XAaGFRLo8C2rKmXdntT2zYcnTgGHI7GQBLG1GA0cV3LD6Oj3Z35uTmW4lHIMTE4Y0LiTNYIZuNrG7WEg4Pjc91AQLPa7eGo3/z6a4uLOfj4k2f3Xr9ZmUM//OVhplw7aSeXF2cbq8tFk+sUhCmIuNHtz+IwUJJAjboZFwKysFxJwqjb7q+uXiMQX16eGAYplXL7Z5eMaBLpLEpcE5UKdtaxNUoJjxp5u1ExGRdD37/ser1xur21+N6b1w3FO62W7bqlQt0yjZOTSwnJwsJCp929uLrYXFtbXCr1Oz2l2Nzywv5h/+HnT91c9vr1TSAlAzxjUcfSTcNM4xRjYhva0fGJFIgQI0mjV+/cMzTCIn9zZXlttdbrNj1P/OzHn3Cmtm9sPHq24028t1+7/cZGvWjjaQre3/euOsOl+XKtZAWzGUeaZZoAS5nGrqPnc5qmYh4kXII0EYRiA1GdIoyo58VQEUT0IEn9mCcp0zSYy7rFjGPqmpQoiVOomOIqFJwLLqUo5CxHI0pBADGCFBEIIINAKiGBVEoKRLCCCiroGhoCnAmGCcw6hqYhIDhTMAg5V0BwYVCDQJJGMcVGwmKNatjUhpPI8xnCmBo0a+iWgZVgiRChH0ZcuTaFkGGkEi7iJOES61Q3DEo1M0giqhmUEiSBglBImbIEEQwhy2f0Wt4quDTv6I6GLd1UCMVxmEgGIJICpFxBjG3TiJhMhIgY+y+ImEKcA6CgUgpqxDQdyzARJVipJI04SDRKKdY440BISjHRkVRCSWBQghDGEAklU5YCrhSCnCkoOIDS1jQEsJKCCQkRhghzIUTIAQBAQkyIUkAIqTA0dEIxEUpSjHWNMp5ijCECCEIFMQJQMvElC0Y0HUCEEMRCKqA0BZVgEkCEvpwoJFIIYEqSlAsBBBO6rjHOpAK6RpjkAkAplG7aAAOepgIIQjWAlIRKKJXGMQDQtlwogUYwpRohCCGs6zrBRHCBAAYcEYoNkwgFIERCKEK+LB4AphAohaDECiKoUsY0SsF/2RZKKDHimOlYEwIkcQgUBAQBoCCUSqIw4QBiKAGgiChMKCGYUEPDBow8rz+chnEooEIIZfI21SBgbDgcT70AE9PJ5iyiU4UwUJyJxI+iWAoFBRCR4IQYhq7bGUujZBrM/CCJooQzgRWGGGOsYUDjiMecc84JQJRiDJBl6ELxOJa6nrHcDFMSAqkEwIiymGuaDji3LSOfMW1ToxoBhGqGYemWY+qSMc/3YgEtK1+qFlwDdrtTLoHlkDgOT49Px9Mg9L1avbKysLR/cNjtTCzTwVKO+u1Wuwm03K07a5ZFPc8f9HuUWvVGw5uNry4vu6Pp3NJctZw7Ozt1crk333qrUMq3rk47vaaQwrZzhpU92j/2Qr8xv7i5vNKoF4mKZDpKWfT06eFVa7KytlarFtrtXhCHhVyuWq4xwQiSUqRXF+fHeydpLCzNDqIEQvu0OYHUnGvUro7OT44vghiUG/NOfmHmJfmMqRnGZBopCGyNc/9i1L86bw6enfQe7Ta7HgcEZqBgIt3tTPVyfvX2RqOcrxULPJiOu72To8Or7qxUJ3Nla3YxLOmRpgCTeDxJixno5GzLNhcXs5aJYTRLpEygRY1MNkcQRoDIrYaWyxhe7COkdMhNrMbjUSrUYilTKxaPLvuhAttr+WTQPWvNQsudrxpJEIwH42LZcLK5XneoEWBaBjYgNEispJu1fT9hTGgEX+2dGhoolXJMMYTV+UlXIbvTG86V3WIpc/fejems9/zps4dPD97+6u3FhYX9Z+9jZDQay0jyQa//wx//9Gvf+vpv/OZvEkQwpocnB++8+xZPg9lk+uDxF6VKvVafVzK5d/9Ot9XxZ2Ew8VIefe83vnd0tOsPh7Pp1NbM1lWreXlp6nqzeWFYztSbjIOoXCxcu7ZJdOP07IyxyNFx7M9mQXLeHi7NV1caFW/QvDi/hMTMuujeq6+9/8lzZJfikNkaeeu1m5Mw+vTz5/OrqxrVvvvevc35/MOPPj1ppmt3X12dr/79L3e+9t7N9dX57a2GTNjDR7umlQFcffbJZ1HkDyfTr3/j2++88/qTh1/o2ChaImTpzTu3T/d3v/joF6N+7+Yr9w8OT+bniqnEl8Ow59Olje211fnf/+1v57LOJ48PFuaXv/e1d3dffg6A/L/9X/7F3c36v/t//78++eIgkqrZuirm3OvXr2Wc7LA/0CFATuGXHz3+5QePTFPOVx0IAsfQnWxRI7zdbj598nD/8DicRqZuLS4tU9s+al4NJ9N2p6sAh0CweGIoQaRMmHKcnO97ceDP1SqdVlcBjkA8GPixQBDDq04riaJyrei6mj/qjDvNjG2lKbOszMbKimnQ7qA/8RMGtNbAe/78xWQalCpz7UFv78XLy9Pzy4vLiTdTQCzUG1Jqnp/Ua3Pl+SLVDNeyAVD7h6fnzZFuZj7//JOPPvwoYPLF0dnPPn74dPfQzbpSyY8+fXLWG0skqJGlVpYQ7cbmzWK5NpyFAhaIVW6sbfoJ9SMlIMYg2txYSsLZy73jJ0+OM44pWNrvjnwvhIq5GqZ+r2jLjG3rVt7zAhb53f6046VhmkpIAIa2gWetC6JIsbLYbntTP3AQrubt6ah/fHzY61yeHx1MRoPxeOAlsudHJ83L4WCAAZxORxAgy7S6nYlrGjeu3YDA7E398XRWquTn50qles0P0n6/Mxn3RRQppkq1EgJIQZktVCw7d9HqXl1cdC4uB4PzKAyS6ahRcW9ub2bz+atOy/PGjqGZBsa2USqXV9auUc2NI3F+fNppXgx7HYTh0vLCnVff5AANRj5PRGW+nC1WM8WyP/N44mUIn58rri7PQ0yUXiFmNmfYURjoulGplsJJ/3Tv8T/7w//2spv2J2xtczNuN1+/faNULOy2BydXJxTi1165PxnOCuVq1jWH7SblQfts/5Ub15bnGv2h92LvWHOzLPVW6hXNKhAdX1x1wjRaWtt8eTA4P3n26s3NG+vrUCFvMChmTMs2DLe8snn96fOnX/vqO1HMz1rh/tGJAbhtQGxZrYn4/PMnhkYdm1AC5qqFWb9ZK+RdQ5ecQTM3TVDO0GoO/gff/w2M0X/+sx8GiYAY/smf/Mc379+aa9ShncU4nfX7ulb4yU8/ZJFYXij99Ad/Pteovfu19+YWliqV8v1bW8F0+q//1b959Y13Xrl788EXT46evzQLtdXV5Wq5lHHsfKH6+aPHN+7cTgDo9du98eDm7RtrG6tPHz5vNTsSoJQlKh3fvrd92pxeNDsmAeW8Gylw2R6YppVz9XF35GSsmRclHBGZFgwUcMWA0HSi2TRJuG1let0Rj/1CpejHIOZA182j48NKY3E6FeNOZ25tjVKU+t5Fc5DQilTAu7r41ltLeQs8eLZ76/7drKPGE29xJfPR485lZ7C9mt9an8sXjShFt+/epYaGKJIcRJEY9EcyDrIWOb1qjz22fn3DwOzlUVgsVqqlevNyKCHx0khEkUmQnyRBwvIZwzbkQrVYLuY0szxj2gfPm7/42cemmn37nW2NitFo1ukNLLNYLJauLs8hYItLi3EQxkl85+6tGzfXW1dnmWz57r27l+3hy6fPMjlnbW0bY4KRqNfLpkWVBMPeMOsU15ZXL6/Ouv2xFwSIiKWVRhBOQ3+2vrSwOFdd3tgah+m/+w8/yGbKm9u39vd3RqeHcxZ5+1b17p01QTNPTvrjTnehaCKM+7MgYAwiTSEsOcubuJpxMUZBkgRcSKkkEAhpkBBimIKgOE4F1CXRJjMvjWKLSCq4ZRmEQKwZEiCBEcTUD1kYxAQBXaNCiIRzxlND11zLopRiCHSNMskUUIama1hpFJiWIQSQTFEkM5ZhaFQJFKcyTZgQEiIgCOAKCJlKJqRgKVexQuNZjBBQghOsKKEGJTrCcZpGMedAIkRcW7ctnXHRnXqjWSAUAIiGUdId9KahDxQ0qUYICaMkiWOFMYLSwNAg0NZ1KFOhYowx0nSMEEEQYig4BwpIKSmkpmZjBOM0VBAAICBSRMNcSM6lH0yjJCYQAgQMw8KYcqFiHgMJESZCCAIJBBABKARIolQoqaDEGFENQ6EAgBDqCMJUSAAVxBAjSAgWTGAlFQKcc4VUmnIEpEYJBkhwKaXUKKWUMsYR1qQQCEKmpGScK4kIQRDyJFFAACAIU0IppWswSSHFSskvITOp6TThXAiFAUAQAYq4FBBDgBCXiCDFBRcYCSEhxJppKi4BBBQgxQAHDGkEQhBEgaWRNEkhhApAilHKEymhaZlCCMmZkpRgoGlAKcDTCGMNG1BKJAUwdT2OY6prkgtCDQgUgErXKFcgDgOiGUEYJ4xhiniUUEMnGCKMTUzSNJFA6jrCAFONICkIwUzJJAY9j80SRSFWQOZsPaMTg0IWp7pGdZ0auoIi8cOIAYR1fTQcC4BMqAxCTaQoAaWswyWfjGeJSAQDYcwg0oQALEqkAlIqwZiCABKMMWWCKQkhgQhLjRBNg4lQQZSyhGOksjaNkxhAjJE03XwYxSETAEnGJAEIAk4wTOIojJllmsVCHiHlh0k7YnHCCcRUc7vTwCBWpUBvVCob61tnrfFJcxamUsJ4EqOsptca1blqScJ0OBiFcWoaWjZrnp1fHB818zlns2oXyvnHT/ezGefm5gIAs+ODrm7blXyG8xiwZCZCatNbC9tpykejDktSqhlSaKNJOOi38yWnVsm/eHGAlFiaa0CIJ9Ne1spLyHcOW4aGqgXNsCrdURhEHHBvc6HgWPbZyXErLSrcy+FkMZfEEgx8JQSM0nMGQMF2X577RLeLJcecTUomoKZezEEEdEy1fqe5YoKFpbJkE8/rK2yHyuhN0nAK6g65sZEPI/n5EDlmYtSWBilPYk4AiKbDKbJGQ54VwfqSjT0WJyJIYlOJYcgjZkqkRkGgE90PBQQgi+3TXk8GwHJzzX5nOowrJQOYxmW/awJwf8Gmbm3v+GoWpI1cYewxzpHl5kbjYTnjitCjWGEosPTmGnOP9yeP+vT/+jt1Nm25RvbsYujmS53WFQtn27dWVueK/c54HCS9wXi14bx9Z73bGjx+Mb51xxaSv3x5Wl9ovHr/1o1rN549ffH8xclVb1TMlSvV+vNne7qdSRJYyBHmj2/dWJzNWDclRwd71Ia//mvf2n2+e9XsWY7OFDi8OL2xvV0o509PzpVAEpBCoZx18o8evSgUswhIiqnruOPAm3hAKi9Nhze2ryuNfvH0aDDx/8d/9l48Pfv5x8+QXT578NFrm8Y/+cPf/tf/+k8++vx4ed6eczghaOBnrl56H5/3Cy74w68v/Kdf7jTmtG+/vQG44ND467//02LRna/mpoF083NPdlvL8/n16+v/y//8Px+30rUbC19p5I+LhZBpDx9+USnaX/vWdz774vkkwJNwVO71nu8dlvP0ndsb/uzi0W7r7375tKqHplv///7pT5Jp8j/9n/8RjIf/y7/61Qcvk3wu8+qNm7t7z6rFpZd7Lx3H1C2zN/FnzdHtjSVr3plrVGfDge1a1XImp0kltfOzU9fISF3OvOnJ1VW13Bj2Ostz1cChOgFzjfnecHbYGlvUpFZkO+Xz7jiJmWXrs8OeH2OMrCiKBkFYyjgE0nolY2giY5qtzsTMzaVIPds5ltTyA5but9LEu7rsEmLNL9cKLs07dQxoqzPGmL766o3jo9PxzCvUineWlgC23v/gQbszcG3qUnL9+kokFbXc6/fvDIbhcOhNZpHmZjNmxjKQq1Mlle/LfCG/tZXWc1miKafhHh5fNBbXRzGfjPoOiop5LVsgw3FQq+f9SP78o/3Qm377G7RYLhoXnVgzf/R8DKkGOWrwmIVsbW0x6zqz6fCtewv+ZKpbc93+JIgjRHAk53Uk5kogAfSjhxd2xlhdbihKWwM88Ufz9SIwczt7V1vXt2kpjSftfDbriDhrZU2reNDqaSTVAG1/9qhazUrAL3rTEXNPz0eubcyV7YXaciLT8SSoVrJ8Nht53mTI2xPx4ZNTANK8gYNEeZNxCoyTztTSSBorpLFCxh4Ohq3RTCKjNr8Rz2aShaZpD7sDqEuoRBQlENK5ucVR3we6OL9qtlrNmzdus9XVvePLhy/OohTmrGml4JazRhR5g1ApBFzTWrDFjIYaZ4FgE6941g6azZPa0mKawB/9+Ifb19cuet2CRY1M5mj3ecGlgCGliD8dO5h/+1uvtq8ut2/f6JxfpUn6dPdUaI6bdUt5azqZJontjaa6EZVtefP1964G7PDZC8uhWxtzP3/UIYW5tXpu0Ll4cS6etiJfwuR0d33r9ucns7f2P//t774d/fXP84Yz8/1aoWj6s0wle3jMPnzR/mf/4M3go/cLRlYVqkcXnWKpHEX+axtrK2Xt9OisM5vdXVz8f/7f//Av/urvnz17tri4tfNi/6QdSuJM/d7v/tbvA7/ZGkyLucxHH3y2sb0sgPXZ548rhezJ6Zlt8sW1lf/+X/63f/UXf/HBT3/47lff6/ni6e6eIOLdt95qtVp21llYXfj4w08sTVtZWzk+OLq4GhYKlVfubp6cH4VpMhj2OIenx2dvffXd2Ju83Nm9dWvVsgDRtVA3D/Z21zY2xzv747FfzefD8TgwDdfJlhweRDHERKSh5NwyiJnJDCfh6qJ9c9F+ejibf3tLzkb9XnN5sfD+g5GPe/cXajIYPBmznzy4/O1fe807fabx4N1XF97//LK5/+zVrUaz3cpmyoPM7MPD2TtqNDe3urVaPj68bJ8OCeded/DuW6+3u4fdfgCTCdCs6Sx4fHB2b2PhH3/n7s8e7D98tnt9bXGC8WAari0vRbNehmgC0d7AGypNh2oa9izTcXReLikv0h6cpZfTq/fe2r6zvTYdDscef3l8UinlqrXiSatnTp1aoXZyPvQ9trS0wBQJxsfFYv72jbVPHh7F8vidV9dX67nhLEmSJJst2KbsjUaHZ0f1em2xanszEcYDg1pCylq1MJzIVj+cTOJra+oPfu/X//f/+JP//Lc//dYb9+HW8kc7p7OpvmR318rVRqZwdNg67E6zjk11O/FDzpNQIYA0R6dAQkuTVRfPgjjCphJKSg4BNHUNK6kgHUYRAEAzXIHpLJJMxo5jAiEUBhoGWMM8lYxgiUgUR45EGgYxEExhn2GcJLahGUAAyBCiBtbjMLQLeQw5QAITkCAAAGRcCClyNvKDRAKUpEJyCYS0LIpMygXECGOMsOICA4i1yTi0TV3TkEEJgpAAlTCQMokJZlFiGFrR0qECszgJmRiFoaFhqBCHJBUEhalONaoTnaJA4DSVvpKUQM3QDBFhYARc8ijQNYfzFNo254wQqBD2osA1tSjkEEHDMlkaQwSlkJJLDgBgHFADCMAF50AgIKHEhCCgKCBMCo4RFpJBBIECTDGEEVRKKQgBJARxLgFGnKcJTwwCFERASM4ThYgCimoUMpkKQQgFCggJuGI6MjhTAnKMqBBSxxRTwJhMhUQIIYhTwTCACANNoxAhKRQCUFKCBOcASAAQQkoCJZWEAmqEKCHTLx2tVACmEMKSCamkVEoABaQMA5+xWDGGMGSMQUQgRRASTBATEgIBAcZEAwADIBWQQCEEQBzFUnJENQB4KjlEiCBEqG7blhBCMAEhAEialkah0jTjy9uEkHGcCi4UAowlkicUY1fTS8V8LpexNEvTMNWJ6VoGRTpGBCmKEIAoYalSKExkmgCAlaahrGPkHFNHWEeUaholCAEgU8m4SjhMhZqO+0iprOXYhm6axDBNy9LCJAnCZBYKP+TjIOUQspQpnqRRzBLGpZRQCsgQJIKJNGYYKpdIgiQCMojFcOp5swBp0NKMOE6kFLapAaD3RqPRbOwHcRyFkgUAcAmUBHQWJABQBWlnNH15cHnVm84ilipt4EXDQT+Zjk2N5TP5bGHu8c7+k8fPZ6OpqRm5fHGuWlxsVOfn64HvT0aRP/MpkrVqRQowHU8KObtWLel25vTkwrTI9vaGkNrOXtOLglolrxEFpLJsW6MZ2yieXzXPLq90TSd6lhIt9GfDQas6X6/WFl7unoYhc0tZ07L8cEYx9cPJ7sFhMWutLC5Rq3F+ftlsXmhYbmwul8rVSZAqolMwxUCtbWw4meJoMPA93zCJTmC9mNNwmNX5es3QeQCSpFjQVpZKVM9FDJmGmy265Xqex8l4NAV2fRbL9ihikqYK5G1pEf3w5MKXdLmsaUB0erNuDEAm4yO73ep3L/3aYkm381fNJOVJHEWAYLvgzDixESvkyolC4wkwNXvi+6HPikWkEcADlskYkVJJkkIm6yWwvVzuToNWe2QYyDDIZNzXCMhls0GQBhzOwiRnGCKNMvmyF4AHO/072+VCuTQeTcPEPz+5mltahVA2qs7qyjyE5s7ekR+IyTQydD3wwo8+/JVha5VKvdtpWaaxMDdHMD47P+722uVy2ck4axurTx99QQnwZyMl4crmRqbgTr34+PT82eOnUZr+2re/O5pOJrNJuVKiVumi2W3U6jduXkvC9Py8s765KdKpoRvPdh5DxOu1OlSQAoSAiuMkjpNW6/z1O3fXV1YGPf+83X391bvlnHbZHFUqFREEy/PF/+5//J/++k/+8qc/f/ra3ZXtzcVX3rgHCb48O5oMrnQOvv2N1/rDSfNw/ze+/tb+3s5gNPuzH/ywUCvdf/MdP4xGw+D8qq8j/uvf+42PP/li/zigutxeru8ctyee3++c57OW65ae7RyMxrN80c2Vap1uZ3Q1/q3vfqXtBb96ev7hx587VJUqc+32pQXh7/6D7wVe+u/+7Z9P4+D2jWsYyc8ffFwt1p48ejBXL964fpNJFMRwfnHNzpYEZ9FshHW6tX6jWivXFxtREk9Gk43tlZX1xtSfrS6thn7A4jSMQgUI0Zzh2O9124u1qmERnnDOPChVtlSOOR4Mpkih/njoRyJXqMRJRGXkWoBCORz2+q2OqQFT1xzXpjA1cFJ08Uqj/MYbd7/z679WcjXGGIB2t99XSWxR4nkyW2kUawujifjxT99//vgzxwLb11Zff/ONbC5z3p4+fXr0g796/2hnp3122Gsfl/P6xtqqSANCUL5YjCK/0zxR4ajgWgmXL48uTCvv2Ln9g72nL44YMt56661SrcAV6LQ7+7snvWFvYX2tUK1ctkdSably3aCQBxOQzDIGtZ2MYTlxNOl0esORN/WiKElTxnK5zOrqOjUsFs9evtgZ9mfVcuXNN24uzxdtArKONgsn5xfNcfdqqezmDfzsyRf7ey+6vW7sz5I4Go39y8vD1sUZCz3Lzt68fc8pL26s30zi6d7TR/5k4vvRyPff//D9w91nBzvPmmdNgPWF5bWvvPPm6nxOxl7BMZWQh2etrp+kQrNsK5stVMvVNAm92bRUW5iv1UQ8Ozs8HEwnzbGapjzm4rQ5Ho58BQCSzA/GhkGTJFURn40m5+dnhUIxa+guAjKNW70xUvT+jfXN+ZKF09k0SpkUALiOU1tcyFUXu2P//PJqfmF1NImQpg8H/avmpa7RB1886o7CxvrN1Y3rCAnTrpXmap9++knojZYW5nae7q1sXPvub3w/m8vopvvs6TPOgnq95FgO0MxcodqfsaO9w2++edewFFRgcXFxoV7ce/RAR+L+vTuuCV4+fvy1t16bm6uFoYc4/+R5C9IchmjiTwiQrqXefP0Omw23lyv9zuT0vFturHb7w3I1u7K2dHHZDoS9c9qZW1koVmwW+b/4xd8fHx//4b/457ahXrt3u5ijrc7FwsLKBz8/+ou/+NuvfOvbd29ci8JwOBydn7VNy+RhPOgPIQDnzfZoONEo+c3vfS9fzB0cHsyVy/Vq4cNffPzoyVMhkR9EOTezsjSXy7pAqXKljon+cudlEAa3790xLb1WrbquBhU7Ozl+7c3XW71hvz+em6tMp5O8m5lNU87juUqOs2Q4nrrlqq0ZJiVplEKCXUeXAoShNHNz2fpcpzdLk8nq2iIh4OK0Y9rOpBdWS4Vr16udpr932KXZ8u3rDSTUT37yObLz7Ymg2HjzlaWCjYSMEIRIgndevbvoiqMWPj7rxr68tr1er5Vv37nhOvT9z75ws7UbN24tLNU21krb62XA+cePTw8uJ9/5zne2lksv9y/yOTdJ008f7AUhTFNWyjqmrsJZKHRX0iwXiYhTJ1d679XtO1s5norPv3gx6I0tkyzPF7e2lggU1bnF2zduR5Nh82KXx+Hpydnj5881k1huYTqOCjl3e7v2Yn//xz/7cOyzaiUfzCajYS9gXmOu7jrZqRfVGitOxoy88VX7yvf9KBaFQib2+q2roz//iw+jkP/BH/z2bBL+7U8/FZp9/+YtM+h5Q/+02euNwnKjfnOtoRE1mfim4+YKpYxpxGE0GXv9aTT2EmJarusAKFPOkG5BiAXjIomVko5lQs5QGmoISoT9RE79OGJMCKVkCjizKDGAhCqVIp36XsKk7TiUICKZ4JylaSKgYVgIYaKRROHBMBBQFwJqGs26ppAJgErDyNLtcimXybmUUtuyEaYUY5capkYyNoWSAyRNQgDkgNJUSpZwKDmlUqfYNk0IeBAnumEBhTRMihkrb5g6opiilAuk6ZpGRRoLqMIkieIIEQ1CEYcsjRkEJGEpglDXiWvSrGGBNExZmsapEFIJkDAuAOYIWRmHEJQmIcZUcYERUAghBIECaRxzwRSEOtWAVBIyLlIIBQRYKqwg4BJQTMWX6S2EUgEFgEIgFQBCiCHGFGlERwgrKThnRDMoIVwoqRQTHEApBWeKSaAIIhJwZBCNakBKzkQi0jhlQgjFpOKCCU4glkAhhQCCjHEIIcIKAQmYUBRhISWESCqlaxZTTEmFINB1jRCIdI1BwRhDEAOoCCFKAEyIaRoYaQgTnqYYAQmkAkAjOkbQIFSjmgBCSEk0KIRgjEOMIIaYYCkVkApDbGCKAAYYUEqVEkoCCADGGEoVsxRQCoDASAIEIYa6oUspMMBKKY0Qx3Us09Cohniq6SCrW0qINEoApIZmGIaRMsY4w4YZMzaeeKkSBgUGFWVLc3Vo6jSM0yhK40imnDPB4ygVkiMFKTbLxYJjYo1KDSOMFJAwCuOpF4ZJGiZiFoQJ4xIKKSRChOgaAlApDiVOUyYAMHXDMHGp6LqGziX2o4QLSDG2CKQE2AbKZbMaRHHCGFMCYk3HOccpZDOEUIz0MI4R1ABBfsLOOtN+BGccxxGfBJ6mabZlLswXVpYWTds8ODw9PulEaepkrVqjZmmAyzSbz03G48CPphNf02ipmI9jfn7eShPh2CahuN1rAynv3LyOINzdPxyOovnF1UzGUCoqlfKZTOm8Ofzoi8fdXr9RL9umw1J+dHi+f3BQLpcbcwunF50wAk4uk8ZqMB4mqYjitD/qbyxVN9eXo3C283y3MwoWF2rXN+d0KMezeDIRg8GE+d717RVikN2X+1Ika8v5RjVTL2cKjipktVtbcwULQha5LqlXa5pSJxctCWUSh+EsHvVHg8lI0dxF13t+GgWxDINZ3gavv34rSNPji7jRmCtW5zqtwbjvWYVMqvSrZj/1wLdesZYX68/3LoUE26uLSRIPAjhLcWcwc22QcnbeDrwUFGoulWHWgoVcBkGRtbXeOAYMG7YNAMg5UIfB6YWHdA1DQHEKpdBNQ7AUSOT7zAtTx7VsTYfYfbp7krfB3a3qYDwRUDUvrtZvXO/1ehTGy/V83nV/8f5HUWpwQKajoaZrHEiskZXV5cnM67aaq+trAMGz02Os64HvXb+2sbGy1u93Hce+dXPreH/v+s3rrm22Wk0Brb2Dw2xW+43vfDtN0rOTcyVkpZgXUjUveo1G9eL8dOfoRCFrGkQsCTut0bA/eu2Vezt7LykxLs6bUqabi4sKqMX5xlyl0hsOHjx9VswWblxff/78USS0Sa8Hk9G/+D/9/s9+/OnPP3xwfau4tlwSadjtjZgEEmOdgq+9e3s2Gf/53/3q9nY2CXyMMntnnSd73nvfeO/l00cxB8cXlwLg3/oH33/25OEHH35q563f+0e/heP2D3/8pFKsRtPZ1ua1Xnc0GqdJGJk6XFxYjMb93/jKum5pv/joyfPDMbFzi2WTaLYGebVkHR4ffvTZE2i6hVKlnNe2NlbyuazrWkkkVxpzw+Gw0+tZtuV7/vnpaaaQWVxeKOYK49HQj8Jus/vs4ZNup+973sHefjaT6/WGUSQQRlEq2/3py6NWu9O/trXhZPTpeJLESiJanZtnKTctp95Y1A2LIkp0A2MNcJmzSSVnWbal6dbycj2ft03d1AxdMHltfXVxrgSR6vV7H3z4K84jzsGLFwcQkdpCPRHRrz789PRssL9/+cXDF4V8qVyp5rOZ1YWqY5JSPn81nM7CKFN0lBJz9fzNreUk8SbDTj6jC5bsvDxQAL377rvXr12LBd89bwmBBoOuFCyOhevY00m0f3SeMtHrtLKuvbq6mC+UNKjbViFi5OHORWsYOI67VHXncmbetUfjcRqJfL5INVKuVur1BQ7JYDAAGHUHfZ5Ih6ZvvHq9VKofnZxmDP2tWzeXK8WcaTgEbK6vLK9tW45za3vx7Xvbr9/dLhddKYGQIJvPFcvltYXs9e3lRqMecNlqTx8/27l148Yf/dE//MN//P1X7t0pZCqmkzHdfKFcump1Ty/bT57vjkfDGzeu39heyTomMc1xCI5ao/EsDqO0P+gYllHL5xOuTi+uDF1/95XbOkqOLme7Z4PPX14A2zWyTqfTciyrVl8YdPuDfocSqhmkWCr6ftjtDQ1Te/ud2ze2l86uxrOQLTTqtTyac6GukiBgszBCCEVBLDkrlBsjP261euXqXBgHWCMUkZSltYWVo/PWg+eHnhciID56cpAK7cbt6yeHp4Zpbm7c3Hm5++Of/JxSo16pbd+83WpPG7VC1jUJosPxbO3aay92dgsuWq4Wh37cal+99srdOGEPnj7KOtb3f+MbqS97reYr9+4UsnatZj455Zej4cb2Vq/VbFQrcZQChcr5zFfvL99ad3/1wZPeVGbL81eXF4WsiwlmSj3fPXmyc1KurkSJLNcbH3706Fcffrq9vpzw4Z1b17tXV3NzhVIB//QXz754OajWC9mMDZEkGNpWbuxNivnsjdvX2q32j3/yyxd7e+PZ2M1ky9XS2vr8Ur30m7/57WA6mAyHUgEBuesYjmvUqvXhcESwVipXnj1+uvfiMApDnWoZw1xeqadJwFmwOJftdzoZp0QAlVwUyhnAmWJRo1JPFGdM6ZquUVQoZ6AIc66edfV2H0z8aH5xXkmgOEz8cGWx1Bt5dtZCEJ4enmQdurKonfejj162MDS/+1ola4C9s+5ZP905uvAmo9W5SjljaAYNI46w+O5XrherJUmc1mh4etG8ao/a/eHGzduEogfPdgeTUTaTrzvaRs2+s13WTPB3H1787Ec/f+edd167OTcYDjKurWugP5pcdrzRaLzYqFNCu91+FMdYd/yU7x21B52rhVJ2Y6lCIA4YLpUaCLFqwcgVSy+ePk4T/923XivnsxDAQqVoUHvQvDy76sUxVlB+5a2b771zPYjZ+x89vWiNs7lsqVygEHnBMOtq40nvstunmGeztmPZEGEJEQHgldur3/3m2/ki/skvf3ZwePDGV9/mGv3g05ecy/vb9QQolorROL64aMdhtLm0UM5pg8EoSRM3YzsGTRIxjsUgFF6QCAUIEAqBhMlEQAghJoinqUXQfDGrE6SkxIhqppMo4IVxzAUByjAMRIBjkHLOLroWgVosuB8nukZNA2csigiWCjIlCYUEQATgJIz7fjyK4jgVGCOTUB6rNOFhHAoGDYIyjqVRjCBJmFBEuYZuYJy1NIowUhJymYRpGDGuMMDY0bSsrbuWTgRPUxEwHnHBGNcA1CmAgOsaUQJKCaWEiZBCAUA1xkSaRFyoVMAwFkEcQak0SiBQGIpClmYd09WpABISJAlO4oglydSLZrMpQQQpqiSTEDEhAUukkpIzpIRGdSF4nKYAI4OYSgAhBQQSQSGEQBDFPAVKSQCl4gAAQ6MUYsVTiBEAPGWMYCw4wxISSgBUEEvdoBApomNEUMK4TCWBmAnOmeSp4AlTEEIdU6ohiBHBAAONEqQkRAAhKIRMYwYEkAogIQQTDAEUpRxBkDDBuWQsBRIwxqWSjKVJxKFSGCL0ZaMQJ1xwjDFGSHIJkAIKEN2QEEKAgVQACAEgEylWiBBKCZJSEapphiGlgBIihCihUkmMIYAojWMWJZxzICEllBo6RkphpFFdCoF0jCRRQIJEcJZiqCjWlQTYoBimEEvBA9swKMJJnAglAFIIIyWVF4RxyhKmwiCejHyFgGWQvOtUctl8zrYMI0ySyWyiGRQQqFGDcZEqTjWTEEoQhUhhxAmBtkEpxBoxITajBEYJCBNGKDI1zXEcXaMZxzIp0XVku5kklULBNI2ZYCaiAJCUIz8IuOQ5U6vnrbxjYsUcy7INKmAKhLRdy9b1jGURTGLOgiiZJmkQs4iJ8Wg6mfiAI10zpUh5mrgGu7FSvbk6/+YbbyAMnj57HoWJZbkEGyGHzc5pFE2LuYxuWoEXDYZDIeKsm08i0e314jR1M5ZmGf4skClf2VpLGdt58fLi8qpUzC4vLcy8kOg5qmU++uCTX/z0aa1S+M63vmnoeqffPj4+TEVy77W3Svn63v5566JrWGQ2m0bBFCqQL5c0W99eW19b2RqOphMvzJWde/fXt7YahZwz87yjw/PecIwMa3O9opto0LnSbWuxUatXMhQEZRe4Osq5NqBuigy7UDKtXH88Pjg5x0o6VmYaJLFCRqaEjfzYC69afiWnr9Tc6/PWV9681xnPfvJBz6Hg1iI8ao9bg9h0EFGkddEnXH397crW1tbLnePpDN7ezthYKCkTYu0dTxtVt1As9IZdqZJ8gdTzWRLHGkaE6IXK2kknmk1AvVYyEUIAuE4mHfcnkeZ5ARCiUa4KyQGASeJnss54Okm4ECJEBHjT0XQm1lesaf/K0Eyg4MLiCk9Vu9Wcr1VzGefiorN31NXsbPPibHG5sb68QJXY2NzGCPh+olmZUr785MmOYWQwwKVytVYrtTstzbCu37j/4PMnXIFr2xtnp0f3bt48OboihP7uP/5dlnpnJ4ejTl8zaJyw5tXJxsZCvVpTXB7tHBgUaxjlcoXLy6vf+Ue/2+x00jjsDYeTKH79ra81xx2KuG3nD86ufvyTjwkW9+7farYHZ+34st02TaO+MPfLX7z/8WePi5XqzRubxwe7vu9PvcALpe+FxVIJceZPZo351bnGPDKdGJgPnux95Y35Secqmk5vv3obwniuXn769OknDx5STF65f/3Ojesf/erJ8kKhUCpkSrlgNoslH85m3cnEMSll/mK1kM0WP3uwc971rGymUm0kjHc6bcDjciHjulkuTS71wBsaFHTPT8qOdW17y8qYp63R85eHVMGCY9gwqpdzGMNWq9NrXSoWJkE8Gg/zmfwbb70dMZXP10QiYi+GiPgxEJzbmWJtfmUSRO1mx/NCohmc6t6MHR2dtC/a7dbV2fH+bDIkGvG9gHIvn6VZy3Qybuh5GMhCvtwbeReXzdnMK1VKw8GkN4mCFM78qFSo02z1pNWGGNTmFiQAhp7ZWF3AgOdyuWvbq+tb20GQxGHcbnWeP3161T2t5a03X7335us3tq7fMC0nl83cv3+7Uc3lMtlivmCYdirpJ58/+vDTz4VCtpmvFrKcCSeTXV+ar5VzQKVXrXaaqnKlUq2UpIi67ZOz05fd1mm7OxhHpNdvd/rD2sJyo1E3DWLo9Oj8gjHi5HOnpydPnj7xvYRQIw4jbzbjAC0uzgGAJl7QuWqfnOwf7D0fjkfVenVrpW7q6OHTpz/6xaMwkf6wNel1G9WS0FAUx5PhUEZ+ZW5+MJztv3z62WcPB91uIlWnO3r/lx/++V/+1ScffxD542xpLkpApb507eYtalhAkdZV+/Fnnw26V4PRJE25ZZsZ27BMQDAkCF6cnVsmrVfrQsGzszMm5d27N6+t1fNZx4/AyOd2thgn4sHjg+5kVqguuPm6gjSKIyCBrWtKpKaje/54caG8tTZ3dn61d3qZzxfv3Nq6d2MTY5kqbTAMJ6PhxeXlzsunkkHO1dn5mUa0crGcyZVOj1vUypuZYrPd+uCjj+7evntjNfP48VMGHLdS/zf/6f1S0Xnnva+8PGk2u90vHn5i2/bcQv6q1Y78wKK0325/8fyl6Rrj0eiV+7cdU5/4k5Pj/eXlZZ+Tz548YXH0B7/7xuef7o/6Q98b3Nmed3Lmhx89vHl9PQrZ0xcvrFxpNJ744377/Nkf/pPvF3Lu3ll34vuO6X762efZQml9qVDK259+uHva7na8KFtaXN/aHFyeXJyeUYGq9RoiKudYN7aXIIIn5939vZ317Q3DMSXEhXw+9IOr9mXWcb/3vd8kWPvgFx/0uqPpbDaaTCfDMaIoCKNb127wOG1eXiJIF5fnJ8OWjmSUqL3DEyHU1ta18bC/ubbBReKHXrHaIJgPu61avUQo7Y9mxWpl0OsDpcrFimk4u4en64vziTf1Z+Nmf2C7OR1wFY3rlWwqwe7eaWNuLo00P0j8JDE04lAZjoZu1mYI97uTUiazvFpp95IPHhzpGn3z7oLkKmRgEuCrbjyZzTKurWs4DKa7z5+/fPki8QcX592UgwTb2MhfXnQffPZ4dWWlWMh0W+Od5zvDUcBYUquX3n7z1iu3G/vHsx/81d/curG9slxCIFldrudKVaCZZ1eexMbm9lLGBIEXDKazaQQkdoKAddsXlDCExBdPWj//ZBcQN+9a9Wq2XLQ+e7AbRMH69nXNMIZDLwxmdiZTLtbag8H+/t7p6em1reXr21sKoP29/SiGM5+ZTh5jQyKy1FiKQ09KSLAWTjzbdgq5XH/YVYIvLC5/+1tfe/31t1+83Dl4+fjrb71TLWf29o8otlcW6kBxQhGi9PhiNByMtzbWyhk86E8n06lp23YmYxINKOCHcZDIrGMahLAkjNM4TVPdNnOuZRmEUOnaupSMqZjHMWciZDCIecwVNSxT0zSKoUgt2zFtg3MuOZt4YSoEpDohulIsTRnjHADomtTQqe8l3pT1Jv40iDFBmoYxpYlQfuCFgc/jMEkjCUWSsChOGOeUwEzGzdmmbRDb1qGGUqn8OI4lCCKmgDI1Usy7lk6VlFHCJ1GcCqlTmjX1nGZQqgLfT1KmEz0IkzhJJNQEIlAKCJRQKoiUUpClHAKgE2QSrZCxXMehWEnJKQKZbAZirBQMYzaN4oSlgBCNYkKI5hoGpZAgBGjEGRMKAihTEacphFByFkahkAoDZFCNIIIQhlJBhISUQCrOOAAIASiF0DAVQFGqKYoUhEoomUgMYZIIgrGOqW3ohmUKJQxMFUQAQEiwUgoImfI0TbnkQtdpyhjVkOIASkUoJZQiBAFQSEmoFFCKGxoWQiKoMNEwgUxKhRAlGkZYASUAkFwIJhFGBjUQpBBCln5ZiAiIkJKcQAohkJJHSQghhRBjDQMIFIBQqYSlSkjDMCFGCUtSllCCESKcM8M0NV0nCiCKMaVxEqepEEphiJSSIhYKYSSxZhoEE0J0KTnGEBNEEBZMKiAVVBJKL4x5ojSqAwW8JA3CKIlTrmCYpBJC29JtgxQsrCFBgIqjMOGJphlpwqRk0zAIAt/QDYQAl0xRQTScc+1CLmPoGqF07Pnj4SyMU4IhJaqWzxQzWlbH1MC6SShGECB/lkqEBZC2ZuQsApSceNFgNEUAVly9mjdKedu1iOvomk7DJIni1LRVwSSuTgCAszgeTLkXJXHEvrSKUyEJpcWqk3NpxTbqZffOevnOreWlpfrRyfnnXzyj1Mjlczqhk0RNJ+OM42xsrJQKlYPd48t2j1K90ai4jt1u9z0/pQhZlmUQfRaEKxurrmk/e3bYG02WF+fu3bumFE9T2Ox4f/LnP3n/i8vX3lp9+41XmucnnfaFFCrj6jev3RQCfPDx52dn55vbjXrRvra1tLQwV6mUbM1xTVPH5LxzddkeMsYbVev6YkklfHe/3e0GhGj5rJ7PGjql3qDrOlat6iglR6MuhFAh7EfJwMM754MHL69Oz7rDcTwYegroi/UKC6MwFhBbCeft4XQWhluLmRub1UZeq5St0WT2g1+c+hjce6WiRq32LLEKmg6lmI3WC+C9+3WXaI+fvuwNg4U5M+8avfYVMvTOaKpRcH2p2h3HGANLhwBwTLEQLPHU8kr9fBp9cUryNbCQF4nvTRmQ0NAUi7E7DGTORQCIMEgkBIE3qxSdMAzzuZxmGvmswZLp6nLR87ht2pVa2ZvNpjPfm45qhbwXpMfno8OTMwKhFP69Wyv3b29aNiWasfPsmWlahBKh4OOdlzvPd0tzNdM011YXxrNpHHsZJ3twerp/cFYuFaPIq1dKrU5//3D3jdff4Gk6nXntq+bCyuK1jRtHR3vFnPkb33svmym+eHEkBHjznVe/9s2vHx6fv/Xeu0dHVz/8+/fffuudqTf9+nvvEmq+3DmslEpTb/bo+QsAyfbm5tnJ2dlV88mzy9752Y3793ZeHnXHUaFk37t3w7EczTRXGnWDWsNxHMZsNppYFtEMXQJ1cdo7Pmv/5x9+uL2x8da9uzsPHn7r177qj3owYQvzRcDZzfXVJAmX5io7O3vjEL5+b1NxLwjSgMvtazej0McKEajqRe21118ZpODpSZfx+P71dR3Ko+Z0vpK5f+fGqN9ttS+ri1Um2MVl5/T8rLFQvH1n+4svngxn4vhyDARYWV5YqLm/9a2379+5trF+E+v6O+++9uabrykROplcnCbT4Szx5YcfPDy7uppbWR17cW8Ytbpj07IRwrlcVWrm8VnXFyTisjfxsvlctZGzDYIowoZhmJapAwsHBVdnQoWRyhSKQRhcXFwxDoFm2JYNAA2l9vywed6ZZrOlWin74cOr0SguZjJAyYvLXnswRZKt1PPXVquSJ/svn0usTNvy4pBqWj5fXl+Zn8x6Dz5/fLh/0mq1L9rNw72DSW94dHgUeKPrW2vlUk5p9s7JeDAMbmyvrK6sEqIHcToadxuV4rWNJY3QiTdtXjXdjFHOmkuVTC2nQyy5kIaTzecyZr706OXZo2f7QKbFnBvEcWvktdoj07az2Xy9Wszlc7PJcDKYYkSand546nHF3nrzjfnqXJgAT5DhcOyYxupKo1Rf4djSDBuDWMWznJPP2jkRRt3WoD2Z7R12vtg5ObwYb67MvfXGxuL80s5J76Lnbawvff97X5uMp7/8xaOTdn8wnQqpHNd8/c27d+9u5Ys2pVrKRMhFxtGrOTvnuBhhw7JzpWKcJknsmRhCBJ++OJpMWdGl1YLpZMhFazSLony1kkCyc9B6ftC8uhzEDHhh/P+n6a+edc3T9Ezshy9/zOtbzGsz5U6mwi5qbqnVopFGLTls2Z7zcYQP7AiHw+GY8Ik1M5JnQlKDWt1dXdXFlZVZybBzMy3mtT7ml98f+aDs/+K577iu+/F9j3M2Gg5rhWI2l5NJMFVNSynvPTl4snPSbo8pEGnHSBLmc6ggWahP3b66hjSONWRg1Gl2ms1zCJHStEnozU7PaIToOhFC/R//xW/N18w//8uf2JV526T/8S+/X6+Vf++3buccgxLaOjvTMeGK9r0onSsiqAoF66QxPDlulovFYk43Cd7aP87ka9jM7h323vnVh1MzM9dvzhyfHF5eXfI6jXKl0ml24snozdcuNnvu+SCOpJpbWPH9EENoWLRWLjSaPTOVLpWq7fZgfbb44uX1qZoxCuE7Hx7+1Q9+Nb+88dvf+s7M0syHn3y2sXYh9sIvv/j49ZdeytmWxpOHDzfz6eLS0kqn20wXsrpu9gejJ4+3e93WP/zDP5iuzwzHk/n51U6j0Wg0fDcYDUd3HzwqlIsLc7OBPy7lcpqmQxXlU4al0f39Pd3QU6YdxlGpVD5rdoajfi5THI+GUmFNpwokpmV6XE6C8KTRmF9ZTRicjAaLS1N9VwQJ7veDlGVZlBeL5VSOHBw1DKqns8lJ29XsFMLCpKDR7GTLU/lqwXe98/Zgtp66fqnWcMGvvjwHen5tLu+PPWzAiMOtM++0G9WrxemCXZ9K+QlcqZClWeu0HZ01+/3hZGq6rlN0tnectZ1yMSUAPe50el7ie2HGEK9eXf3O11aEkk+3H6+sLOUtzesNE2+cTjmZcur44CTwJ/ViqpDRNET8KGk2e4EE2VxZscnC7EyuZB634nc/22p1Y8dy3nrztRevzTx9sj1y3emZmVI+hQDstPqGQZYXZg3dPj0439naqtdzC4tTlm0FodreOW2c9oHUxyNXYjVdLwGMhAIRY8Ne7zfT61sHR8dH551uK63j7/zW106P3c3t+1cuX4FE3n22z4m5PD9VTUND14Cl7XdGnU7nyoWVapYmQSwFtynWkDQ0BCASTEIuHEvPOqZpEjcRw1HIuMAIKaE0jLLZtAkJhAwqwJiKpOp5stcfhTFTCCRC+b4f80QjwDSpRVAYJxPXTTinmAKJAKBEcZ2iaj5dsDHRoBfxIFbjSZwoSQmWUnKkoKYpqaBAEEIGIOMwjCIuVcJCXcOGgS0Dp0wTQuhFsjsKhhHzk5ixRMPQsA0IkUIoYcqNGOeSUGhoyDY1x6QJ45jopp0au95kMpEc6pomlQhjBhCIY55IITgIw5BDpYTQMNApsSjlgkEIHNtgSgCMGBdMsCiK45hJJgCTSgoNEQAghoBigCCCCnIhpBCEUmoaGCMJVMITAADViEJASEUwjhOJKcWUQAwRQlxIBXicRFBxBKGQghg658wwqOCSJSxgiRDyN5orxgBhCBAgEEJEDEJ0A2NMojARCHEOAIYxYzFnEgIuBQQQQQghRJwJxpWQSgopFYvjmEANKaCA5EJBAEXCGQCUEimlUCKOQi45MQg19DhiYRIlCeMqAQjquulYpmSJkkApKDgDihOq65gIwXkSK85sw9I0M2aCyQRgTDVdSgUwFlGcxIFBdUo1ghEXEkGqJEdSScWE4BAjpSRA3LYsDWk8EYhqEOEwigMvBggAihMWe54vEm6lbM2yuBRCQtPQbY0W0pZj6U7KFEAKzQAQU13nSnEOFGOmlSIQ+2GkgLRNy9CI4iKJeRzHQRyftXsT5qdSVtrR5quZpdkaRUpKAZicuL4b+t3ROEwSDOKcSaqlTCHjWAYBQJq2Vimny0U7m6EICQwhotpk5IZBaFlO2nB8PxiO/bEbjbwoSiKISMrSqUl1XcumnFTK1kTkELY45dy+snhhbTEMvS/vPrp792mhMDVVX2q0hk+3t/1hv1xMv/7iLcuxHj151D1v1svVSrVimdb5eaPdGxmGkc7mJp5/1mrmS1mD4IOjA9PWrl68fPniehJOGieH5yeNzc09otHf/71XX3vpxtnZbr/XzGWqhUJ9Zmqq02588fkdRMibr75azGaiZKIjOPGjdqs/GnVPzo7PO6eKx7mcUa3mr1y75kXJ/cd7/TF38iXHxhhEOZtGjCPN0jQiIqakolouUvr5kJ/12dOdxvHxAChspUymEgaUmU+N3XF35PUGbqPvnncmg3EMAXZsOum3W42268F7D46wBN/7+tV0ZqrnR5ZObUiypvm1N6+9+tKyN+q3Wg2FjEI5nbFA6PlEM5WiKgFXl4ywdTYJuKlkMAxqlQpDtDMW9ZpMa+T9958IJFans5j5oTuiqTTUbSHl2OcIAcckBKFIookbpfN5AFAScYKlRbBDsWSJbdkQ8PnZ0tFRc+DGYRiYFBfyduDH0MwwAaq10qWN2fmFuueO253xD3/2625/qBFyenYGAe522muX1hcW5zQDcik4oJpmjV3v/PC8WC3Vq8ViMS8B2do/mapkNaCePtvqdHqGZV65enVz+1kum7129Uan0/vw/Q+arearr95eWZu9f/8zDQERJ3/7tz/8ztffhkgOmmfZfP5HP/1R46RdnpqOAjelkT/4o993A9Ab893D4ZPn59cvX+q02m4EmGA6ApE77HTOidIkRJvbT4MoUNTsdHuXLl0c9JoEyFKp0O70ocIXL19kPChVc6bmnB8d3375BhBiYXUp5qCcz2pU3b334cZGpVKtHB2du97YSWXiMDBAeHG1srEyl8vmnu2dvvvJY8bk9fU5G8dnx3srKxsby7Wnz55QM/fdb/3+oNk8Oe1evnZzefmSTo0H95/cefiwPDNjpZ1CLgV5FHmTR9vPHj6+98tfvvPkwdMwmNy9+5mRyhCMGRe+Pzk42sFYpJ3M5tOtdnfU8QVHxvHRwfHRUczYaMKsdJEzPu538xnzN13NwsLc/MIqwhiIOJ/Sb125cPXSRdd3G2enUiamYQCNdrsDhJA7Dh89fn7eaIWRCvxgEgTPN5+Nxx5jIgHy+Kzj++DkpJXO5q7dWvVZOHYnjuWU04WJ68UJX1y9YDrZw+NjjaTnl9actGM5mWK+mM/m8lO169evKM52t59ubz6JwuSF2y+kU9bBsy8/vvuo0WonCZtZWnF9d3dvf362Vi5UCLYPj0/zaeOVW1evXVp/4daNXC7XPD3ggWsiSAimUGwszTo6tpysn4BxyBYWV4hGJlHgGMarL91UQM7NLbo+i5MwbRvHpwcTn5F02Wf4tDM47w22d7ZO9rcoimU4zucqV69ey+XTiA9fvLH6u7/9tQsb17pDNj278N3vfqNayDWOT3WDZvIFRVKhhK3u+aVrV65du2ZQ3Ok0gsmg1Tz7/JMPw9hfmJuam5m20hbgwhuPNWr4YeJ6ccKA4rHPRaVSyBecTL6MdXvijWQS5Eyc1SmF8uSsf9D2jUwmk6/ousMU39/e9d3ASTlcgWZ3+PzZwaA9nozdiyuztZK9tDjfGCQuQwJwhwILo1hE1NDdKEAEzE3VNEyz+fLU9HQqlY5Dt1ZIT7qtk/2t61cu6pZzPhx89NEn//gf/P5rt+ceffngzW9/O12t/9mf/6det716YS1fKF+9fmviTrJpAwjOeXLpwsV82vnWd1/59Mv9UqFwcWF+ppDJO8b+7vPFxRXXjfsu+PG7H88vX2m0u6aRXlue6/Z6S8uzx+cnULPmFmd3Dk/e/+j5/lmX5upuHF5cnqF8uLY0d7h/YFupwXiyvbOXzhrZNF2er7/2wqXzk+H/+D/+xXsfv/f6G98ee97/8h//5z/6kz/Z2Tt1/dHXv3L10lKpVpu+9+CB49iOraV1WKoXxoPo8o2bjbOTo4Nn165fbJy3njx/atnW2AvnF1cc29YsZ/+o2Wh1yvnMxA1K1frM7GqhmIJIriytDHrdUqXW63ZHrnfr5g0YxWHoUT2DsZZOZzQQ60QIDvyEtIfhqD/cWMre+WK/PLOUyaUcExhZJ2QcQA3itEVIrydxEuUd6kWoN2GEkHzK8HyZCKFBe7qIW33ZOj0vO+DyvHnUB+99sVmbX3phJWXKWKfqfCSfH/ZCnyPppy27PrdsW+hb3/jq2mIpZdL+kO0cNEtzs5CifrcjBMjnSrqdCSJ53mg/e/x879mdtKnN12snZ71xv3vz1qXVC9MS+t1W0yFYp9jzfT9GQIJsKj1dK6cM2Gp7XELTLPrBKG+RQtHwA/DJnd0797fu3Lt/8+a12y9eO9jd7I+atm0XcgXTKgy7LdcbJwKnctOcIX/cm60X0qmUqWu16Uq729o+Otg+OB+NQkS1jJWOBQOIcK5cP9AN0zLSRycnKcd6vPUwZ+uvv7r6xb2On/DX3ngLiOSTOzun7VahVChmDBvjKFF7++3hsL+8tJjPOVgkIgmAEgRrOqVKSiEllNIgKqfTtGEmAo3dyAvCIBZjz0dcYAQtJ23YlpPSCFdScC9I/IRNvERAEkmhJHPslIZpIZ8pZx2i0ySKJ4HPpYpiwYASnCkRaxBmbAdBHDHgJsLzkiDmhmkpriCHWMOIIig4QgAKDhEK4ySJpOBCR8rEEkOhEQgQTBIZxjyJlFBKSIAkY0nAwhhqivEkinzGeZwkhoaABqQCXsQSLnLptJRSAWgaxHQcgKDrR1wCS7OohgjWkjiKeIIQsCimGhFcAMah4BqhBCAhWMwFFwASLJTiQMY8STgjBCkhNKoTjIGSOsJUsyTABtE4Z1wKwSUESjCGIcQKIQCpgZhIABNIYYwwhFDDGkZQIYoxIVQDQiBIlAQaIYRSHRIgheQSIpRwhiGACHOppBRcKSUBB5JiiDEGgADJEUJJFCeJggpIKRCXQkmgKIQKKACkQgBgKRVAXAjmRzFnTAKFEERISQWF4CxhHCqpgBKSx1w3DCkEJgRCKBkPQx8ATDCACiKEIIAYUUwRhBQjjAkBgAgpKESUEAAwACD2A4yAwiCCUAqACFJKhH4ihYAEEs3kimNDBxKFccSlFFJJBaTgDMggDJNEIkolFBAiHkVRLDVdExAoiTmTnEtMCMHQNJGhYRnHUZyM3YhFCY85jzmFmqVrmUwaQ+iHgaFRiiFFAEHEpGBSRFyedwdUw6VsLmOAasE2CA89lwvAuXKDxIvE0OdJrBxLL+ZotWDmU8QyoBSJRUEla1oUYgA4B0ziwSgY9F2mhKZpuoZbk7A9GsdSDbwwjhJb0/KOZlKMkEIYeAkjEKZtfWm+vHFxHUDw4On5x58/PzzpF4olzuSn95892ztjEq1vTL/1yk3Xdzc3jyzTWb2wWK7luBKHp41mu1fK5QlFfhgwJgq5TCHtjIIJQaSUzxGgzpvt3cNGd+ByGd++eem1Fy8W0mRz+1kwCbPlOjGoEGJ37/z4+Hx5pfSNr7yAUez540p9PgjD05Oe6/mc+7O18vqFpY1LS1eurF27stZtBh98uhUwCDWj0Rx6Pp+emVpcqAGkh1EyGE+iRHIAzvrRvUfnm8fhYY/7EbiwaN9cTjuIWSacnapkdC0URiykQEijOoG06OBiGidx4DMYArp33Nc18Z3X61k4ev7s2aMGoaGbdmChWnu4ffSjjw5Ph4qpLMKMQgUUDznXbIcxXs1hJFm9DBaX17kX6QBYmdxBYzT0wULVdkfdOAqmq5QALfEiroTkKmBi4oYh8yEHGOIojsaurxt6Jp09OTzVbB0r7hjU9VwzO/Vo58Sy8GxB64wCTbPjRJgZvdEbhEpyloSjkaVBClT3vOFP5CdfbveGye2XXu/3u0IKoVhpauql2zdZ6GqUxJH45KOPY8ZSjrmyOnf71vWXX3kJA/DxZ/eVnq5WS8TC3V4PQLC4sDQZj07Pz6/fuNZoNU9OGkGUXFpftW3ab7W2Hz+/dOXy5uaz11+5PTM/95Mf/2xxdSlt6U8ePnrx9TdKtaW9rYNXbl7TdfCLT5483O6EQfAv/uH1tSs3Pvzw43qtUsw6hVLG0jUEle3oXCChKEQo9tzv/Nbb40EfQ4yA1DS1slAvZMl5szGcJH4o3/n1B9C0up3+yPOglJRobiwOjzq95vBb3371rBlNBrGp6Yp5p6dbr7xw8fLq1Mb6StdP3v/sma2hGyuVSyvzu5t7LA5rJSd0h6lMJmbyr/7ubw9OD158YT3j2Edn7d6EDX0BAJGxm9WZaQAAWKc3ePj8/OCwp2t4bWVeKFGr1ghC7U73wuWNWr04VSt+67e+MjszpelICRlJwKTiApo6mAx6qWwOIxGH3vpqvVzKJJEnBegPhmenZ71BT8ZhykSN88ajB/cdHadsLY4mEMM4CC5f26jXpg3LTOdT+XxqtpK9fnktCdzuIPj6C4t/8gffThk245zqIJs2UoXK53e27j3eqk/VF5bnFU4wlrZjP368ee/JzklfjmKQKebXVpcubixMT1dsx1JKzsxU33jrpVtXVl576WqlkBr326vLC+V64aDHxrEEAHgDv9UJuKLEtMI4noRRr+8Rorlj/86XD4aT6NVXXqzksKWrqXL26mLpxYv1uakCgUCj1PNcHvu7hwdRIINJNOz3s7l8PmNpUKwszM7XpxybSAW2Ts7vPd08O28MBm6jE0zG4TffvPK911bzFusNOj/59RfvvvcJVT4Eanv/eOgG03NlKZMgDB4dnPe8ACiRdmghY/kBv/9w7+6jZ3oqdWlt7oWbF1JZc7pWc4zs8cGZbWizU7lqLmMb2tDzd04a/UnkJlwhypUBETk8PI0Cls1m7HQKEbNUqgsRVrOOY+sRwI1+1BjEw/FYKVUpVeam54AiiYCN807M0SjkzeFwEkSUqOuXFqmKgOTn5+3zVldxb6ZkZAjodTqnjeFg4PuTQbff3z8+3jw8n5qqzM9XbCxWF2rd3qTZaFGqHZ50f/zx6aMHn//JH36joLEf/vBnlKT1VLnXCzY3d/KFwtnZ8dX1C1iyfMbY3HoeM/7gwfO1lWsX1rOnx4fLs9UoGfzh974xVTSylko7mAE0CVGz28/Yzr2HdyrTq0kiG83+aMyePnhuUm2qkDZM895W61cfb+9undy4uq7BOPB6lUJNoyhj0y8endJ01U5lWP/47dvz3/va1VTW+S/f3/ny0ZOvf/3t46OObqKrV6/cf/T48sZGoYJK1Sql5mQwzKdSVIPTpfIkTHa29t986yt+5PWG/atXLskkEVx1O10FNMOkXhBcunb5g/c/6XR6AtJP7zw4PO1c3FjBKmn32tQpnJydQqkOD/YiQV64dSnxw/5kEMU8lU1TILHwlhYrEQcjPxwPupVisTsEW0ft+sK8KQa5rH58HB2djYLEm1uaNjUwHp7l8jjiajCK2x2fmBogoNXuAx7m02ZtKn96HgXucH4qXcmh41byo199mapOf+2FhdUZLe2gsxHrDDlQeDQYxwzuHrFHm0+WV0vzNe3WtZpmwOPzdgTIOALnndHYi1JWOm3p5UI6nTEGXjzqnBeK6Wym8OzZwcnpiWGQG1evztZTRI0giI9b3oRrgNqTYduC8Wwto1tk77B3NgiIbi5NF1PEI0omRG+2o5PD0Y9/9MHS8vxv/+7bKSLSGSNI4liEUcKS2Asn3u7BWcARwXrK1DRKEYhuXp679sK6beqDcXB02PI9CAlIYUSg4AB1h77vx6trC4YOT86GS3NL25vP67V6sax/cf9R1jT+8W9fn5srPNt3tw8HBsUzVbucJQFWzxtuo+/WCplq2nA0GUcs4BDpBrUMNxGenzAFBE9SNs44NJZixIHPFWdAYcAlC+OAJwpDaOiIEp0JPpiEQSiiMCGY8EQwHiMEeBLmbTtFsVQ8FiLiIhHKC+Q4YH4YQ0oMHWbTOkHKD8MYQD9KlFAEIS/wOQMACNMyoRKIIgQQQFhAoDSiIAGQmBQTJBydGhrWKQIYhiGTAKV0krFN04QW1lKWhQCSXEkmgBAUaATRKE6gkiahlqExycIgApIRSgCUEUsiEQupmARcYqAI51JJxRKumyZEACJoQEw0QnSTalTwJAojhSBUBEMMAORISqAinkggqK5pJhUihIInMQcE6oRioiEIfyPfEoKUEnEikEJxlERx9Bs5VgoBIdQx4ZwpJZOIIYAkY5wziAHWkIIKYqwUggAqoKCSCgAlpBAKYqS4UpgAobgIg5hjoiNMlZKYUoAIkUBBDAGHiWIEIq44jCWmRAglJSAQYVMXnAugIEIikQBBiBBEQErJE8Wl0LFGqS6EpBADCpBEQgopAcYASIARBBBGkSQEAMa5VABDjDATnEthaBYXCdIkxgRwqWMMKQFcUKoBCJCASiEuEkqJiJOYC80wkoQrgYTiEgAsISQIawggiCDhglHdQBJAoHSNAiCTKCaEYqo0ggwElUwUNnw/CsMEAJByKISQQAQxDP0oETyXNjWMEAIYgygKEVeCUs/zKdUsy6IyzmZMWzMGrhx6YZCoYDJJGA4TxRKZMrVSykzpiBDCWRiGsWXrum5glUgFkgREPAYScqmooTGgEqZGrW6iTABNIIFjQ8swTarHLIkTEQURRwgDnkmhpbm6k3L29/cPDlqxAlYqD00+cuMoGnBFbDuzMJudqVUZnxwfnBrIyGQLUeCORxPGAWdxvlQQMe/1uoalV8uFtJNSGPJYIaphzWy0m2HIUtm0oelWvtJondqmkdJo4k7sVMb3k0Fr1O60dMu+ceOaYZLj44PeYJLOFcNJ0O929Yw2Vaouz00jQobDQa/VYV7khXJz50wCzcqYvteaqhYy2WzG0ff3DrqdwMnkBAOcqdCLWs1JmAAnLYoULc9U68Vsr9dKOwYwnMko8tzBJDKTMNE1qmBop3SLUEs33CTWMGZJPDOXni2VIBCPH+/2XWAUS7mUMJ3s892jVlvZWTVTqQ/6HRFGU9MFFXqmkfJ8N/AjACSW6trV5S+3x52xIhiMRkHij20TEJrd3mpoOo14ImUiocYlMymAGE5CJIVSCNTKxbEbJIGYv7qYTjkQwlLeyTkGBtzMTu0fdw7a4GuvrCTjdm8ckjjMO5ZjWUHQMpy0aVCsa1xExWKh3fEe7z09bY//8He+kk1pD57vza5c7HV6q/Oz3mAIsOQcvP/Bx2urSy/cvOGOXW8yOdzdnLiDL7582O32X3v59fnZ4vHJPiHy5Vde6Q/cn/78l197+/Xdrd1nzzbzxaJu4kQm11avx0lSrVYIBEEY16vVre2ten1qrj6ztbtlGebMdPXTT98TECyvL//X7/+402Rv/9OLtzdmsiq6v7XzZHvw2i1nY3nl+PSwVCm220Np6OfHgzBhmCd//PvfUJy9++HHN67e3Dlvm4btB6PhMF5cmvr1+59iiGqFqVbHNYnKGXYSB3snh4mk4Ly3fvFC2XSe7T8ZR3Jmqj4aDr7y2uuTYXN3Z7czZP/lB5/bGefiynylmDk6aTZHwWu31i/MF0OWHY33Gp2uZpo3r99kvnv3k0+IbWaKtc7xWSbjpAxtrlIgUCSxSDicnnU25utQhOWCBRKoCBYRLxTzPGbuZGzq1tH+abPb4wppKctWbOhGF9YXMeeRSBqN87PWeGU2o1tOEE8mQZJPa5InlYKlFKnkctPV4nm/m8ull+ZnIxZGSTKeTJAEvVaz3RsBRPOZbBRzN4iG2we1av3b37z4+OmdTz49AhzPzC9wxQpZ+96XDye+v76+kDGMw51DIJJ8qYAtK/a6UBI3it397eNtWcxoRCdMorTlSBmcHx0VyylNs0yrqBl8ctLc2WG5XGZ+MTfp7GfrxWGv48UiX6rtHp1BwaemyhgKgcjR2TkH0B33v/z0I13D2WzZj8OioxGgbW5uQWQk3mBhpp5Jm81WV1EJidYZ+Z99+SCdTe1sP7I1cWFthUCjVNacTNFzvXKpPJr4rW6nkLZLuSIPJ4VSLTe1vuqFGIJUzjjYPTs8OeQMFLM24PyLz7rFSgkpo9Nzh/0WRXRuaU4zyBdfHqazk7kCGHVHUlJIjZmVucHjB0839yq1KYIC08DZ7JwXBp43ypp24A2g7cxWK6cnJ41mK1/ikTvuD4LY8wHGJoH1tEVJ3EsSLwrPQo5kIpifTTm2bnQaXSGlZWiGY6ZThoiSve3jfCk3VdSyGbvrctcPy6WCikLbcQZu1Oq7rptcuX3laH/PDTwRRvfvPVidn6IA1qpzX/lKvt0dwZidnO7nymtPnp9eXtv/V//tP/h//a9/PXFbvfPRP/nnv/Prn/3i/oO7SAIexxcvb4T++NL60lnzvNkXf/l3P1qvrXrMn56e2X4eEPbe9RsrBYcWUkYvSlw/GXQHUsT7+3650r51a/2LT+7P1FSpXjVN07EgF3q5VhwPh+ed7sbSTCaXpXoaIprNOmkLPdtrPd85fPHmi88efKSrMGOjWzevVav9D3756+98+42vvHF7a3N3/cr6ex9/vHt8UCundcN49OjZ7Zu3eRR8eff+jVuvvfLKzZ///KcQfLVWn3FH/Warkc+lDcNq9wYPnz782ldfOvj7dySQS6vz73/wyf/hv/vf3r0L3/v5z9c2Vr73vW+/8+77x8fHtqElkcym8+9+cCf1tUsXLl16+ny71e4BJPKpXLvZnKvXXV+cNXpZDFeWZ8vV3QcPt7/yylXFVAaBuQV7+9DPj4blnFMpgP3Ng0plBjzfNXTaGSdsmNRrxcCLYqQJP1qYnd3y+vsn0ZXVzPp8bvvc3Wkn/Z9s/s4rhcXlla+mw1+9u33U7KzOF3gYwOgsQfD506PXX7yAeeR2Thdnpg6OmoFSTjobJzFUUfOsIVUyPVsvTVV63f4gHM2UraXlhWPCxqMRRj6QoJAx7UopPzV9//HO081GMW2lbLs79ErZTL1W6Q1ibzRqJ37OKL5084Xtk+Nmxx0Mw4212UF3+Jf/6Ue/83tvzi2tPd/eSTulrIUoVI7uDMaTk9PO7s7x+alxZXUxlTW7/cHjJ9vXb9yanZmxv3z8+NHe5s7R6tpUoVxxJxNlOMNhY/dwl2j4wsaVv//5r3UdzVQKzB+++ZUX/vNffvz3P33nj968tDE31WsNj7oTN2CzJW2mUvSD/micHLvdjK0WporFSiU5aba6Lg9D28KxxAmSUEIDIMQSh9LE0JmUECMBOGPc0PRxFCdsgiGmVCMYSkg1RCjVKGUY4gjoQCEFgJQCQ56zKNW0vh/5QcwlgljpxIQQChYpmThUl4SDtB1EQmIw9gKL6JoGCcWCMU4Tw3CUiDlLJABhnEglM7YFpbINGkXamHMJgEhQDDjQMEKQIFTMWiHjQghq6HYhKwSf+JECSEdiwrgAEPvcMbSMpUeMAYiVlFABoBBU0otY2sCQUIwAZ4lGEQCIQqmUkgDquh7JiEUCE2poepKEAGHXjU1dajoUQkQh04gOleQA6BRJxolGIIcQS6UQkwwIIBGCWAEgpYSYEiQBQBITE0GopIJSKUgEVIpzgpGSkJgaBJDqhlJSASC4xAgLIYGClOoAAZYkEAEEiEZJwhKEFIsiQjQMKdKA4AIgqKSIE4AxQkAiKCAEgECCECEYKyUBREwKCRAXQimpoORSAA644gACTIkUiFBNKkUQEUhJLgyNSqgUwIQQLgChlOoUISSEhEDpBEIAFEKaqQEIBQeUahohTMSQCiGk5IJgggCQIpEISskhQFLKJI4MXQdAxnFIKdZ0TCUkFAghCASmqZumDpXCQiKEIAaCx1AkSRQxxpM4looTKEqpVCZtUIyBwkEsx24YcyiQAoAKzhIRRYmQBJuOyZUkGGgYAwWAhBJikSRcCFuDRISWBnTDdIOk0/UShULfZwoIjKRMbFOWMnrGhLppMSEwJJqhKaF8N/R8FsUo4SqORRwLBRFSErAkTngCDC6YRmDKQAUnlXYyrhcNx/HQj6Bh6YaezmrZrOM42YOD1v5RG5hWqVSkAMZxNPElJykM+WyZVDPWxJs0zoaAoPpMmSV8Z/+sO/Is28zkHCVEyMKZ2draylwqa3ue12k2iRA6QpOhGwUik7EqxXSSxHfvbe0fnGGqE92YW1ixrOzEDUYTd3q+fv3aRsL9x083B26cLxayGcNKkXKtdGlppj41vXtwfufu/UazfXraP2wOzjodU0OzM9lCRt9YW6zXilixx4+PN/dDXUeFbJoLjGnaD2KCwELdvDDrvHChfGFjmiseAnMc006rP/J9P8a+60OAquXMTMGuptRMJaVTrilBIShm7aVKtt9rPtw6bEWYZnIWkmEiHj4/CEK5tmRdWShB7vlelC7kKdTiRBVSJkGawjJhcHll3use77eDEUMBAjgZ5nW5MJfvhsnRhDEBUg518plRGCgp8mlqa8aQEz4ZZm0baHoYjksFWJly3CBEGORzNsDqrDse+cn2bnN+GlICN/fOYTROZcz6TCX2PIJBtVoBxIgSYSCZzZXuPdt5/PT89ZuL19bL495JeX690R5oGgy9Ub6UPzgf/sVf/M3GyuLrr744GLtREty/93Eun2uedJtnk6++8ZZtxMdnx9u7u2++9lradg53DxbnV8NY/9HPfj1Vq6Yy9my9TrVUFMUH29u6oTd7o83tBkQ8DMbpTDbG9mefPZqZrQERJxGYmpr/2Tt3Dg7b/+qPr/72qxeZN+qO3F9+tKmb8KVXb48HrXLJmpqZ3Wucu4k+iPhuY/y1r7yYN+Rf/8071156Kzczf3Z0amSm3vl457e//UIyCE/2OpnStDDSB7snjqGz2GWK9n1IqJ1LObpm/PnPPh70m5evr3e8wI9CnoR3HuxmZ678/IPHtkWvrM9RDJ7tHNx7vnfx6sV8tf7F/ad//8OfQsfO5apC6b/+1Zdnx3svvXq9Wi4PWq2Coy9OpdYXpiGC/YkXx+FiPfPiSrWUt8b9lq5TLwjOzk91y5BcSiBZrD79/M5poyOENhy5WEobsvmKPR70ENVHA8/zw5mK0+1PNrcPd46aClGIUcHRFqvacj1DUTie9CjgQeS32p2jk8Ojg720lcqXpgeTGEqVSadHfnLUCe9tN2OFqaY9vn+30x7m0jnNJAjwainreuN8MfXNr7+1OD+NDEgpDljS7PYG/WHEgoxjXVuufPPVm7//vTfffOuF2cVpzTCQ6ejperEyo2n5Tt//4t6T85Y/v3SRA+eoObBVkLGNfF5//bUbr93asC2cTuXL1emNhbo/Hpy1er40cqX6dK0CoajWF+dqRXc0aHXdnquAnsNEXd2oX12bruVtC4OcZSuAm5PAB5mpmRWHQgnBzkHjkwfbz59try5UDBBsP3uUy9h5y0g4f+fzJ7/6YuvOw62jw50wHn/56O5ff/8DL/LfeOn2d775xq3bl5cvrVWq+dHAb4/cWJL5pUuzc3OlYu3ChUuX1ivjUbsziLsT0WgNj88OP//8o0iCsTDP2l4tX8xaOAiHhoYq2fTCTCGXNnvtTqN1nk7bpWpJNywjlVtanDEMpLia+APTVEuV3FzR0oQKYjXwRW+cnHYHvZEXclko5CvFLIx8xcN0qaDbaSHl6src0mx+0jvDQBi6iRBPwrFNwYXF2TCI7j1+krL1mxdXX7ky/8LFufFovH3e+dUXjz++/zQCdHlhPo9xuWJ5kHzyoDEcu3/0jddfu3lhZX3uhz/+5W9991uD3nCqVpPUevB8rzfyi4Xs6y9eo7ra3+vtnHd/8tPPDo87sxfyjw7dH/36Ua/nXtxYvHlpPux3oQina4t62vr5p8+qaaNSyTa6AwHI6mK5ZCEW+6aV5Yz7HPcGY51iSNHm4Wl3HBhW7uWXL+9ub3rByMmlDcuoVqufffTJa6/c2ri08fnnd4uFbL9zcry9+3vf/vbO1h5nWjVrZbKF//p379WnN5rt0Ye/fueV29duXr/86/c/2tk8XF1bXV2aIYQXKlUnkyPE1BS5tjztdZpvvPzKWSt69PTgd7/7Wy+8uHb/8dMHT3Zv3X6x22mZFk5QHDGYyzrf/+W9fKl2YW0hibzxJDjvjpCVhsKfqjnlEj7veW4kXrh6IWcKHveVbnHWvX71kmlBGItiCi9P0X733HDypRQwdVmtlrsDSSC0MBMybnuK9c83Lq5zA2+etEsGvDJbLKeNowj85cf97//8Uc6237g9CyRoTRJqFetFe6li6ArsH59WZxfKtVIlq61MZ0xKLYoUUBBJO5WeuPzp9pE7Hgm3GwOjN+wOep2Mk0ulimlLLxcsKcSzg5OHW7sbyzM3V7M8DgYBi7XUcau32ZTlUubKfDpF4YOt1rO9g9WZ2VurNduAJ2ejYrU08dm/+w/vnPZVuTTvDw81LLhUvkwqtdziYn6mVmj3wl/f3+y0/alKzfWCn/z0F4KDr33l9ddeuRi6vcPdg4PjjoR6JWe/fOvS0tLy40cHRCPf/MpLT5/t7B00zWz+SjX13VfLjb7/w61gPhOvzeY5B4NQnQ6SOHBXprMZC0RQNgO1eTYYjcaLhdRC0dBMECQwTJIoYGEkhMJMAdPE9Xw2o2EDwoTDwSiQCGXslE61hMNJAmKeQCE1HUAYUQSlVCGLg5AlCYCIskQiBLMWLBgobVKNKiYR40wCiQ1DCAQBoAQZmk4RFAJGsXCjCEDCucKEIomTZCIFt2xLIxQqxGOWMMa4ivxA1wylVCJAokjMJGdqFEQKKMGlSYlSahS4jCWmrhWzpmNRTLCtEyX5MGTtYRBz4CCkhDQosSxNchEp7HphbxJxKaVSUgqMiWmZOtQxxoKz0WiMocJQQSHDICJEk0IpoOKEeyFTCmm6gYA0TV0pJRhPuOBMQAQBwBD8xrclhGAkCcUIYySEFEL9ZhSUKSkA40pwEWFEEFBcCfmbqx+BOBZxwoSQccwgAEgpgpHk3A8CRAhSiOiaglJIrgAm2EiUiIQQUgKCgFJIo+I3K0AYQ845QhgokAgFFQaEci4wxhhDjDFjiWACCsWERIRAgOKYIQhlHCslY55ACTFFiVAYQiV4GMQKCZYwzlkcBRAhLpRgCcaICxkFMYIKUaCkkBBgiLAiCGEAgFIAAAkRFongggsukUk0AzMOgESGZROCecSJgTBEBCJENapRJThCcOyHjHEMEcQQYpzOpABUGJJSPlXI2ZQIA0GAsJLQDcOYcapRnVAMFdG0iCk3CAWPMBB527QtC1DkhUksVCzExAuAAATpukYM04pi3h37IVejievFkmLL0mg1n5ouF3SdKAjDOMIIMCmExFJKTCEHwI0iP4zCMJEAUkqEEFBRoSDACCFICcEIcR4PB33XDyAmGkEaETqMlmfKa0v10WQ4nriYmo6VDfyg3W4kUahkKKLhXL08Nz2TsnHoRe1et5QvBxE7aRwZlu1YlmHQdDqdsrWpUj6fz7YbvUF/4no+gDBOEkxpGLiQgEqlknDZOm9plNy6sV4rFTAmjUZnc2dbI3Bpvl5MZ0aj3mQS1CpT07NTpmVZpmkYVj5XGnjh3buft5tNJ5Xn0mAJp4YzM7OydGFN1zXbwlTF3W776dP9jjuu1MpzCzPd/qDVG3UGXUsja2tTl9fq8wvFjGOenTbuPT16vtMauSEXCiiAqTU3k7+wNl2wYcaC09UiwijmKBEoiIPJaLJ1cHbS9l1lKATD8RCM+qHAaVO7OJ/N2njsjQf9gUGRo0HGJhqhcchDb0yALKZ0Sq2HmwOTBbm0Zpg6UrKYsQgGh40BgNiyQSWTjyPRG/FMitaLhfHgnACFdJjOZOIoYVGScWAwcFutdtpOZW3j9LxFTPu42U5r6Mb6gucGWrqwNJ2brVSlorv7RxbFseedHh86hja/NHew8+zs+PT2C2u//Xu/8+mnn/Q6vdWZOQ1jyWW32Xv2fGd/f+/q1WtXr189ONwdtY8HrfZrr361Wp3fPjj++jdfvX7rwvR01fdZOpNTAL3/4Ud3Hz3OpNPnp4f/8B/89tUbN0u5fKKkoeNHzzexpq2tr21vH1y9tpLPp9YvLr/y2ss//vGPiqXKb3/rW+1Ov3V2eu/JrhdM/vv/07+9cXn917/6xd0v7+7tN3rD0e986w0LSsPSpxcv/+3ffH8y8GbqRc8dzlUzU8XCo4dfzs+mAgZ+/LNfzlUzP3nn83LesBT78MP38jlr7Kv3P/6slKeLc8XbN6+enreSmGEKKFZ7h8fl6lIml1UqHDcbhUx+c/cgV5k+OTxx4+iN11/s9wZRGAHGCzadKhbe/eWvO832zbe/C5F+dr7XbZ9dvrzwp//mT3v9Nla8VqmkTKOctWUyfvLo2XAwnpmb88ej0+aZ22/PzdRElBiWdvOFF+M45CIJx36jfV6rTM/OLw78SEvls8UyUpB7bi6T6Xc74cS9tr4wVS1PVWYyTkYnsF7JrC/UqhVzqlqdmp0ulyrZVKZaq9mmwyVgIaJaOlH40dNnewctO19jipwcnseeqySvVmr9Tv/oqJMpzJtOzjH0crVwfNIIgmgyiff2dp9t7pydNs2Us7Zx0XbsVMqZqZdL5fR0uSyFf7D3BAhpmOlcpthpt3rtE5aw07PzUnW2Up8/Pz/a3nsahi5CummDmam8YiJwR6478cf9vZ0dwCNN03zPMzSr2Ww9fr55dtaIwzBwXcGTqVp15E76Y3c8ctvNNsFoPB7u7p+kcjmqEx55UcCPz053d/dsw8zlK+lCqVIqF8q1WBJPAED1SMih5478KAhUwGCxPGNb+cnQKxfzVrGCtPSTza1njx76k0m/cZrOWPML1elKud04Pz3cOzze+y/f//mHH7xrp5wkip883U2lUrpl64a1sLAwVSkjKXuD/siLs/mijIVGYCFra0TNzUxduboBiT72wsnIj0IPcBaEvmHQUs7JZVKFlJ1C8VzZnK3npmr5fD7LBGRCE1QfToLz8+547KUydjadgjy20vb9B892dw4L+eL6+kpvNPn08zvN3jCVrSSRp6HYsujYi46bvSQMkIxnasX/5p/+4YWlqVwapUzz4YMn7WGvNDN7vr87ngTvf771xd1NlsTjxu433rzuDbyj40Y6k97a2b5x+SJPZHcS3H/0UMPwD7/3VUdXhXI54eL+owd5M3P75esAWT947/MAkMWZqdkqbbXaU2uLV69em0qD1tFe3rEPzwfng0Ag45WbKxtlOldCgsXDoY8NSyiQzRT2zwYnB4d7O8+DYecrb9yyHXvQH39459H0wsLy6vzPf/bjCxfXTEqiIFpbX3+++Xh/57mmOa3ekJL4H//xbzs2/Pf/+a9e/+a3hxP/z/78P77wwsYf/6M/Pj493ts+QIiWi1V31J+qlj/66M6XT7cW1ja8ydBKp77xtdd+9MO/D4LgxZdv/9ZvfeX87Kxx1sxmUwjQ6amZ4agnFZTE+vCzu/l8Zna+Zhrm5k6TYCwY35jLZCgaReDpXlO3zRTijZOWoIXj0+ZwNMrYWIjY88YpC9tpJ/YCapDReFzMZkIXnLb60spSJ21R+fjJmPvD2xfW3SHYOuw5Nr12Mb+UQYluPm+qX77/2DLxYt3kQXjSau/3xmY6d/3FSyJhz5/siiQiSC0uLCzNlT1/bBBt7MeRlKlCwUrnJNIlyUSjsUoSPxifNpp+KNxIRAovLi1XCuX7XzaePXyQcrSVxdrcVKGScYqF3OCkufV0L4F6eXpxZrHWHQ6fPXukafra2ixG0bjfunnrsmGTn/7ycy5RLlc8Oz3NZjKKxyfHeynTWFmavnxhwXPF/ce7e4fNtdVrmUz2Rz/44c7m09sv3HzzzRfz2ZwUbP/g9OmzJ67be/H2Dd3Av/j5O7Va+frVi3eeHT96vNcfDN56+dqLl6Zb+0dSqSsXFmcLlCI28KKTju97Qb2UzqUtjWqTWDT64yBKTI04BtYp0DQKCYmiZOyFCVMsFkE4hiwBQFLNVADFYUSwNDUMkFRcBaGImcSQcA4SqQAFNqVICD8Mw1AkAoYJBwLlMk7eMvK2YZk48P2J53sxi4FMuJAAJEECFVSSMwC8RIzCpDv2R27Ud4MwVhGTLEwkkLZtQoACL2ZQYo1iglK2pRMMiOQJY0zFPJFAWY6VNi1Nw4KLsecnUaJTlDJIxqL5jJbRdZ2ScRx23VDphmGZSQIowLatC878SAz9qDP0B16osDmeJIwpoiOZJAgihChSiBBi6gRjASTHQClIIsHHrh/HTHERJUnEGKEaoYQQDUgpFeeCAykxxVIILjhAUgEUsphCQjDkjCMEgRBSQgIxJhQBBQDACgIFlASMc0qhoemYEqzpEABEEGecEKLpmhBMQMU5E1wSiIESWEdQSkwwBBhy+Rt2RjMgQApxpQRUCWMcqCSJpFIIYiGg4koKLiRSCGJCFUIQAsg5VxgiwCWXACKNUE0XUvBEcsYEgBgjQoAGKUZEKaywIhrGEHIJsIKmrmFMCcCAg5glmqZphFKgAYEgwgoKqQCQ0LANiBDRCGBKCk0JoaCSUgophVJSAqrruqFRDBLGGAdCCgA41QmTaDwJgzASClIIMJUYg5Sh6zoOE5FwGSRxFDOqa0AxJUXIYz8RPGFpQyvYxlQh7aQMxnjgxVyoJGaBHwNCJQJhzMduNJyw81bvuNP3gmQccEgx1oBOhONoEEmAIIOK6kbM5XDiccElwGEo4ohRBAmlKceyDAwFUApgnTKZsIQZBoIYKUSiRAWRIJSWi5liVstZcGV5eqpSaTf6x8ftMJESosFgPPQ8gLBpakWbvHR9aXWxFvmTgIHRoDddr9vp3PbzHR6DbDpdKNj5TNr1oihiXKKD7dPe0GVxTClIWbaZSfeG/Xa/VS4U4kTt7B7lc5lbt9aXlite6B03Ort7h7VqdXVlAeugNxxijVi2OQ4mzdMGJcgLguZ5//iodXJ07hjOzHRNcTUajBAmxXxBSbS9ddjrdwjBUexJJuYWZ65dWb2wUh4Oo9FoXEwb89PFYt4yYaJkLDg8b4+29vp9F0pELNs2bF2wmFBVL6dY2PM8HxPz+Gy8e9I7OB+d9ULPTaJEdV0xCKDr+kjxehFfWc0Wi7l8TmeBG8SASRzGwCQy42DLsiEG4yBIpSwNyFzeOel0R0JbmNIsykHIHN2wbWMwCFxXpJxUBotCWpccxQwUS5nQHyAWSQTHrqAmFQpADIIISwRBnBADuj5DECtFVRxfWJ+O3d5kNJRKR0BirA6OjmOJzUyhPxy0O4NStbS0cuH09KSY1t549ebezuGDh43Z+eWjve2js3OEaQyBqelXL6xtXFhudtqci1TKunzzynAE3vvi6cLsrOOQ06NdLuHR6a5pWEzCZ893DAJm5qcuXVzeuLh+78EjNwzTdlYidHRwsHHx4oOHTzSkz04XddO4cOX6O++8y6Lgn/6TP/7Bj3+0u3tMzHS1ZP7zf/Ynh9s7/8P/+6/2zl3s1BVWs2VzddYZdk8W1m883ek+fta6dW1ptpZJvMm1y5ePmmemnanWp3/17q/z2QyTeP+4NT9X4ip8+dZlpRkP7j+cLxmvXFmGPEAIHx8dV8qZtEERkBCTnePm6Wkz5WTq1WIln5mqVsvFtOtPXrl2++j0JIyZZpgYgTdee/lo70klq3/7W197+HDzJz//fLqa+df/5Lv/4Pe+8fnnHx8dnS7OVBZmizpVo+FoZ3s/ipLFuanJqN8ZBxxgSPDGhVXN0gzd/vCDd9udBkYwYhGFhqmnz5td32X9XrC5uye40iwNCh64/Qvr1cXZqkGhY2GdivmSU0qpnKky6XSn29l8vtnq9Af93oPHzw8OjznjmXy+PXC3Dxq94aQ2VQ857PUHr7xy/cbFhUurU3QMI+oAAQAASURBVGHCZxaWFxbnx7E6OTu37XT7vBUmKuLGSaP3dOekPw4kQFwgBKBjaUEwzKTs2elapMBZozvxknGQRKF69Ow5RuDtt1756lduO6lUZzCam678i3/8u2+/cs1CEiRQsBAoen/78G9+dO/53nm6UCkV0wii02YXEq1SKdaqpcX5WYCwpluaqUdxIpTKOGYpn7p6cXVhfv6sNXATUl++cXzW3nzyPOPgG5cWK7lcu9vDpm3aZuSOHJMmPP6vf/+rpwfDfgAbje4oiLCQszOF6WrdZeq4PZxbXP7W6y+tLC9pmpnLlwUxT46bmq6nEKIgyefMi2vlTM6gGr59eXptZUknMFfIZkqp0XDEmEilC6XKlG0atUqmUiq3h+PecGKnUlEU90bjg9Pe4dEZFCKVyznpvJnJhgmIhBqP/ZhxQpBtUih8quEkCmyqyhlSyWcNQ+uNJvuHvUYn6fjy8KS7s3vIOZOcDfvtfKG8td/95ftfIERnp4q5jD0eB4MgmniRkjydsgxqGE7huD0eBfzZ5v642/vjP/zOKxszV1cKKRN89umDdGWxPl2jAOYLmSe750+O+hJr9z5578VrMz/7xceXX3g5kyuenG7VK/lCvsCVdXTSzBn40uo05f7GxQXJeS6X3nz6oFbLJ1K7f/eJQ8BsKTMcJUdnvZSpXZlPr67OFlNapeB8/GXjs0f7uXJ9uWa8cXV+fTbPw+j4qBsxNDdbXyybAQtXrtySUulUFnIGtvUHj87+4i9+9I3fentlsfzk3uc+Y0eNhojh4sICNSg1UCLRMOA7T+//X/7P/10pg/79//Sfv/K17yCi//iHv4Ayqs/MvfP+Fz/6yccff/ZAKnnp4srLN2b+5//1V8fNcapY+8u/+ouvfuOtubmZn/3s/SSQ+az54kvXRqMBxvrJ2UkcRkvzc81Gb6o6fXh+Nhh65Vw67WhEg2fn/TAMC4a8dGHFT8DWUac97Gdy5vGxb+emNc2YjCcQIoqw63sxQ5m0EzBPd7KjEauWnHIOHHekqwzbceZKBOaM/e22xvu3rpSaPrj79LjqWN+4XsnrCTKtwzHcPO7Xi5nlKcsNkr2m/OLByeF5Z352LpNNHze9e8+O7t5/nC+WZmsFiDjW9Ea7D6F0TN0PJvPLG9dWqinKqiWLM7570poE6vxsfHJ89vKNxX/0nXlCScAUIrJoAwfyufna775dH0/AT+40D1pDAkDBNLpDtn3QKuWKF9fqBAqWxLdvXNB48uThEyu3DJR2cnBUyOUBoc2hWyzmN1bKNy9WNVPbO2oen5y8cOPm9RvXfvqLT3/9waflcnV+rlSfqmXTjuRq2Bsnkf/2W68nUfSTn/xwbWluulaYeNG9zaPHz49vXrtwdaWw0+gmjF1cmS5lNR2hmIGeF8dCVrNW3gIIQDcBjXGUAJQx9aJl5Gw9ayDHMCzHFkq5UZwwHkklFIBAOimLYKhY4ph6ztIMTcQAuQnrjt1EUS+AAKCsbWVzjhLSjfnQT8JYRIwnHGCMTR1nNVjMWrZtxyGLYyEA0YgBidKw0ggSXCZchIlKOPQSEUoFIU2YCBhDABgUYQAUBJyziHMhIoqhRiCQUhCccBZGchQkccJNi+oQ2IYBIA654EIRDHQoahm7kKIWRTpC/YHb6EaB7yEolZJQSAPTtG0hDCOuOkOv60WBVK7rCSEdxzINYhAKAMIIQALSTgpBAADUoCBSGoQwKcNERkIKiTQNJ4xJGSsIeMKlVEBBKICSiFAdASKlRApzILlQCgilBMEEKyClIJAKAKEC1NAphYgohIAEIOGKJ4IoQAhEEEoolJQIQAChElwJwQXnQkop4zAhhEL5Gy9AUaRJJeNYAK6QAhAAxBiDmJimyTmLklgKjjCSACjIFVdSSakkAFACLJlQAFKCIEQ8EQgqCKBQStc1CJSUkGh6xBKAIMIYY11KACAwDJrwBEAEAJBKKSAxpnHAXNcPYl9CiTFSSgGpIJQsTICEQEgFMcCSK8UVgBjoRKM6BkolTIhEKiGARFxIBiTGmhAqiIQAGBsUMqZhhCHQMRYADEdBGMSDsdubeBABg2o6xQiCJGAsTlIpPWUbxXxOITkYDIIgVFJQCBPBgoRxJnmcxDymms4ACBIJubQylq2htEkNk1CKkSTjiUsJtgCKEqEAyNspR08hAJVKCEQYQZsSDJUQKogCIGEYRSyOHJ1oCDMZM65CznWd5jK2gZN82q5NVdLZwpPN40/vbw1czhT2XS9yR5Ztrqys1qdKi0t1guTJyZ4guNFqr1/aKBWK9x88ZAAUirl8xixmCq1293j/WDARJgGTMp1KZXO5fD6r6TgM436nuzA7XypXnz95xhmfXpjOpbVBz22c99r9/sz8zNzsFEs8qaSuG1hqw+GYEq1ULItEcAm4ZN3xsFQszK+uJYk4OTzww2G2kE8C/+nDR7oBL12+nksXbLOUSqcNgyTB6PRgb+JF+Uw+n3M0wm2dpNOmVOqsMTrpxL2QcCDSjm4QhaFKpW3b1JLJBEuYK1Tb4/iwE4wjnSOUy9mVYqZYqWmmqVE5lTNXZ3IXlqdM3eq3TxLXNTRk6TiRwjZJPm1QDJIoHo4YpgQRDUgQhKLZ6usY1ReXEsmVkEbWGUWg2YsNi2YdZCKlW2aj340ZKOWyDoFMmU9OpY1AMW9wlmAtM/LUeBxhSgGig2HkjhhIomI+w5k8b7o+E2E85hyeNluCx/Nzc1EYR1E0v1Ceqk91B8HBXiNtU2/U+/sf/OTitUt6Kn94elIspC7fuIYwJhSmHbPfb0eBDyFMZ0rv/uKL//7/+meNxn46Z/HIt6zU44dbXMZZJ0UxqZQr3/nuty1DKxSy7//6vSAYzc/OGKb92Sefvf31rwgInUwml88rFc7PL2w92Rr1Rr//u7/3P/2H/9EPkt/9gz88OTm6tLHx2ae//rsf/HRxfnHt4k0lY5Cw3/veW7oOpqZqE9f76M5DSMjV9Uvd5okUbLpsglgCjQaMKC5XVy7vnLTWF5yLS0tX1q/0R2Os1Js3N/70n/8Rksl4NNrZ3llanJurTaUdi0M0cOMvHu4tT+d8d2JlipjS8aCtYcWZGPqDydgrp53j3e319VXBwtCLX33ttV++/9He9uE//aO3f/93voPE5MHddztnZzevX8nkskrElk7TuZqZLrx8+8qF1bluv1WfmU47GRYljx4+Pj7Y29vZXlpcfuOtt0ejcas5bHbCR1vbDx9v9QejyaA9N1XK54w4jIUINzYW69XC1vMno1G332/7w/ZcPX37+oal6xnHzGQyM9PTlml5gZyeX37h9kvDyfis2dF0ncVhfXYBUHP34KzZm6gkSTmaTvT9ve1HTx43uq1u44AQYNjGcBK0B8Mo4hsXL125eKlcLEdSPt/c3d7ay2VLdirVOG3c+eLh1rOnkyCp1leFwFCJpdmpdDrd7U22Nvfa7ebO/v4vP/h4b/dZEPrVas1OmWHkQ6ytr964cvNmIjV3HGm6CTA+Oj7N5/J7Byfd3rBUKGGootAPQzkajn0/CRIxGQftTrN53qCaFTDw+b3PeyP/jTdeeeO11w0KReTnS+W2KzrNcy8IThvntmnPzS+YZlYCet7q1UpVw0AscAeDs/3dvdjztp49/au//cmjzz99cPfR2cm5Ru1CbXp6Zrk+v2BbKakUIlYqO3X1yguLiys6Ut3WeSZnv/ryW2N3sn/WfL5z8PFn9+8/eByGwXR9Sgk2Go05j4AEmJopOwepMRoNd54+O9jb7fX7URTruk3sdMwgoWbWtBw7ZWZSpmMbKLZ4GA5OIXPLpfzC6uLIj8ajxMrkiZZqNbulcjnnaI5laKbNgXF8ciYi/8qFlZnpmjfxEiVb7U69UgTMbzYb45DtNsYnbf/J3tEP/vpvy471h99+49Vbi34C79z5wnbSjm1UauV8be6LB7up4my2WF5fXlmbz3784aevv/5quZCNw6FBZBQnodL393d57BXzeuh6g1E4N7dw48K6O2h/7a1XGZe+7ymkpbPGvbtPDJ2W0qYfBJatvvOt11+6Wvv4492f//wTmqmUyqWNpfLSfCZhky/v7e/vbq0sT5tA5Qu5dH6uedZ0NDRdrVdqNd9lH773y1deuDw7P1uplQiixwe7gskLly+nsuk7n3zkVGZjht/96Y/+7b/5582T/s9++avv/PYf3HrtzR/+6IfXb126ceOF6kx9MAy6vcnZ2dmf/KPf/963rv3FX/9odeM6S8D3//w//O73vtUdTbYOmwgBnYi52XqrNyqVp8/OWrdvXbuwkj/c2yk5mX63JVhMRPz2m68Qy+z23OFgtDBbfeOFhX6fPdtpF0rL6Sw92X9ODMNJO0gJTdNMM+VHAcRExSwGRq/Pht3zSs3qD0GnH05GbjalT83XFQF7O61irXj92prvg4O9w3xGX85r0A9OR/zeQXDccRfm595+ccXSoML6yVH3p+9tK2KWqrPl0oylOzvbW7ZpQSznpqumrk/6Q4vCw532xx9/hGyrVl9cnF168YULWRN3e0OkG643ePb8YblYXllddMOkMxgNJkOF1ZN7zykBX//miwlU+6cdwbhpp6xUbvds/OjBI6qUZpiu7xKcXFsvDofeJ3ef2vmZs273/KSxMn+RaPpZ87iU0+en7Ll6EUL14PH2u+++VyxWrl2//OjJzuHemRCx5w+djE2prVu5mAMeey++eEPXUt1ed6ZSzBfTuULtuNXf2trKZ0GhUD8+PgqC4VQplTKJphuYakkseOinNehYJOYqYHLsMsGlTkTG1i2qKGRQxkABLoBh2CnbBIIJyXRKMAGMy0TIlG2ULFrJOFhAN2CDgEVSeJNw6I40Q0vZJoQKQBVxMfYCLwgSlgDFMzZNm7ZGkEYpxihJYsYSChQm2ND0XDpFMU4SxqXiCsaJFDzRDYtDJCRQUCkkFFA61AEACmCMIYUAKkmRRkyTUN33Rac7mozHSAO2YUBIXM8HimRSmVo5C2XiOFrWJLapJ5yf9EbtgccU5JIDCG1D1wnMGBrFGGp6f+SPvHASsN+QMwgq28QASKgQVJxCaFtmyjaclG3bVsq2LINCqCxd40xGQaJrBiWUIGqaJlBSSRELpulYMAagEBwSghGGGCkEiBISIEA1TSogEYBACMABkIhghDDCEEEIkYAIAoLiRAilIERUo5AgDJBpWLpJdUI1TceaDqASSiKEFUSEQoCllBIhACBASEGMMcEaZ0mSCKYA5wxRwrnkAiFJAYSciyThHCqgYQUEglApLH/z9VcBgjAmGENAdR0AwTlHCkEMecKkEjxJuIBKQiGlkhJiwJRinBNEFVJU13SNahRzLiBECAMAlQBKCMWlVEBCJTFFGqEIUgYVCxiACCuZJDGEEGIlkQQCAgCAgkoxyzAo0hPBlFKObUkAJhM3jAVXmAuiAIEQSsA4k5apF3OpYsZKGaaloUSwbnfEGaKE/gbLoQhrBAEkAYCObRiaFoUBAiidTmc0Ui9k6oWMLhnFUCFBEJaMMaAo4FBwamAlwijxs+m0oWtKiYQHkECMACWUQwAEq+YKOcdhTCUh4yJxDJJybMfAlqEhpYIIfn5n++7T00FIwoT2hz6Pk6mZ4kylIEWYxKLvBc1WT9NSkktTt03D3N49Gg8GywvzxUJGd6zT8/OzZieXz+h2OvSZEIxSRQiIY3B22gp8d2Z6vlCofXH3Xqc/Wltbq1VKUoEHzztHZy2HouX5uoRyMpmMhhOE4GA4hlDTNJ1x0ekNIj8CEkyVc9Vq8fzk/LTR1k1zZmrKMWkYu1cuTb9y/YJFVW/knnZHzWZvNBrFvkgiCZEMk2ji+VwIoWI38Pv9oDecjCYRJmyukpkpZTQKeAKlVDwZaSYBmvV89+TZznDkc28wSUbcBH7KxnE8ATxcn6vVc1ZKpyMXPD3ocalSKb1cqto6nCqmHBOmDR1J6HpuKmMauuEFQSTRcWOUNY2lqvnuk95xG3AAuMJbJ50RUzrGWAXFsjUeB+1+VMhhB0Ok4Yf7wf2usVAGWUsTXPgxC2MuAbBTeiTos4OmpLpj6Zm0tn9+HkjSGwfVSrkzTqRE5VwGAdzrTqhma1QTXD2493jo8kyxcn5+YqXRi7dv7GztDibR5Str3Pei0KOaBiFKgsg2SOjLk1bwn//rZxtr5r/9l/+4WsrMLy36TDx++NjQ8NXrV0deDKWUip2ftXqdyZPne5m0UypXt7afO5azvrze63d5GDa7ndn63OH+3qOHj19//dW//eu/Xl1e/p0/+L29vSdZi0jOnj/ZW9i4euXWla3HdxBi5VrhdH/LHcblQuX502eCh7WZqivwo8ebV9ZruRTeWF/W09nPvnyeLle6vQ5X4MbGrE7RJ18+Ohu4hYI+M5O2TRABTg0nCEQUh5Kxdn/UnsTNgTc1XVmfqceJP7+8dvfBMz9kE1882+/tn3YtDYuwvzhTYlH8wSd3DKf4q08ff7E5/Kd/cP3a+uJ/+o//pdvtz89Ov/LS1TCYHJ80J+7QttONdrM/ngwmw+3NXQeh5ZkySzwuYoJRuVSqFPPZXP7zDz/pd/2Ry8+6o7NmNDW9aBjilVsL1y8upR04N5Wdn542MT09abpeWK9O1auZt16+MV0vPnv+bGtnG2EyO1MvZlJBMCrUqik7++j5drM/KpTLF9fXisV8v9dvtQdcCCede7Rz8ONf3Xu+fagQ5kliWenXXlp79fa1scf32z3OQaGUjfzB3sFuHPoa1ABEvbF/dNoKR5FmaPXpqXKtnssWhxN/a/PZeHCeSZtM4d2T1tBl2XzxxsX1erW+edB//PTg4ePHgAsNgcbpoTscYw1durweRF63PWAclYv5VKbYHQUJME4bbU2nmXxWwySMJgBCRZzd0+6nd55lSpVaOR+POzc2pv/lH38PA/WzX7z7dHMfEtzrDCMGTSd9+cr1qZnp0XhkElIvZ1aX5x3bggCuri4RokEFr1+arU9n9ZQ5vbrx7a+/+N1v3FpZrmcK9u7+0Xuf3//RLz9/vH++ddR77/Pddz++9/c/e//h43vpXKFQKg47rae7m0ECM9lyaxQOvNgpTvX6UbfbLhWyaZumLCJlPHEniGDHTuXzxaWF6aWlGQKp7/vnzU5nOG73/eNmf+jFnc4oHAfVQm5teXp9qXZpebZacE5O20qparnc7A1c1y+UqwDbvV7/wvrq8vLUbL0wVc7Pzky7oceC8Y3L66YmCum0Y9gsDl++fY0ql/OYGLnmMDxoDk967Ps//whB/C/+0e9dXnBUlGzu7Jnp7NbWoQQqm6189sV9Tc+eN5tf/fprFox/+Dfft9PVKOJpyzEJ6Az6MSc7e52Tw1YqV+8N408/+/zyhcWZqdp40LiwPL9/cN4desVyyTLIF3cfBNLY2jsuluuHj++8cGlxdSn1yzvdv/94tzkIrl67/PTByaWLa5cv1vZ2dpaWZ8pT2bOTUzcM/FDGInrphZu+O3jjrZeX5xaPDw9tx5ybqS0uTH3tG1+Lkujk8GBhdnZ1dfqnP353aXWjPD33dz/46b/+l7+b+ONPP//s4uXVa1eu/eBvf/TCtdWNpWmI5MRnHJJ33vnlq69enimlPnj/nf/9/+5Ps4XS2fH+hQsXDw72gjCqlkoaxYRQJmgi8eeffvrKjRv1UiGd0gCUEzc+a49bnW61WOyMo5N2dHK0dftS9dKi3miFDw9ay4tzBht4Y9+2M4RiIbgECCBt5Hr5fDabApSI0+O249izZaADESfxk10vT7xUiroKnBycF83k+qVMHImRD67Ppt66ljag2h3yXzyNfvzhE6Xw29emYRTXalXdxh980fY9VycIa9hOObqugygS0WChVvQC5Ufi2tUZv6v+h784+Muf3nv0dG9mevb27Ys6kZJHGtUb7Xj/pFUrpdeWaoTAZp8BYjqO9t6XDTuD/vCtdSrA7vGwG9BUvrAyV+KJ2NlvuH7S7Y6CSZCrFC9fWeh3mztb+1RLP9s53jo8rJWnu53xabM9u7hUrmY3NhY21mfjmH30wYfTpdKVjdXDg0PXT5LAVzIauJOPv3jsu8zJpjBi2Vy+0+vrOpCJiyDXiOZ5ycF+m3O+OL849oJuZ6AbOkFYQzKftQ2d6BgXbZK1oKUBjGWUyIhJwRklmmUYhq4RDQPFBU8I1QCCWHEpuAKEK4gBpBjYOiybqFxyEIa+5wsuvYhNQhXH3DI0R8c6EhTBRKqYKaVgkog4VohIA3GLApMipJhSzNAJRgAjoSGRc6ihE4gx51IwwTlPOOeMh3HEGbcMCwrFoSCQSCWU4qauUQhYHLt+wBRy0hYmWhQLAqFSDCMpARn7QXc8iBKhAKJQGQYxKLIdGwDRncQ9n8UCMa6YYI5B0raW0pEOoa7jKOGhkO2BP44Tgi2EEUBQAIYRllIwxhKWICmlElRDUgrNgETTIx55YaSAEgoxFjOpEKYIIQopSwQEEAAAiIyiKApjBRClVALMGAOQKKQU51IpIFESJkIooCSUWEghpYQASKAUhBgiqlMGBI9CICATLAwiLiTjTAGFMIJKCc6BkkoCJYGQUnIoAUCJ5FJJiJAEkCuOCYLoN5GLE4wEElwCCSClmkoSkUipJMZEKiWkRBgqpbiUiKAwCZMoiXmigERYiThRSEGFDMNEGEsEEaRSKKkkpZhqGlec/f+tas4EBzJmnEkIAYJQ6b+xhqUEAImYIQAhQlgxhCGEQqeabVlYMwBQECIlhUg4RohghJD4TVtgObYQisVSAI0pPHJDL/KllDCJKIC2TSydUk1ZGJk6CEJvNPFZjDChXEmkAKVIQQWhkkzqGqYIxkmEFDA0pBOleGgYCIqEagRKgBWvVHK2bdmWRjSqGxoAUFBZyKZZzBIeY4CpZkumuBAIIQSBoRmAkOFkrIBK2Zal6zqmBoVKyiiWIYf7B+f7p22kGZjoQThBKllcminn8t5k0jw9mUxGSSAMK5svVW3bVgDsb542zs/n52YyaUNKHngBT1TeKRga7Q97nMfFQt4xzUFvcHrS6HU9AlG5XH76/Hl/MHjhpeuVSqbf7T948OysMaxXp27cvF4spUUSx4nK5YuKoPF4FIVRp9nptDsYEQUUpTCTts86rVa3m8oVqvWpcjEXxEG5VMwW8t1u9/GzZ6dHx4EXOtk8ItooCu1sTUEIELIMi1LNNtIAGhxQLqmmk3LOLtim5/UGQ1cAaZh0qjzl+nIwGANIL12ae+Hq8tVL9bdeXpieqQih/NGkYGsiHERBMPG9naPzocco0THNNLv94XgkQldXqlDLdfs9pWCtlOeCRxEbB0LX8PLyXDgaPdzqxxIgDGMmuQCGRkzKq+XSZMJOO10h1YW1ajGfbrX6xyMuCF1fSCe+P/BYbxLYDs5kHIyMVrNvW9bsXMVxzNAN4lBpqQJTMG1absKzto4x8N2hrtPBYBj7ge8N+/3B7Ooq1YpH++dzU+XxoPt0a+fi1etISY2oSrEoEtBqdsfeJOEcAPHg4W6+SP/t/+YfUiIoUoe7rWdPHm9c2njrtRfNlH2w/ZzotNPpdfr9Lx4+WV1eX1lfNx3rg19/snH5aqvVah4dPNp8uri8bKSLdz6/k045Tx49mqqXXnv77VG/f/ezT19/63Vd00c+iCK2v/3UGw9+/7vf0a2UVEBA+vm97VarV8pYpqENgyCW6sLGeiFb3t/fv/vpw+EkqVSrg8m4kM8aKLn/6OnBYTObzss4jNzB0yebJ+cDRU0FlaUb3dGkNQj6Qzf0o7lKcWZhZtKf/OKn3y8WCldeeOPuw12X89WN1Zs3rtm5wszc0t7hcXVmPlBgMHK/+totqtjf/M1/nZ6rLa5dnHhB6Lss9CdRfNbqdgejbCqVsvQgiOIkmJurYxBeuTB3+cJqtVJeWlhOZVPNs6Mg4I1OfzwMELZzubSMJ0v14pWNJcI8kcQs8pBiQZycNseZUhFBkEo5E28SxSCXr1+58aLns3ZveHRy4gWRROrBk829o7ZhpQ2NnjVPkyQJoyiXzUyVi1HoVurz125evXrz8uWlhep0VbczpYwZx9HEcyk2sEJSBYVCbmNlNpWyNU0v1abrs4tCEjeOEKBCMF3FiKpEsLUr16/evFUsF8bjyePNg7N2lws8nLihOw4SUZ3fyFVnueRXL67duHUt9Dp7Tx+ZOLQ1VSnbUCl/2ImigOr2ZOIPhiOIqE71waiHhLqwvsBEpDSjvLAaSVrK56cLNht1es2j7Z3dCOD5pdVcoWAYRImkedr44v6j7iAYTeJe41hE417rfDIat7vng6E7jhiidqUync/kQi9pNoNWZ7R/cIgQ5kmSyuRyxapZnlJEH7k8U6y8/vJLr7/+cm169ujkCANtZX4x9KXrhQPXKxandMvujdxAEQaoZVsvvnh9bnY67VilTHY8Hj/f2TntjdOZlKFZmk6nZhcAJlwRpZtjP+SQZvO5JArGw344CThLIIVzs7PlYvrZo+eIgErR6bZa7rDjuf7Tp7uPnm9pkKugG4UDTCkE6Oj4NPDG85W0joWTdprNlmDem69eR9w3CK+Vq51RWJpenFlY/L//P//dBx+++9ar1958eVmTauInxModHB5NTWUI1B5tHrhh3Do9nZ8p5YuF7sjV7HTGsW9dvz4ed0vlIjLw+3fOAkUW11bOW52PPv6MJfGo3TANzUilnXQ6ngwvrM6eN0aHfdVohwJoqytLn7//4eJs/fW3LjUH/N//2fcTZL7+ytJHH3zw1tffGgz92enlSq4QDM+Pjw4b3eE7P/uyPlX96hs3Pvjwl+lyJVagkHXy2cydz+8OR6ON1TWWRK1G8+aNl8tO4f/2//h3S5euNdr9e/cffu3rX4vd4a/feTdXqJma+cmnH6+uzkrGT07Psqk80IzjvZMXbl0Y9TsfffhpvlBIp7LLc0XbUHfuPI4Zj2M/55hPnz3rD0e7e/ut1uHFi8ssHs9Mz1iZbLM7OTg6sQxczDmno8gPUTLuX14t1iqk3Q8TqNaWZuPQ99whQoizJOVogZucnrYzmcx0wV6apZISLnC9SLM0MFNp30gZQXuqnNNsezBxmzuHOR1ZWXNz56zjRjcuzH3zxZoJQQjATod+8PkWV+6lKzkeR0tL87eulWNvzMK+rWMg+MgLZmdrsedRHUzNlFq9iWFn/9mfvPz1b16wnNTuQfveF18Us/aF9ZmpWuHihY3yzGyj03ny5FlGQ1fX1hSQh2fN6ZWl85i898vPlmvmrUs51xdbu83N53tQBNWlZWKlNOpAiI6b3cEgmK5X3n71IoIKS1Kr1Ee90eazp7plNbt+f+jVKvnYHdqGtrK6krKdw8O9tGOWSznfE5QaGStdrdVazd69Bw/zqcLCwnwYDnWNCs50TaOI9XrdSaKypcXD7X13PN5Yu5DOOOPJ2LEwBGI8GhLL0i3D0a2pUtYg0CRYtwyIiecGrhcGcayAMjXdMTQIJBBx2jQNw2AsCSMmpPKCkCUcEc0g3EGoVszZphlLSQ0LIL0/mDCROBbJZtOWbZqaTgmChECJmZKTcYCwnk6ZuZSVz9pp23JMYlKkBIMwphQiyBFQRIOGqUOJDEJs25A8YUJISDRq8JhRjDhLIj+kGrVN27F1DEEQeJ3uwI8SqBkKICmFTqkEXCgQR6rbH2MILF23KHUMmtJpLmsJSHuD/sj1Y8GlEoggE2GTkpSObJ2ahiYU7LnecOCGnENA0halELNIQoR0DCVQVKO6oRNEUoaBJeRJousGh9j1AsGFrlGkJEY4kSxKfIhUwjnjEkuoFPj/EfVCUAQRIFEUIoAhBIToECOAMcAYAai4gABQpAGooBQEIgkAAkTGAlNDKq4UhAhjBBGhCCAAFMJY0wkCgCcACI4QwBoCACMFIICCSY4IkgKwODE0ExFEqaaUSrhAQCZJAiGm1OAs5lwGgkmlFEJAKAixklAkUgKigDSogRQkiAJIEUAJ5xwogJVkAmOIKdE0TQkFgBIKmkRTAMRJwgGHElCMMUAQYIq1hAsFJIAQIY0DqTAmBDEBoAQEEz8KgjhOGJNKQgUIpgIAwZmhYVPTEVAAQaFAFDMmRJwkrufGcaJD6Oh6uZQvZixLxxhJIATAUghFqCEUQEQZOqUGQRiHEQ99piTERCIIgFRQIoygaegYQSAUTxIFkK5pRKe2Y+mUIgx5wsIgwZhQQhyakoC4YaIENk1dCokgAgophYiuhYx1+yM/5lhJijWia4ACgEDE1WDitboT3/XLuVzBsS1dpC1yeWWuWMi3mt3+KEwUAkBlMlq+kAoCf3P7ePewcdJtFkq5er3MWUIR8Se+rmsKqjjiGLJKqTQ1XTlr984aI6FgppDNlUuHh8fn7daLt1+sVQsQ0larN/bVC9dmX3r5sm7SxnmfxdI2073O+PSwRXXT0DWq43KpWCzlKaW5TJ4l3BsFGSelIZbL6EwKjegKka2Dxv5x03XjbCEzPZVOpSgBoD5TVygYD/ulQtq0iW3omm3FDDcG7nnDBUBgKVq9XpjgdDZVK5czqZzr897EI1amXK1RwIAIbFufhPHhca89DBQmAAAviEdeNHQTqGTBoQbB4yDoeUnoyTjiM7MV32PjQJSLKSTDOGZRrCyDLq5MCSUaA1XMalkd1osWBXzoRpTQmbkpr98NGdYxSlEwN111OdlscB2Ca/NkpZobu+HE54KB5dmSiWWvN5FYzM+WVDgEMPb9KJ3NMhbrmLh+7GgIIgAJncTM48oPWcY0Y9+dqhVZzJ89faIQvHrp2sP7X4SjqFrOp6zs1s7+eOwPh8P9oyMd65VSqVouddo73/vOVyhFkTvZPzn/6ItPpmfmrl1bK5frR/s7rW57erquIIrieGVlMZNJzc4t/fAHf6+QuHnjcuP8OPTF+vK6odOtZ88BJnGcTM2Ub966Nhn2f/iDn1LNqVRnHj/fPDpplQpZz+3/t//sTyajwWd3HtSnq0eN3oPnB4paR8e9fMbutVu1ShkheHBy/um9nfaYZbNEI9wdtGSioghIAd5862balJlMjkVmd8RtOx2EkWOR2Znqabu5tLLmBsGFxfJKzdw5bxQcsF4p/s7v/u77Xz49bo/TGFxZnu6P/b2zzqPdPUK1Qq7QPDtfXZxares7x53DYzdjG93zk0wqZ6cLTADLtjWtwDg0DVrI22vL85SQ5zvbJ6enMhFhEpi2nrAwCKPxxFM8mavm5per41HPHffr1YztmHfvPHr3/fvuSJgaDTk72j+xbavX7/lBn2JOMB67wUmrf9Yafnr3+Y/fubt37kuYap00pVBzM0VDgxHj7UH8ZPu0N/aUjMo5e7paLGVSlUoRxInnDSeD/tHx/pOnB5/fey4kr+adTBqldKPfH3gBiyLR6w97vX6/3x1MRoZuajaFGHhhGIfRoNd98PDx3/3s/cc7jWKx+Oqty+lsceSF7dagWivN1LJQRflCPhaKxf7GysLrr97+6hvXSjlraba0UC9RwgIBGmedSiErVYwhHLt+s9UGCOerMzZBl2arL11Z6/a6H33y5OnujusFAuKj8/44kbblRJHrTXqlfKqYswvlXCqdVYBYpplKpxlPzhoNRC1q2Dv7x3tH58/3G3/z00/uPtsduFGr1zxq9Lv94PHm/tlZN4mjXq/baTdajW7W0WZLjozGmLtSKF3LYA12h4MkCrJZO/Y9wfjcwkImkzYo1g3j2fOjL+48IbqezqWITux0OkrQ063T3b3WwOXn/WEYJtl8zjY0KbgQyE8iDFXGNg1COv1h1w0azZ4X8K+8fHmm6gy640wuq+sEY+WknJyTDYZht+cury2w2BcsNIzUwEsePT+ydZxNW37gptKZRqObttJfe+s2i/qaxjAA3Wbr69/4zte//tqHHzzYfv700tL01fUqiH3TsrxJ6I07b799S0q5d9Ra27gAAYQQnDaaWLOe7Ow6aSdnpXMmWl9btwva/Seb+WJNA+Dq1fVyvhBLeXx27lhmvZJfXZvrdc6rtcLEnaysL289vlebmrp8bfXO3Wcpy/rmmzcH7eh/+f/8+atvfH2qVnt292nI0PbOzutvveKYJJtxDk9Gg0n03kcfLy4vahr9xS9+bVqFTD5LIJyEyV//7U+7A08pdbCz/9N3P/1X//a/yaXE3/zZn/3pv/mXj7dOHz1+ODU9k3LsX7zzqxu3XnLD5NnzrX/9p/88lyLNs1N3FFqmOV2rTtWKAKpfvHdna/8om8msLa34rt/qDq5eubgwV7myOm1pcHV5ZdDt+/6Yh9HBwW6hVLUz5ShMihmzlDM0ojc6g/FwUHKc6WJ6OPT2985DBuqVjJ2yqUkn43Eh41gpOnHlvSe7FKC0CYhOh4NBJm1SIE4PjzOZatqxcqZancsjAocJOG0H+WzOMMX2Yfjw6fbGbO5ffWe5qAmu2FjCT56M4hCZloZkMl0y8nmj2/cGXhTEyWgwCMJ4PJH3nxx7TA7c6KMvnv308ye6EGtrq0trK0LSne3dtG2ORr3jk+MrF+Yubqx6YXhyemzq/I2X1lMp2Wl1X76x3u/BL798fuXS+vWrUxuruUzKeLbn7p00M5lsNqPP16vZTObRs5Mnz3bSKePKpQWKOUv8ubmpMI7azS41rLOzLsbw9u3rPPGb7ZNMNosh6Qz6EQ8VhEmikjhZqFe/9vXb/njy6WefZ3OFr775aqnocB5DRfK57Ey9cH7W5oSkS7Xt3SN/4l1YW5itpjrtnkQGpHqvP/KiyAsnLIiSiDFFKME6QulcOpVJEUxYyGQSMwWiWEQxFxBSjdqWlbIs09YwlGM/GMWC8YTqECNk6MRAlMdCAQARCOMkFhBAiACQQgjJNcp1k2gYISWC2He9IEm4TqlJiIZR2tbyjmFiTAEhAmgaJAoCCDkQGACilKFZfhiHoQ8pcmyDYJ5LOY5pRKFPDVB0aDljpGwKKekNx/1JPHBZGCsplYYwYwwRCCEVCHApLR0ZVFLEKZCmZUiOxm4UJUwp5IdhoqSua5alpSkxMeKCE0P3YzGaeEEiAMCEYECAUIorrGmaAhwDwRUXSlBMoFIaRBbBiEBEScxZxCKpOEEUIwoAJJQACJgQCEOgpFRKKBVHiURKs3SNUAWAYhE1dIop4FIChXSqFBRKaJqmaTrSEJBKCg4oQkAphDBAECIIEWOhZEmciCSKhVISAAilBIoQTSnMOEcYAJEoKSWUSkCFCJVSAAD8IOKcE0y5ApjQiMVRwjDVKKaQAyUlBFIqwIRUSCkoKNEkRFwqhKhUStOwFNLQKeAACIUIlFICqRgTAChKDYoUwJpSULNMW7cwxlIBiCEXjEsOEdaxgQmWQGiGzhkDUgAEia4rgA3dkFDFMVccsoRBhBGFHBMJUJzEAAGkZK83HE2CiAvfixQkkGA7Zacyuk4g1aGGoY6gggpizZ14nHPARSGTAiJhLPEin0tONYwxMoiu22Yc85HrAUS44FTXnGyK6CbAKEyE5LFgSTAeAiiCJBFSIKSiKBq5o15/iDHSbT3mLGEJxBAiyGXiuh6XEACUTmVMU5NQJFHIExaFURwlUSSFBJqpZWySctBCKXNhpUYMeLy/3+kPYoWtTD6XyZbSJpVRv9PqjlyJrYW5+Qtra1IyiBAEOArD0XDEJbNTqVwhbxikcdZsNfvl6RnL1jMpM47k7t7u1ctXZuengJSbzx63mu2FlYXVqfSw1djdP3SjqNltnZ6dtttdM53J5wqYwrnZ6vzyTBJFQPAgCkaTACFsOEapXIoFcycTCGH7vNtsjDlKbVy6mkvnNIQNhHIpJ3QHw35ndqq8OF+3TcxC92z/7NnO6XAcrW7MLsxUE86krqcrJcnZoNM4Pz3dPmkMQtoeuEcnRwd7jf2dxub2wZO95mE/CTk0HNvK5JgiQDd1x9pYqS5MZ3jgJkmspbJWJp1KpQSEJ41WLm2mTBqMhoQibxLYWLqd5u7eAbfsLBVEKZ0oEQDmKyeTymRL3VFEiBUGwUIJ5B3ti0e7+y1QyqC3VrNRPBmPwknIUw7NGZo7GXb7I4MYBIT0/8vSf3XplmXneeCy2+/P+y+8jzg+z8mTPisLZVAACp4ACYIiKQ1C6pYu+rbH6IvuH6HRFCmp1RJ9UyIJgDCFQlVWukpzvIsT3kd83m6/l+uL1H+YF/Nd653PI+Oc406jcDQOoomXtfXIHzo6Nk3TGwe9XqCkLoSiJpmpVBM/7nTOdAu/+/ZbhGq99uQHP3xnppz/7NOH7c5gYWn54ZMXFJGtzXWNkpe7r8tZ5407SxNv9PL8YuKFP/zVH9x/62YUTi6uelQzsq4tUrXYXPjeBx+OepfNZqE/GPzyq8e/+eNfJzpCAMzMznXavdGwY+iGrltv3Ls7M1OPY38yGCuIPvz+rz54+OLRy+Ef/dGv3L2x5uh0drn5H//sY4mMcDSEUkisBQnOZqzV+WUTJlvrS9HUf7V71ly7GStiWNb1tSUL4/WNdS1bMaksmPp0OBgOhlfjabfnbe9eDEejhfmZ7Z39+dk5jECv5a/P1ep5enaVbC7P37s51+1dfPnN61TizZVy0eQ//enn5720PwzLjfqDhw/m56rX1hvIO80WV958a2thtlHM2O2Liz//ycdpgifd3sH+LuNiOBxOe4PE9ykm17fWb928N5x6w27v6uK81+1Px2HropPP2m/cuUYhe+vN5b//d3+wujLr2LaRreqZ4tzKWrlSH43GM0uzM3P1+bnm2sqq67hSqnave3pydXp6pjs1TvP9kBiZnEaBmc0aCDdKRclhz/Obi5vv3r8/V81RIIJJ96vPvzh8+dC1cCFrb6wuvP/+u05hKWYQcKRTPNNsGLoeR8k0TC7bPSvnYp2Op9NczoGA9gc+0a1SpbIwP1ssF53cbCRyByfdJBm4urg4O6AUNecaq8vN5UaGivHV+XG70//pp6/+6i/+6umLx71ud9gf6jp1HAvK2HQy+UoxjsZF28hm7VKp7uSrpmlJon/28ccg6OdNmDddN++Uis3lpVVi5CKhJ4lCQJmI5xzbMIg/7KUJcx2TJUF/0IuYXF2/3Zhf0SzNMDOG47xx8+aNWxt2oZDiTKU8s9jMRwm3i431628Ry844zubSzJ2N1fm5ejGnWSrl6ZCzEEk5HI98j3W6vVLRWV2Yn5+p+eH06dMn3U5HgaTX7drl2cPW9PMvHvW6w7OLy/4oKDXnVlaWhiG/6PUtuzAY9cejfhCEOjUA4KkXJ2lETFSbqRertd4gDBnvdduHO89KGTtmie+x6xtr+bxrabTduzBc13Aczcquri1GgQ8g1sxsgo008rOOQ6k+mvhcqBcvX1Glbi7PlwuOgUTr6vTf/Zv/j2sZf/In/2WjWn/26mEtQ2qVnEY0pEHPDxM/ePudexKAv/35L+rN+X6nX8rmSqXq7s7Vs4fPsqb9+slzJPnCfLPTmiReHwO5f7A/M1uemV/KFsv9bjuYDLKOZel6xtELrubamSAIv/zik3pjtlqtPHrwdd7mP/yV9Uqh8q//9b+wbdcLgxu3V7e3X6VJcOvG2o9+48OPPrgxv9hsVBuvdvfmFtbOrwb9STAdT+ys/d2PfmU4SVKZzs3OlGZmTk4Gn//sb/+f/6//e95BxXLhn/y3//iq1Q7Hk5yTX1td+Ju/+sv7999q9UZ7R4fX1ldFEhSK5ScvXncGk1y+dPPWG++89+HnXz0dDEe2gZeWlne297pXrUrBNW14/40blWLOzeVqxfz83MLOyxaUaGV9pdPn48mokHdc25qM49cHQ7eYq1RcVweDIL0c8/FgQnUzV6hO/ZClAVK4WCkJLj0vsaleydgEAZGwSr0YBOzy7Dwl+Wg4yqDJfLM6UVorIGkcrDbKc0slz5OPH2zn1fSPf221kiWxMobSveiML696J/vn3nBUKlVml2eIqYepkFxoTj5bbZQqzXzOtl2ETGM0hnsvXn3280cPH+/1RhMl4Wg4tE13OJ5888nHrklu374zt7QGgFdxxRtbyzyNLDXeWs8+3ksuOkHZhTga1Rr1+tLscBS22q1g0h95w0IhPz9Xbl0Njo+GFCTLyw3X1lvd/rWtGwiTQa+HsPris6+mfvS9X/3+4lyzO+pBy1xcXOYS+pOhkqA3GAy6rUreWlqc3ds9+6u//CkQ7NrGWqNaHoyH27tHc7NLq8vNvd0doOHqTOP85Cgcdu7fu1WrO2edAce6Y9mmrhNKUwU1MzOaBNPplEd+MB5zznRKCMEIIaUgVyjiMgqT0J8qngIVGVBaloaxFiYMQl0pEiVBGieEYM2gWKYYEKFwwvlwPGVpqhsWEIDHkiJgmsQtFQg2BEAJ43EQJyIRnNkazdqGnTENg+i2DhRRCkjJCNGYFCbB2ayja1rCOU9iFgeEIEfDpmEopSTjBKusZVYzrmmaum36aTL0ozBKKSGOmxFKBmH8LZNTsEQ3qKkbhkYcXXMNkstmTMuMEh7FTMemN419P9QQNnWCNaphLDhkaSqARBTwJGEJQxgyJWOWQKAwopx9e22LJCaYYEKA4EwBlaYpAMg0LQiUVBxTEsQM429BOYhSIoUQSgkuAYSSS56mjDMAoQQoSWKhmFJMKKCkQoRyzqIgjoJAMi6BFEBRrEGIgPrWCQaZAghSAJGmEaRRKRXGBFAsIBCCizSBUCKFEJMAY8qloBBDIJWCUgBKKcGUCwaY/DZQQApEygGCGCAFMUKYUA0hBBQCSOOcCc4AhEwJAViccoWUhBB8m02EgIAoiChAQoE4SQEkKY85kyJmQgIhJIY0YZwpqjABCnAkGRcKCu1bGRoHBqaCCyZSpRSWEEMQJYmEMIhSLmQch0wpBSSUgDERpwJpmFAt65gaxo5NkGIGhTolSRhhApGmE6TJNCWYKAGyriEFV5DEkUiU5sUcCEQpQRBMxn6QMoGgEJIAaWAIMYxSzpTUKHRss+CalmNJIQGEBkVJAmIOR7GMGQcIAqEkE7alAwQlUKlUEGIumKYLimNIqGBMcm4blgCIEE3XqE6gaVKCuEWZZSGZ8H5v4nOS0rylqYoLsroIOd5riSfHY5bwWxuNhcXKYDpOuMJY742GTEnNpCZWcTKdTtKD8+GjZ7tr68uuwbzxABt2fzDJF0uNRj4NgtOT3mXfm19dW5ytnFxOh37UKNVYwvxp6vvh7HyxUdXPWxdUc0wzf3xwGicxNLBuGYauOTk0N1OrV8swQmM/OjnveHE6W8uvLWSj8eV4PB4F/Ljba48uNRjf2Ji5tjYb+EGUWketuD2crsxm3723nreNs/NzyNhmw1nOaJLLYYoDYeUMA2IiEU4YAbbh5vOS6Aiqpg3qGTZvJxk+WqjoG3VnpugEjL7qppiieiGXx5FOmKGbl21v71yMVf7x7vgygJlc0XAItHOtCY9S4BAcetOQ4KFWOby6yhhgvgBbnfYwQpjwPAXXl6qvD3rf7A3tHLm5mruV96fCEjzMmKpeyrUmweVQJUqb+lHgewsLS/0JO+3BSYIAgTnXCJM0UHQ8GgHpLy6UNR1kMmajUjo43Ns/787PNX7lvc16GT98/E15bnnt+ubOsy9nq8kf/8M//vSrp4Np+N533ia6aLc7iR/dv3uTqCSJ+OCqt766Uinal2eXV91AI7R11Tprd/LFTHWutnP0wvP8QnX2yddfLy/MLy3PHe+/Nizri6++TqP4jWvz/pRVG6XmYuPLL5+cnntffPHV+nx1fqn28Sef3r+zdPva3W++/vn6eu305Lwx24CMnJ93NazzKLBtU0HlB92tjeXRqH94sDvfLA29qeDqNz684SbnpbweSuS1tm/eWnv4+KmiroC5MFEv946Bga5vro0GfZuCfDn/zeNn1XoeaNbhwUvXdC46I4bA9u7h1OOAOHMLW599sz2MQLWcbVZK3aO9rRn39vKc4mycinS8s77UVFK8Oth79nzn3rVr1erC3sng/v1366WMS9S7H7ypIV4u681KvnOxrxE5U68TQFgs+52pBBTo1t7B0fnZsW3og+Hk1e5ZbzgCcvTde8v1DHu2cxnH3lLN2Go6KAr7/Z7nTR1brc7W3ru/Xq1kdRQHo76OISUEQGJn8kI398/6r/fPsIAa7zdyyNLT1miom7lSOVOs1oMUtsbJKEimg4436a2ulPM5ZNoojtNnLw+FIoV8YWF+fqFcePfWwrt3121Dv+wOLtrezl5nOokTqU0jmHjtu8v2b3+0eXtzvdMf5HPWfKMhWfrw8U6rn87Pb64szL59Y8nJFXaOu90J3O2iX+6GX73q9/vh0txywaSrs5WP3tyaL1FNeYL7aRQ/eHG4vXOo5WrTVA8jUS46N9Yqk0lv4EW9ob+7d3R22dWcfKzsbn9SzGazLn51GrzYPkJINJo107S/efr1dHRBIZ/6YW8kLnqRY2i/887K33t/aaFC/Iht1rWKw492njJ/LJk/6Z13u63BaDzw0kgziF0+b48vugMuVbNemqvm+x45ueinnBdct1GpZJ0sJTpX0CJqc7E+2yyaOuKpAJAXLVJ3oJPPsDhyTMuxc7plCiVjr2tqRiDUwZU/DnjgTXMWXJwtVPK2bdMEOZrprMyUp8P29uHlzt55v9fJZJ1W67JWcLFKpsOxAurk9AxJdnNtmdjFOBhuzGVyWowJFEg/aw+USudK7vXVyrXNlcEk/o9/8dnR4cH7b75Zy2oPT0a6ZTYrxKHQteyL86v9V08211YfvW4fjkF5ZeNob7+ZJ9dv1i7H/erC8kUITjsjN5/LUKBhiTKlL197j4/8gq7eXMkUcrrQnNaYdYb+VWsQI+f0qh1JLZIaoen15cLdaysX7RYXoFbPp1LvTuKr/oQItLG2/JOffHLSDm0Zlk0umT87W6sWcvmM/tEH958/fzIcTrmCP/zBuz/44RuPv3lEaXZxttkoi08fnv3tJy8AtuNJ68ZiuVjNc6B99eDLza3rxCQHhzvvv3nv0aNvEDXtfC4OJ2Gq/vonXzDBU2+wtVrKOPR/+F//fBDbEusyYTt7OxLT/jhptcd2NuN509OLZ5WFZeiae4cH12azy2X44OFuqBq6jKq12rML+fJ4vLVcv1YFWiqnDF/55OL0aq6aq2TNTm+MqIYwCkOv17sSxLAsLeOQy06cLzZWVten0/ioHYwZhZFarWduz1u2DPwouvLS8XBEcxVSbmwf9TLJ8MdvuDkadQOZJkJqRo/Tx2f84euu4qrk2o1yKeWy2+4XjXS1EF2btRsZreZoG8vlSr0ADdQO4PMW2r1UUWqWHL3gEgnJNw93X++feTHo++ikPaqU7F/7aDkN+4brzCzlv3n0nAliupnBoNssmjeWyyBNBNYV1oeDftk2Szm3N+ruHA36k8hyM96wN+y337x7vVErJTyOE/zn//mzL758uLCwuLLQCP2BSL1bN1chxYFIrGx+NBlOhwNiahzZAXMePz87Oz++uTF7c2v16GL6i4eH9bm5lZreuzgtN6pOefbp64tp7+S3P7z5zmahc9mNYiEEylpOUWPLJbJQN4VUfWjEWJuEchDGCQACIii4YyHXMiDCKdd9phKJfD+B1HIc2yKgn8opSwVDCEohk4SJRJmTmAGFACRMqpRBpASlIEr5MFChlzhENQq0YGHGeSAx4zBNZJymAiAiuUVl2cIFS+oUcy58hqZ+NE0CwJlj6lCJkIlUamkshBQGUQSKbzs1ggkTs1qGLlfckmsgLiMuJ1EkWGJophAwStM4SSEiivNcxizkHI0CQqGGhatTW9cSBQZ+ICHQdAogVBgZGIlUYAQ5xL1hMJhIpYRCChOdAGTphECVRJFhGo5haBQJEVCCKUZUIxAghSDBEAiEiYEBZlIgwCXjCeNpmgBMlII2pZahYRMDCISUEAMgJcFQISikkhJgiKlGCJAa0SAAmm4CiYDgSEHOE6kAIlgwRqHEUgrJIKVKKASh5FIqIQXHEgEANV1DECIllWkaCAMMCFCKCykAABKwlCeSSyYFwUIhLiSQEBKSMs6hQkrwlAvBOeecpZylEEEIqJJQSgUUhUhyxpMkBRApCQkiEknBORNCCQmAwBgShL8NLkwKCJEQqeScQEElwApCgKRkgEuJpFAQI8BTDoEkiFKqYYKlkgDib+1lRGEMEUgThDAmGEJlGLprWwgKwyH5nCG50E0NAhUmnGhmEqkgTCLGhVJU15FOk1REqZh6Ho85TxjkQmIQRskklFxpAOpKIMvWIMISwCiMkzhCAJqGls24CiEoAUJEAzoTKAiTMPQNhEt5p5LL2I6hBGepAhIkPE2TVEBl6lrGcLNmNgyDlHGqG1HEphM/TuIwDiWQBBuOk8lk8kKgSZomEnLObI2XS6VsJhsBdN5qd/o9DOT68uza6uyo2wI8sXXDn47TOHEMO+vmAdSiIARSjgaj+dlmqVRst4el+vzVVTtOpo2ZWqlUubhs9/vtu3c269X84e7rIInK+VKvP7g8OTd0eu/Ne83mWr/rlfLZmXrp4ORwFPhOIZ/NFE3L0A28tbzm6uTVy9c7h0feOM3ZhdlaJeNiapiZQpFQmM0b66tL12/cvvXmh2Z+rtcfH52cnZ8eM5E252esXOHs4OD4cK/smnffvJWSzPOTq2nMmBSAcz9I/YmfBDGBoJy1TBLXMuTmRv3a6nw5WyF2LjHyI5HZvoofPLt68fxchUE+axLIuFQA692xd9meuDbg0ZhJNltrRIGPmei3uhiRbKYURJEA0DR0LFjgyfVZkMln+/2RBlG1lltcLLTGweuDC0sDMyVbJlHfj4djFqTAzeX9MBp2B8WSBaDI5Zxq1RWSd3p9gAFnPGsgw3JGXoqlCEMJkKvrZveiVcxlqVs8OBvNNAqri7MAwL391vFx6+7dG8N267zd//7v/r2D49OPf/78D//Ob83P1EYDX0jt4mowjdjR4XEYJo6bcRz35YuXO693NIwPTs8VAveu36KGcXZ4eHneuX5ta9zrXV6133vnfueqZVvZXqdPMPjBj76LsNlunW4sbV3tXzx5vON50zj23vvo/eOd/ckk+dUf/WB/94XiwHYKQPKZZuH59uHiysrAj1MBQ6/vWLCaz4ShzyW7dvf9TLX2yU+e/Oi792ZrM2edYbfXOXy9vbl1K5OrNhcWqem0WhcOjt68sfB3f+c3Dk/OzlqjG7fuXR68Biz93od36/WqZlRO25cv9nuaU8e61Rsluaw2GE33dnZvr9bmZ2qlnPvuu/crtZlOpz07uySFyaMpAhgoDSvyzof3V2/cPL64kEkc+f3L1qkfR71Oz3GtmUal07kwdBtJNZkE3U7n7PIMUShYGseBN51sXd8sld3dvW3BwjRO3UyBGeT8rJXParXqfKSs897Izdu2m3GdQhCKwbSfdczI9xq1an2m7BhGqz/qTcJosGcob26mtLgyoyOYyeRM2x1PY6qb6xvXVreuY6K3OxeBN+leDc7O+4E3iKNU151iriEkyRczhonPz08Pjw72j0929s5avf54Mi3krZmZvG2pi7Pjy9O9nA4RBtNQ9T0Zp0hH2LRMbDqcw85gctwafPHk+c7hqdCcGzfv1Gdmc4XKeNDtdi66vc7u8flZp9Pq9XZ2t8dekC1Xi9V53XBCnrx5a6uUc13HMHQZjS9A3DEpiFJ2ctFys9n33rnz5q1ljXAuQm8y7Y4C3XZX1+aLeStjOxqlKUA6dnLZcqFcTWSSsvDi6uLTL58+fbX/6vgkThmEgrqZxuzM4nxzfm5hcXklW27ohOZdC0l2dnTUbfd1y7JNZ3G2CQllwBi2Ooenp2M/JjolWOOMj6exadksmkbB0Nb0haX1N9++VXTN8ajX73WGIx9SY9Rvd4ftMEpmGrNaJq877tLinIWZN/IE0M7bfYqArRsAwMmwL1m4PFudqeRNXTPNfJiiWmOxUq6enLdNy825Tq1S2tqc1Si4OH458dPOOO1P4q0bd/OZvEjik8NjhZ3xaDDpToq57MbKytzC8ssXO9v7z91sOWvAw+1dA8pSqXJw1S/MrywtrFEVll3t1YuHuUw5SpJXr17cu32zlCu8ePEcIG0wGNSymaXFbGcwMSjJunjYOTu87KYge3barpaKo85Vxs5MRp5FoeEYUslO16+UZhSgUHi3NjdH44Gta/ms5eparZD58tHLbLW2tLL6yadfnrVapZkZxtnXD5+aVt52rA/ubyzVcpNxGE6jn/zkL773wXfv3b+zc3RgOZnvvH//xYMXB693hlP27Mm+bli3NlcQTG/evH1+cWpS8stfPjm9HFzbuLaz81InZGl+5cffe0/T4cXFuDv0x73hH/zBb2cy+n/685+6WZspFfhM1zNFN3Nw+Pr49KSxsJ6MUymkZdpHB3sAxOsrzbO26rTb/WnoRenCrP3kwQ6C2uJiKZMFlg7cTK7fG0wH04ybjYLQtJ2842CCA6mZupGEYT5fmCRq76SFDI0QMImmAy85bPmnHa9ayd/aKOpY2jaBPG63rjqDAad2eyQyucbmfN6OJpEiluHUy5ZBpB/Huyft48MLCmC1XCIUTONE6Y4XBBnXViIZ9nszlczWSrnoYA3LVr//cv/0ahibmXq1OetkaOoNvP5pOZcjBB8cXrj5+q3N9XB8ldVTrKPj43Zjbo4gdXl+bptOJpdXUlhUgwIEXMyvLRcKNcD9YDygCM3Mzk3Gw+Ojg6xtzjUbd+5er88Ut1/uffPgWaU+vzCz8PDRy7PT4RtvXPe9aac10IysArRayEs1PTnem8bpVWv0Ynu3Uq5+9P41b9r55adfObkasDKt8/bSXGmmln25fbZ3fLmxsra1Uu6PhgM/ClKRSOIFcTFfKBccCyU6ZIYGIIBRlHDBEQIs4TEXQAoJheSKcZEKzgJPR0pHUqSJYIpQSHVdCpjyOEhiBVF3MvXCFEPCFIs5I1TXTQ1DEXGZJsrQrUo+M1PNAB4nSSoADBMe8ZRLBBE2TdOgNGtT16RKsVSi4YSHQmlUy1iWQQlXPE0FoqamE1s3KFRcgkkQ9r2ECUCIlrfMXN7RDaKYSpIUA2XolFCNp1xK6Yeh54UYKiAZBIJL7nmegsjUiZDcj2MuZBwzJaROsGlhSqBOdUToNIzHsUgVDmORxhGXAGuUSxZHoZQKEWybjlI85amu6xrRkeQAopBHjIVSSSW5bliKAC4lxEAkKQCCS+BHgeQSKCklowgjRLgUSClCMFJASB6FoZBIAgmgzoQACECkQaAIQYwrIAFBSiqkkDQQFnEE/k+sDpdCAAUgRoJLpqSUEikJAFBKwpRzIZUSgHEGMDA0DUOYKg6kkEwQQiRXQiiqa0AqCTCkCBGNEgApppgoCQGGSgouGYIKAWIYJoIoSRmkWEqFAAQEKyghwRSTOI644AACoCACECKsgMSYIIQEUKlijHEoUcIVS4ShYwUgIhDrmpIs5UJAknIVcQaVUEJIABAAlq3rGDMhJKCUIIpUxtI0hFSaFrJ23rWRlEpKP4oVQkoBShDRNKGASHjKE8mFUDjijCshIBApjxMWpXGimBAs7xoG1TWq8xQACLJZs+CaGcfmCYuiGFDd86LueDLyAw4UJSTvahmLICwiL0iEBASkXKYJ06iWsUyq6TohURwpiAA1Y4ZGXgiRZlDD0CyNapTimDHBQRDLbjdujfzRdFpwdVMj+8dXZ5ddIFDOMDeWmxsrc9N+l2i4VqulKfP80DAMw9QCz09FgnU7ZnGj5G6tr5ycnHFlXFz0uu3W4lJjZX7mYPfo9Oz82s2tfMY6OzpIBZifmzk9O3+5vVOqlW6/cZMp9fGnn0WxrNVrV61LBNRMvVmvVREE7fMrx7U5U7v7h3GSZgtl3bFjGQ7HvXI1b2dz+0cXSqS1YjZj6kHCd4+uPv7kyd9+fnDZGiNCHN0ajif9Xm9mtvTu3Y2VzflXOxdfPDo7uoz3DsPhIPKCgCtcrRRdx3Qc6NCkkUObKzUT48tWy4/8y77cPR6/OOptn/mTFMzV8VpdBwoKCDAxOiPvoh9wCWqlTE4H1axjm4gqEQs5mqpMqRzEycDnBuYWhUSEc0Vw8/qaobg/CR1XqxXynu8dnPt+okrFLIr9bjuiTmkUJDGARDPPrnzb1iwCWeBnLUODxmAwIZqdcpmmwtBMwcHY45pOPZZKqg86bQrBTKNyenrFFVicn+1cXgpBTy/OdYodC7/eebV181qcGP/0n/7/VpbsrY3lo8NtocDx+VWQJBDrSmqjwcSippBiOg2Xl1d3d/fyhcKb9+5fXpw4phEDOTOzYLuZ548eaQYhlB4fn+/sHEGivvPdDybTSW88XFmbxRT/9//8Xy4tL9YqxWy5XJ2d+5ufftJomFkbTPyoWJw/PLjABH72i09/5f1rK0ubz1/uHJ/2EFR/5ze/z0XYumibeq6ysP7P/7c/XWyQzdXaVfd85KPD0/S3fvVdnYrjk6NYki+/ejnbcO/dWbt5Y+lwd/vRk4NCqW5mMuE4nq8aM7UMEGlnMD09uJpfW2ys3/uLP/sYQ3BtsSnS8MbW6v1b6+sL9eWl+Xan/+zZTnNpcdAf75+219aW3njj9nTiRTGXifzZ3/7NZDDY2lyWPKlV60riNBXFXP50/2jUnygAoyi6OL8aTpkCNAwiwzYJkMurC3MLFQ2B1aX52Xrdtajl2Ae7R8eDaTnn1Ov1OBWXraHtFCzTipis1Zd0PdPpTXXL6Q+HcRgzhBiHw0BuLlbXFmYBkARht2BYprVzcNIepL2R/2pv78mL7ePT82w2b2h06I11O7swNxsG7OnLvUfPXrQvL1cXF2bm6uViJp/PWG5GsyygCEUQybhezm+sLV2/+8b87NxsrVosVlv98WV72uqNbDefdbPT0WAwmlQbM65tXbba/Wn8+NnOycFOrd6QIs3mrOXF+vxcUzeMo9MWgJptO6/2jnYPWy+2D1nK5mplAuJs1ikVs/VybmWxtjTX4Akfjr2rbr91eWFSWMjYulKWRjBFaRpjzeFhYBCYzVieH3Quu9lqjVLr6rwlE9aoVBZn6zc2l618eRLT06uebdhhApJUzs5Ua7W8H4aBH/tR4rhWo1aebTay+fzm0vqH7797dnH1bHtfUvP6taWZ2ZmxFx9fXg3Gg0jJkKso5aVqNZPLTMJwe/ew2x0SigwTZQt5IVHGydn5oq4w5CKVqVSkM5hEcXT7xpZlW+3hWEgSRBxrZG11+daNRUsXGo7KFTuOvWnEJonc2z+PY3Vy2t3bPZSKQ+a/e++Nd9+4VnC01mAksP7w5enRadu1TceiLJXn7T6TWqVRHfb6g25PsEi3sqNRygR8/961tdnswf5RtdxUyPnpx5+PvaCYybz95g1diV7ncnl1YzgMe+3h8vICD0dX/bFu2ofH+67tNkolfzqaq1WC8fDZXou4pWKpHExGuo4LhWyzXvBG3YVqNk2T7jDsjqbXbmxsb3cm/cvvfufDTn80Mzun+PB3fvwrjqk/fPLi/Q9/sNzI9rtT03AL+Vy31VFATcdDO0u3rt148Ox5qTlv6NbP/ubP3rh9WzNIkvrzzfof/t6tzsVVvxeft8f7r/fuv3EXSpaIpFEo3L7zBufqwcOnzeasbdOXz152B/5iM3ttfWX79VEisGDSH4z/wR/9/lW7//LZ9p379zo9b/v1/vziXC7rjr3w9c6pbmbGk9HC/BwGqtU+by7M5lyQpl6mVDs779cKZY2i16/3FpaXixkCojHEQjMpYz4nUnNz3XZ7Oh1XKvXxJBrGSZRyS0MmBilnURoZOpACYYxDSVvt/uHJZc7JLzeKNgSL9Wy1QIHgpy3/bJRiam4t1a6XQCRIr9fVAbeoTBmUOJMCcnpyNZqMNESVoqEA/cE4ZzoZB08n3sVFq1zMri2UNRQjDGKJP/vm6qw1zhSq9XJpaakOhTw9OyPKxBC/2j1aXG38we98UC/gjEOuBmm/F964scHS+MXuoVSEGkaShJbr9obDg4OrTKmwONuUPG11u4RqpULNcTKtq6vJaFTIWndurM3NNs4uWl/98qt8MbuwNNduXabeZG1zdTSdnJ11gyRGUP7go7dtDLqXZ262HKSo1TpZrGT+7q+/Wy2YD5/tY1q4OG+PO735mQUnn+sMx5fnp4vVbKPkeNOwPwliCaZB4vtRNZ9rZM2MAbMWcWwDKjiNecxwpKAQStM0DKSSEkNCTYtoVMPC0pDj2gSkCAADUQAVUDhJY6aAgLofJaGQhJpJIqI41bCmE5ACedkfdAYTXSOljJ13NMV5zGUqxcQLU46TlAEpXZMAEVMCXUsjWE7j+LI/6Y4nRDNMXUdSpUBGcaQIMnRiagRwISSKuOz2/dEkiFlKgLIw1jVKKQFQUY0ihACCcZp+20VHCmZ0nRJkGoQaCEqZxBxDDWM6DcKYc6phRLBj2hqGEAjBeBTHk4h7MZ9MfQ6wFFJxqGsmInQaRinjSkGMEMVUAMlkqoQUElCMMCK6YSBCMUSSQ43qFFMFAKa6lIICIoVChCKC4zTmilOsaYQqARAhCCKokFBpwjghkhIMBPjWGQCEpFRhqCDRhBJKSAkh0XSAMRAAYUwoxQixbzdoBTAkSAEphOCMQQSZZLpOMMZKKQFlnDBTMwGCiEAoeSoSAIAQTCGQMgEkYnHCBUIKc6mEFFIKqTAFWsJ4xNI4SbiCgqeCCcGVkJLFiVQASJRIgRA2dB0qSXXKFUyTWAKsaZpQSghGkEYwQlTTNYowkQoIyZlUnMmI8WkYet6Uc4EAFAoIJQghGiY6poRAXdek5DpF2reyAs6kBBhjz4/ihCWcJVEMAeBMCCEhEFByqhOd6gRTQLBQ2NRsjFEMQCJRKrlisWvTfM4FUMRJCGRqGXo5V4AEBEEYRjFE2rjvMSYARlk7k8u4OVvPmoZBjMCLEsER1aKIR1Fs6hlD1+M4VSydRlEQRkzR6TQYDAcxTwWEPk+FkkLwJAkEZ6PJtDeeTkMfATg7W7VNrTcccSYz+SZXUKRBreRgyhVU5Urj7PxqMvWphiBQPE1MkxiEJqmMo8Syaatz3u0O0zQRIv7gw3fnZ+Zb3W6r3Z6fmzV1Mwgj07EWFpa63eHJ2cXWxvUbN9442Dv96d/+LJe1t66vxVHEJS4XS7at9Vrdy9OTbDbr2vbFeS9JMUBW6AfpdIIEu3Ztk+jOy+fPMqa2tLqWJGL/YP9wb2/31XYQ+Pl6fXFlrVypF0q5uXqpaJsGQglLdg+udvY7YZhKAIuzmZn52WazVi05FCTh1J8rm8sLtdpswx9H49HAyNgxzD7Za591QwTToiveuzu7vLpANU0g3O9PR+Ox5IQQI5/PmjpNkzjxfdfVDdcRCmku8gb9sR+mUDOVLFnEtEwra4xG02G/pyTI5QuXvcHeCVMEGFnX0WzPFwsreaXSwWCSz2QiP+AcrCyvJoFnYeDk7DgV7f4khXQyEYYBMxl9Mhxolm5qGoQqZilCam211hsMrs4uFhYa/U5r6nmTMD08Hdy4fevk8AhDvDi/8v/9n/7XStm5tnn98YOvhMBRJPu93tLczHjQu+q0E5ZkcoVuuz8/u4gxrlRKN29f/+yzTzPFvJvNXR6dOo51cXHZH45u3753cnzSuWpVStmN9c1+t9O57FQrldFg+Plnn5WbuX/4J//wwaOvr1+/0et7+4cXv/3D9yAMz05OXh8eZytlKXgURrfu3P3k059lTH1lsby5tjgYesOhN03Rl4/2/ud/8W8HQ/GHf/Dd/vluPls46XRu35hdW2i82t2uVRuD/uDaWuPe7bXR2Huxc/LgyUGpnLl+a6PTGbeH4N237hRs2r049gK2VpNv3r73z/6X//3xIf/dH90sFmzJWaVSOj85PHz57PHTx988eVWdqUxHwwcPH5vZymg4/eu//Kud4+NpEPX73YVqDWGRpJNMLmdZehhN5mcL5XoumIaFQqlUK0OSSQUMExBH2Mlm5mdqi6tLjUZNsCSOY9uyOv3Leq1YcHUAEZfO8Wlr/+Co0xkFXByfnr96efj8+au93ddJLE/PWpZTGHq+ZmgEyatWRzA4s7gRcLh/eBKncRrJdr/XG8URh7ZuxFEClAY0JxKoVJurVGqjUf/07KJaq9+7d0dHumFRP5iOOl0ApGnoBCCeyij2bt++fePmG73R5PGTF9u7Ry93jw4uu/3xdOwFbsYSnGMCLFMbDvtAJBbhM7X8O/fv3dxawUB1Wpf+dDQetJHiJsKLs43VtVXTpI16fmFpqTuKoV1pzs71rs68UUekUbfdnY4GVNMTBtq90DTz68sb77x9b3F57vT04OrypFktlfLZSqmSpqzbG0XBxLFtw7bTiPmx3263B8MxwrxeKWZNEIw77cvLwPPC0FcElCrlk7PWF5999eLpw+dPn758uXvZGQpAet2RRBQT/ez06NGTx7/4/KvLzohSYzru9zqH1XLB0A0Na5qhWxqmCLc7o7OLvgJGrlyfjrzTs6s4EZad9YMw8v1Bv++HzCnkF2aqyXg0nYyllOdX/b2zDrVMDGGSCt3OMibOjveJkvP1MtVQMZfJu6ahEyhkbzC5aHWBwp2B7+Qrg2H/+PVTRFi5WnA1KtK4Ui7t7u51Oq18OT8/P2fo1tllrzfyl5ZXIYRCconki92T0150ePjq1vVrRZtG0SjnmkHAv3q43e1PkjQtZO1J/3Iw6OrZyjBgQEZv3lgyDKLbDsZGu9/NZcxizuBp/P6H38k7+heff7p5Y7M77ADBDA1uba1STG6tzcyVK3ZG/8WnD6Mw+Z1fu/vLBwc6NSBl3fMDCPnU93/37/7OJ5/sn+w9/Y3f/rVqzr08Pw8jfv+teyLxOlfd/+l/+Q/1xTXb0n/6k79cu3bLZ+jBV1+8f//udNi/6HQXVpZ+6/d/D0H+1ZeP/uInf3Ny0X/z/v1CIdcdd6uV0ve//0Ema59fHIlInZy1fvaLby5brQ/ef2d2ofjZJx9zTPwwNCn98K3F7kVHprxQazx4/Nw07SBKvMmEaLg9mUqBdAOX85mzg0tMtOW1im1BXTcIhgdHJ8VKqdWZDsaDQj6XsU0bAx2JYrEaTqLYD3PFbG8wMkzTNGjvcsDDlBJANSAVBkJICTSTuq5tEm4b1nCiXu6daDomItY03KyXZuvFRjW7exR+/eSZaeofvb+wuthwdJDGweJ8M2fjTn8s9Ry2M2GK0zgWYch83yKmH06XFpbWVmY8ibu9ftlGb6xWXYJdK5cpFXe2r1onJ+3W+XDgl+c2qJadTEaBN/W98MXDx+Nh5wc/+PD9995q1IyX2/ssnd66uZrLGRIZlBqjYd+fjuZmZ9tX3eePX0EAsvkS1eyTk9PesO1HrFqfBwpPxwMg2daN9du3b5RKpVa3l8/l5ubrF61OrVK+fXMjZhGhxmAwUjz93g/fzecdUzNyhWKaim63LQX76IO3l2bzUTjtT9mD3dOen1hWxjAdoVCURnONwkwtlyZxGDOAaBCH/f6QYprPOLaOKUx1DSEAEgnTVCAIueQYE4WggFAI7vtxzBFThiaYaWCTIqoDk+q2AbKugZQwCMRQxWHEGVcIJwwEcQoQNCG2THMcRBfdfpIE+ZybzTpCKqrpmBDP88KIhVGUpsLJOFhxEwND0xzLUgoGMev2RmESaYRQTCeePx74QCrbMjRdWQbRMIGQRCkTkmMI+Lc6AQWAgmEYKiSRgghRTAgTIk5TQlCGmgQgUzMJJVJIJhUHMFEgSJjkikBlUEChcizNNEyKgVAwTUEU84gLBRFLhVLSMg1L1xDGinGECeM8jWMNE41gIAXVDARVlEZEflsLElwygKBOMQACIKLZOoIYK6UTkxJdSiikSpJEcCmBlEJhggHAGqFcSsYFJBBjpKAQAmAAEQCMMYwpRBghBJSSXEkkEcIKCCEBhFDTNCkEFwxBpLhIMSUQQF3TUyE1TSNUk0JijL+NEYwxDhBBBGMEIJKSQyQVBEwpzniYRFxICQFnXMiUQWWYBiYYQKQTDWKq6wYmkBBKdCqYAhAhSJlQQKqUwyRKCQQQUiBBmqYIIkyoFJwDCKQEEAiepqlg/NtOUcS4ggoDBQCUpmYqBTEhUjElJUtFnMgkTaWSlBAKMQaICRELEacySVPLdJFUlqFjDEyTYgWVohQjqCQCgEsVBCkmRAHJYkGgRECUXLtRzZaLNkICKIgIzuWtomNGceB5sZIiZbw3HHlxyiW0dE0j3EQg47hcyeF4OvJiIAjgIIi5QFAgFqdJkso4FRxAScjIC1IhCCGOY1NKgjBOkwhjpGsYIDQK4jBKCUTVXAYyHiVpysNipcpTfnnVKpYy1ZITeCPTKb18vtvpTYRUho5NC2bzeUM3PT8cTZI4TSUi49G0Uiu4jn7r+nKpmD07uwjDpNKoZvMOADz0IgPp5xcXw0F/fWOtXC1/9sVXO0cn19ZX3nzjOkHCD8KM4yggA38CcDIzN5/L5y7Or44uB0eX3Xa3Y+hkYbl+/eaGH6fb23uL87Prm1vTaXhycjUeRTrEjXJhabZWyJsJF0LK8bDt+31AVLsfvz4dnrUTAUij5s6XnbKBTZhMx8PJZMpj763rlTfvrNuZ7Oujy2cng6upHEzx9tFVxMHGLHl30/n1N4obTRcA0BmLwWBCdQ1bLoYAqdQxsQQqCOTcfF5H+PC8HwgiE+m4GdvOhAoXM6BZz1AIr9rxzuu25pBqzZx60c5RD5moWZtR4XQ0nZAMLtWaZ4fnWIBKMct4WGkYAuonF51c1s45eq83UGbhtBumAjgGdjQiZGw7pgDIpEiG0+bsXJCK6WRab+RyGSuOIz8CpydnC81SvuCenFxUZ2ZfPn9+vHf13rtvPXjwyMzkHKd4drhfzOWbM5XZZhMCQYjSNNSfDgVAnh9tbG1dnJxHUXR9bevw6DCIQlND/X77N37z1+M03T84vHnvzr237h6fHHda/eXl5edPn7/Yfjm7MPvf/tf/5PlXX6ZxsrFx61//y3/39ttb9+/f1DVbCjWdRt/9zgcvXr+Yn5158uyVZWdqVXdpsVos56Ikbg2il3ut7iQdnXX/y99/2zGQZWpzKwuOht99987PP/7PgZcsLc/H/nBpvtRsLpy1+s9eXQYpdnUiUvXXP/s8WzS3Nq49+Prnbt7J5HM/emtDk+Enn39z/VrOyea+/PxLAFh/5LfbXYW1VOnZQi6IAs7SbMYRMZuMQ4SAZaibN9fefON+v9/SYLK6MAsQmo4n5ZJ96/oqENLMFhpzc2kYdSfi9WE7TFIG0zSJGEvGg9FX33zdbfct3XJyOd3MzcwtHu4fti6HDOvdSXx+1RKSUUXCiGkWvn5jM1/OY93oT6Onu/vjkFFqEAS3Vmfv3t74+KvXf/v5y4hpmBoQwWymAEHiGmhlaeba5mqtUQu89PRy2BuO5xcWtlaXkGm1262Tk2MFU40QfzqVSjarTSlRxJJ6s2ra1r/5D3/27/7Dz758fNQeQz9SZ4No/2p4cjHSqI6loIjv7x+0ev1i1nrj2krWVCyeTgeD/uX5jc3mr//w/aVmuZgvZjKZWIqLswvHoqvzs3lXQxAMp5HnhQtzzUo5Oz9Te+f9d968fwsB7dn2QXsUdYb+VX90fHF5eXaBqZbJZKIgjYUMozQIfD+MlBTValVILrlARFFNS1I+mU6yblZx5UfB3FxjbXWWAOmNxzxOvSBaWJnf2lyulctSME3Xao1Z28pEAmzvXOwdX1RrFYXJq70jSOn62uL6bF0z9NgfrSw25pu1e9c3luYqS7OlRqPSHo72Trrbx1f5SrVUaw7HSZrCTKZUK7mmTuNk4vt+lCQL881a0WQs8KKk1feiaVAtFU3bPDo75RBmco6QMEgT27Q0nbiOXs1ZOdtGEEGqT6Jk/+jcMu2Vtc3js9Z0MiE6na0XNMVmKu7mxsJgNOAc9kZ9A6qN6+tffbn37OVTPZdVqcrni61O8OR1C8SyOx4UipmlZm2h7uQcNInTw67f91i31auVa+Op9/LkVDOLSiGDyg/ub0w6XUqwQakQARO82x8enbV+78e/VslYh9uv1lfWdd0AUIZhGKacT7v337udchJPJjvbr3O1ppvRX+8efPjuOwQxPxQ7OwfNsvujD+d+8rOfHRwMszm7WCh883J/6keFQu73fu+3Hzzu/tlff/7rv/bbh3uHf/NXf7W6cf3rJ6/b5+252VlTI3/6l7/ERP7Jn/zeta3mq53uT3725fPXJzqm9Uo5EmHGMUwDVctlTmEQcYXM/+lf/WWn3/7NH/0awfLzL34ZJGkY+4sztY3N+tnF1d271/1APX/+Ymt9A3B+a3M1m3PbnVYQBFLRSRCfX3UoJIAnGlKlvOUx0O1NiC77/YHgUiBazGU03SA6RZAP+8NmZQ5iiBDIOrprYiAS4pQFQGGcFPNZB4K9s6iQc6oFNw59O6OHqdjZa2eyVpQGcRDUi+7drfl37hfPLtkvvn55PkwXyka54E4DgJA202iEoer2x4MQKt2S1OKKti6Gfiy5ZOPphOAUEKJpZBIEnXavUDRtm9aLxrVrc5hSU8u9Ohw83zmFul2uF4uVTNai+ULp9e7J33zyoOiYf/8Pf/Tdd+ZOjo+ZFLmsE04HENByvY4IoTrYuLaYxvHz14eTIHUsp5jP2U5mOvKOz9tMIInweNIfD3r5QkYppRu6rutx6HGBW6fnlXJhtl4+O20FMT86vugPJpVaqd8/xzzNFvIRR2eXrcl4ePf60o35XDGjn3bkk52jqZ8E0yDhggsJWZw3YTlncJ4wCZREQcz7fuzFSkJMMEToW4I3MkxDCY4AphRAABKuBNL9VF71J8OQSQQVx1BCwQXVlIlJ3iLFjGES5JqGTjFjKaIYQxjHSZJC20CmYTiOnSRiGnKCUMamWYcAxbCCUNNCLofTeDQOocAmNSCQlIKsYRIEFMSxVF6UcikhAARjqZAkSDDu6LqtY1vTDEKglCJRumtjrEVRKhiHWBFMo2k0DuNYiDhOEVS5rM2VpFjYDgVASCkRQmEaCiaFAGHMJokQSjiGmc85BKps1sjoGiWAAK4o9rx04kccyDhJeCogRkmUSMgxgFJJUzcwQgBTqXgUxUJhnWpIQ1IpKSWQQEglFRJCISUZE5wnUkHGUgWUBpECAGFNAiGUVFAoDBCBEAGghJIcCpHEKUEaF0pJpSBUMpVSAIQhgixNABA6IkAoqBDFECGgAMMYQISQFBAIzFMhBYMYCJbGYQiVhAgrCAVXUikEqZQSEy3wPSWAkggBmDBOMVIIaZQipZRUACFIMESKcY7Rt+qBmBLCOVMApGkqOSAaFpIpKQglEZNEw0SjCRcIKl3HGCEJoJJKAKhSLiUTnEkIORcCAKppEBEEEUJAp9QgBuMcIoARIgRpOkWmZlgEQEUxAIgDqPwwVABmXceixNQQRgIjaDqGoWtACUoJ4UJApQhmEvQmkyAMFYCcJ4ZJXMMo5zIzlWIlkyWIMi45F4Zu2K4toEwTBhHiEiJIENYwoQhDhAQCCFHcG4/aI38aQ4BoKoEfR7pBdKrHAUtjTglVEgCAo1RiILK2WbRdqpRSqWWSUiHj2lYUqLGfBqmwHCefdxSLqI5tw0gV6Q0GV1cXSyvzd25v9XpdgOjJ+cVgOKzPNCAQjm0DCbmQz3YOzjuTKBi7dhZKqjtZU7MUELqO+p1WkKTlSr1SzDmWFsWxVHDghUkYzMzNaxS/ePUSQvbW/TcW5uYQAr3BIObxt1NqWS7gZDKZHhyfTb1oNBwrzpYXmps3VnI5Z39vv9O+aDQqkJAXzx+3Lq80UysWirVqQ7dzl53u8e5RMOpqOndz2Wq9mQDzcuD3PYEk31gtNAuGhjwLB4KNqyX7xrXl77y/Uam5p2cnn33x+MnrsDPGAw+ORiNK4fWFzOp8cW22VnKc4/2dLx4evm75WAE7X5ACRnHiEEUQi8KQmtDS6PPd/ck4NCyay9vMG46uenkNri7W4shvdQbQpLWlBYizAKDQn/p+nM8VWBCIIOVRsDI/3+10h2M0u1BLvJEB0rzunF2eIYTy1ZI39tI4Mc2M5/uFoqlpcOxNwjjhEivFDIodXVcKdnpjXTeSYCoZY5JTSgp5a+v66vNHzwjVeKoefPP87/z+D5QU2Zxx9633fvrpl63RdLZZ1hFJBPe9+K233x+PhyYmpVJeKeB7wYsnL1eXV7rjLlHizTfe7HWHczOzXOHHj57ki/n1lcUvv/p6f2d7ZW11MBi0O907d+6++c79s8P97YPt+2+/tb/z3Bt1f/d3fzzwwour7uNXJ2+9dXPUH6WJyBSyLJ26WTeOvLlmtXV2SCi+aA0Bommazs0XLCu1Nbq+df3i/KRSyhWyjqEbv/obv/nwyYu9/d5HH7z7+PGLZ48uGs15RGWzWZuM+sE0Wd/aen2wv764XLCzo16v1pzZfvGNbYBaY/7k9NwytMXFWcFFaWY+P7to6na/N6qX84uLM0QFS3X63vu31jbmXYMW8oUnz1+MJuM/+qM/xFAe7J0dHl7dvXVDN8D2zku3lAmj4Ozo5TQWo0laLTjXNxcoRXamZGeK1UpjpjGLqHzw+dfeeHzRHR9djL0YZtzs4srq7Tv3HcOFmBg6FYmI/elVu9/udGcWVgjN6npGSZWEk2DSFukwEQARksk4ke/ZpsnTMJ+xrl1fmvRar189a12eTpOIYHRyfra7+6rbPiaWXS2W11aW51fmIMG64caRaLfPc7ki4OL45KxUmVta3yo155fXbhh2RqfGjfUt27CKpUKtUYMaKjbm127c9ANumGalkss6tk50x7VLlUrWNXf2D1682k7DQHGWLxTGfvD06dPRcAglOjrYVSIuutgfDAPfm/rBJ598srv9WkHpOlaaBozHXCgA0FmrO7zqE6xy+YzrZgGk7f7QybilUt0xzSgIR6OB6zilUqlWLS3Nz0KImEJSKG/imRrd2rp27dYbxWKJKKGLtJQx7r779q1btzNOZjzudnotzvkwZFQ3C9XK6srCGzfvOLbTbV8pou68db9YynbOd5OgFXgDmPpp0DNJkinkgjgZdacTP+z3xp3B5PD0ahrGxaJTLtnVcjGbK8SpGE2nlWJ+fX4+6zhAQUVor9+zdFLIO+NhP5svaRRDhUZTLwoi13EAZPNLdcMA00lYzOUX55qf/OIXrpPbvH5tNPRTP62VndX5mibDomvqls2EVqlU/emwWSrcu7NwfjouOfZ4NOqcXb39zpsQAkJtolDsTymYrjRLWR0X8plEqChV2HZixn7jxz9GnL54/HUijVBkri/kayV9Mp0ialJI37h2M0pkp93+/PNffPDB3Waj5FiGbWtpnFx2u2EU+sE05+aaZef23c3xYNxvt6r1eqs18Cej9a2bhdLMabt7uPuiWXHihP7tV4ftdn84iTc3V188f65jsP3q8B//F7/59S8ftc5P/7v/6z8sZK0HDx5YbvVPf/KZkS3aplZqzvxv//JfhaPh+x+8ffPWUr83tCzzqy8/29nedfWcgij0PMsyFmbm+iN/beu6nSlv7xy2LvbvvfWO6xb394/ODvZLpQq1SpedNkTiw7cXnz3bkYQQwwJA3t1aFGFkGXZ/4lluMY4C23EGo+jq9CBbLObzhlBgOqVSOpbpnJ93hsNJplDo9NrzC7MZB8fRGCNtNPIQEKVcRiomYh9TfHgyShJ/vgw8Bh/tXNBMqVzLKxYCSAUxQuiUcuXYDy6Oj06P9mfKlXs3yqZFT6/GvYsTABCX8NnekWvBpTqZjuPhNNx53dm77Ke6Zlcro8kYYlcEwUy9mgWifd6zMsWFzQ1HM1DYDQcXQTBsDwbIdkqFwmg4evLw5evtk5ibllOQgs+tXadY+8Vnnx68fri1tnT3jdumTpcWZyHwW5dnbiFXLBf7nbZNwN3b68DMe57fbp0TQoI4LNTqXuDvHe6PBkGp3IziYDDopkl4dnJhmka2mI0Z6o0nUTpdWpnLZ83YizCl/cGk3w85A0AyU4O6RgExDs6uWt3+Us18+81rhbzVH6uhHzKA+tP4sj3lQrqO6Zi6Y9uQgDhhEkrB1TQIPS+xiFHM2jqBQeSlHDBAOOeSKQQhlhxI4Vi6kiiNZRBLSaAkECNAIMYUaBBhoSiSGoZZ23ZNHSkJkMKUcqH8OLI05OgYYRUlLIxTTYMuAjoAECGgkFRI6taUSS8IlWIaJRrCAEpd1zVMsIRM4kjClMuUi1QIFnzLYsEaBgQx24EZR0eQR75vODqkyOcpBMTQiGvbUHHGBMI4SZMwDA3bwkoSKQyCDZMirExNg1CKVABMxp7HBBpPJ0nETEMDLNUJ1pXUNEMJwZSIEhULQAw7StMkTkzTVIhCBB3DxEhxIXicSgkpwkJJqQCBkKdSAIAwJBjGSSShgphwwRQkVCMAQAgQQCBNYgyVTqkSCiMqpQRKSSkZlxo1BEYAKAAkpRRhqCBEkEIEgVRAAmrQb49kJeBSKc6FVAgoKCWECiAFJBOCUAohEqlEWJcKpOLbjxL1LesHECylTFNhWhnGGYRIQYABEEBKIIWCEGMEMQJYSKVSKYTiCQMQAQgVF99axoSQACEgAQJICs4ThgFCCgAhEVAAA6EgAIolScoEhUhBIAAEAOlUoxqRArKUY0IoRAhBTdMVhEIpADlG0NUtomHFE8iBgTVTM81vabSIKgUhVgAyw7QwAYZlEoCFYBpAVNegQZMEBqEYBUJBSgjRoMxYejFn5PNWJedCIGOWTqZekiQGga5FicJAYJ0SQ6MEIS8KeJpYFtUJxFAACNv9cXfKxpMkTOI4ZQmLMaUpZ2GSAgAghgByTdekZJSCat52LYKpRATahJYdy3XMhKtRLIYeM5AG0lAmscJQJ0YUhdMJmI4Dx9aur88OhsMkBWmKupetZr1uIGkSIlKRRGr34KLTDSchLjl0ZraRcjgYBK3WxdxME2J9ErCsYyEqwsAfD0M/iNI0IURVSoXQ59sv91wL39xat6gK0uDsvHN50XYoNUxiWVa7O9rbPx+Ng4zrcJbW6/Zbb1ybn58ZdnsHBxecy3q5hBBot7pAQdvSLM0gmrV32vvq6X43IutL9vtvbeZdw7TyuyfTLx5dXgwYBWK+rJccwVJvdaW5uTJ3c6WysT4rIPj66+3nzy72juKDNgAaSQFjKUMIViy6uqDPV0sXLf8/fXr2YA8AAYoumKtXk4idXoyxScr1CuBJsWDMVApTL1QcZgsGSTkWIojE+oL+/s0KNHPnJ+MkYLbpvD48v+qMlEIQq1xeQ5AFcWTbWjkDLSxeve4q0846udZggAmCMDQIbjYbAOLRcLK4sNS56oZ+ijFmAhCNRpEyMSlkLSXBOExGUw8CZRq6TkyeprZBKzWrnHOH3R6CIJvJHh8dlBvVYrn29def/pN/9F98/LOPf/nlydrypmNZQRz+v//H/+yU6radOzs7m12YNQwNKM45t4kehUkYxpu3rrW652N/PLOw+pO/+s/Li/MfvPfWsNd//OD5m2++AyAPkth2ch/9yq/sPN/57POvW+ejJILd9uV779yUIjk7n/zTf/6f8nn93vXrX/3ys5XltYuT07dur/X7HtTtYmXm8bM9jGkxZ2RsMFezfvTDd+dnGr6fnl/1Ap+VS+5w3N5YW56O+b//D1+89fb1Vy9fvX69+/t/77tzsxXEVL4y+/Wjh1kLbMwWs6a2vLm1vbNfqc5+8eLkxeFgtlmHwnMc/NbbNygQrkUgod2rwfPtV9dWZ7/30XvPHz3IOPijD99c2Vzots90ivb29i7OT996+52T07Pji7OZYqZSdsM43X61X8hkylkLA54rFOMgLhfwu29dsw3hOLDdunjy5OllZ8g4iiaeRhTWM7/44sXpWPXjmCUhV6DdGXUmvu24+WJW15GuudlCUwCrMxiPPM9wHIQRJbxRzViUz2TJzfVmrVbCBGRcODubK+XdcOq5rra0uEQRCgeTRsl+9421Ss4ol8vSD3YODh4/e9m5mkQJSjiP02DqJZPJBOl2tzf54qtHx8fdgRc9337R6bVtmmYdqeIhgcnJ+fnjFycvds/a/SRl2t7p5WVvyCRJFDSdYiqMV4ed9kAWyzNmJisVOtg9iFliWlnPj3vjoFqr/uD96+W8fXF53ur5mpEhEMaRr1O8uFBbW6ovNgp5l3KeapRgKgrFPELk08++GIwGs43Z/mD0aPv10fE+IcDQsG1YYeDFgS8kn4aRBBAZmWng//Lx6z/9yRePtw+GI49COQ4mP//q5cd/+0sGtcZMg7EwjSLbsh0dCMijmHf7AyX99cV5SMnjZzuffvIomyssLc7M1GsECkPH5aJjQaTiqW3iWjNv2WahkCvms2bGUEASQBFSMknG40l7GF72JueXbYCicgY3alnXzWJiAqTymWy5UJx4vuXYuuMahh4HkWFoCBLfmy7OzUoe+uF0frYy36g8f/YEU31xecmfDhgXSCPZrCslyzqZ8/OTTDZDdfj84dezjfKtawsEye9+9804GJyfbK+sLAyH42K+cH1jpX1yeHF+mnGyGQMXbGPS7ei6cXE1uDw9/6//+HtKJD/9+ZffHCSTUf/O7bVGxYU8ffz0Zbk5d2Ntpmgpg2o//ZufZwvZjEtMHdebzWKumClkv3p45MXh5uaKpZGZ+SaXDEvV77W++PpFLICbt5fnqzbV8k6GCbB71tZz+acvX71x/fp05IWK7hy/KOXp/+2/+d2/+Otf7O2f/OZv/9bKXH1n9+JiDB8+3LYoee+t64uz5X/xb/+joWd+9XsfjvudUtZeXFw/PrnY39+j1CBEjb1ptVxwDJgm0zt3bhweHgUe29t9PTs/c//um73xcDCZxApdXgzbF6cfvHd/pm4OB/40DB49f9ksVokGPc9HxJ6EkaHTYta0Le28lSpESqZNpWx1vcsR1DP5Zk5sH4yYBIPRNGUsnzUNHZUy9lW3PwoSaulpAk3TXF2d830Ue4HlmIiqozH6s2/OWyNeLRcyBm93o7PLTibrLswX8ll7Mon2dvcREBtzsyuNDIdxJFPd0TSij4Z+tZJdmLWzljZTJ/2R+OT58CzQoFP3gvj8sm8Yub//d96tVPVPvzoe+KgyO/P221tzM+WxF7c70XASlIv29aXaXBVftpJvHu+/POoBhIFg2Zy70FwIRvGjly8vrnpezGrF3A9++J2cbTz+5nWr23fc/GAwGQxHtk4oBqV8FkMoEg5FtLk6W8m7+/snvpfWanUgWKVRyeasy4uLbDZXb5R8z2tdDqYBL1bq1ERACELwYDi96k4gMTGQxayhawTr1l5rund4Xi7Y795ulGw+nQaTBHKJU4WH0zAVihBdAgwB1gyjkM3rlFKMBWcxY4ZmZm0bKjDxPT+RsQSJQkJBpCCFgGKazRimCZkCoR8yxsOYTdNYKKyA0kzMgUhZgrA0LU0DAEuJIACApRxOPR9KZRuWAjhIEwRJJuvYjpbTKJSxgioMY6GggBAiLIXEEDOWAiAAYJqFkVLDSTgOWJyohKWJ4KmSTApNIw6BBCmLao1ywcAgjSKECIa6FycII4KURjUpUZwqJqAfJixR2NARwJpBdCgpUhZBrq0bFpZMhEHUnQaRgAlLKdGVVAlITUKxDgkEAOAgScaTII4lE1JJEgUxYBIIIDFmTCisqKFplKacMa54KhkHAEGsCMCaFABiKlIWxAFCBGMcx4kCSggZp1xhpABgUgAMMFJQIsEFQgRBEEWRFBJrREgIkEoZR1DplgkZ54IBJIFUSqqUJQhjCaSAECPEuIBIAYIQAohQpJSQEHxr9yJEY0nKGUcASiUlB7EfKCkRhkAyjIFUKuFcSYkxRYQCoRQELEm5EEopgjDECCAEEVIAMQi5lBhghCFBMBECYEUIBer/ZAlRjQIIGBdCcIGghBhCwIVAAFCixXHqBSFCmGIkFQQKYgMpqGKW+KFHdKQRS0kVJWkSxkohJiUCGGMMEQn81PNDwRGCACGgJE/jxPMjP42TiAdCTqbRyXlrHARRlCZRlDKu61ouY2SyRjmfMynCkIdRlDJGKMEYEBMZGlWYSRBBiFiSJnGcpBwopLjACEmBB0EaRMLzecK5wogJrhOqFGKJECI1TN3QDaVAwmIMZVY3HFOXXMQJExADjJiA7e7kvN3rDccQSTujO66hG9TUrJiJ01ZHSZjPmhsrjXg6iOKpk8tOp361UsoV8iINECZX3cllbzoYBgAjjSK3mPe94PTkzAtGKxurzWbDG3sapZVKyZuM+iOPA8TTlFJcr9X6o8n+8e7G9Y0bt25PwnFn2Ium8TRKlpdXi7VGHEbeeBr6vp2zK5XCZOxLAZZnq7ms1e/2Xh2cAIxmZua9kPf7ngZhpT7r5EtjPz2+6B6edINYvHn3+jvvvNXu9l5vnx6ftE7PuqYp72zV37u/uLg2iyEo5V1i5/xEXPXjj3+5++nX+042b1UWLj0RJZACkdNgo2g1y/pc3Wjkiof7Z8/3OsC28gVzbbm8NVcGiI26PTeDy6XmaDRNk9REMolDhLGVcUXCSpXi0nK9XLXtbG4y8J693OMY5sul3tDrj0Rzbh5CZOrYggohCgjiVNZrtfGoRyBoLM61u2empiFKdaQXM3rgjQb9rgQiEaDXmZgGDmMWpiKMGEEoXyhPx+OUA2obLIoLWdvSaDZfanX62EDNUlEyfnXRrVTrk/HYMI2bd+4/ev14dWXJG109+uzzd9+t1RebbrHy8tV+s2H99u/+xtNnj23Xmp2ZbXXbjmMf7+3ZOSMMp41as9vpTqfe/bffOXx91OkPG41SIe/u7h+Uq7NxmB7sHvS7HSHgl59/cnKwV8qWM26x3+8ury7Pzs/4XjLoRtMQ/P0//N2d/RflciEIkkK1MfT5L7589uPf+YOD4/NGs5Z4wx//+Pt3bq7ev7tczFr1ZuMXX3zRbDQogghBDekWxo9fP/itH92Yn89TKv/kn/xBxsRHuweabp1fXvlTb2O1WMwTQ6P/4//8757vXeUL7jCEuUojl8+l09G0P9zb3ZNKCQlaJyeh17m7Nfud9++fnpwnaXr/3huDzvHOs8dz1cYPvv+rZdddW2lE4fj5k+fNxvzyxtzi3Nyr3YOd/Ytiuby0MJPLZaceS4L+vTeuEYLjMMy4hdnZhVy+UC4UDo9PBTCXlm+22xMvAUkKkxRyLi6vLl/u742n0e7BAZBqpjm/f3bx+Nmry8Fk7PueP5oOLgoZfPPWjZu37mBqUA1RXZeCJd6onKUz1QLW4MXVgAMrk8vfv//Gb3z/7t3NuXyGumbGtAr58ryby+cKFTtXCpMpY/HtWzeb87Nh4HXbl5sbG7VaczIZ6ojdvbmxuTTjZPNxwlfWNkzdrTfnqvXq3v7p0xc7552BlZ8ZjtjuwfHuq+3DvafnF8evd8+jMA6iKPBDSohp242Zha2tzTgJ/cCTPAm8kYbYrbs3dcdNlLSd4r0332g0Z6Iw1JCGCUAINhtVS6cqTTHU67MLAhEIETWgqeuz9XqjOb+wvHbVHflh0qzVY39gEVYpuKNhf9Lr1+sLWzdvCcNKoZkoGsTijXsfLi4tHne9x0+3L1ttx7Sbc81ysTDbaGJEO1cdzlQcTEb9LpC42FwxKPnqm680zazVZrBmc2RoVmHt2uoHb7+5sTZvYDWdBClPmWCSSaCZCIM4jDACeTeTsRyMiGk6SsGC6xQcIwj9q1ar1/MlIH7Mj87652eX3c6Q6Fa2WBhPhrap9/sjloq37t2mWHW6rc2tdYDB/uvtxPfzxYLk8Xg0INhMOIeI5V1y+HqnUCoLKbzJcKZRuDjeN6B67/27BFOv1zkZxK/3dm2dVGqNwSTI5POuY5okaVRdSpFhm18/ery6vnr7xnKUiIv+qNsLsk725urC1tYSIeTzX/z05tYGgfGv/PAjqFt/+he/rDSXEFKcBZWcY2IVAPf19nbGBpft892jfdOpl0vl9bXF7tDLV+pPHj61iGa6bnlm/tqNlVZ7eHDQ2lhePjk46A/GDx8+/kd//Mc/+clfWq77j/7kH3768TfbDx8sNKsffnR7Oh7+4pNfKmCcbm//8Ie/Xi9Vf/n5lyJN37t/469/+pOVteUbd65JAA72Dt1M5fK0bVnG2uoM8weNvAZ52p8Mmo353ecvvNGwkCkHfjpfr0OgHjx4dXramV9cZ+l4bXnrsu0PvfFitdg6O7WyrhclaSwFC5BKA4W8MJ5rZHIOUQicD7uTOKpltEhCzmGlWOx2WxrNsDjJZU1XJ8NBMvUSBmUSxYvz5VpRDofjamPOJQRTxDT8cn/U7oelWmV2Jnd5Hjx6sR8lsjIzt7K+ZLpG4MfnpycaEIVCNWdlizZFMIiSJA6FbWWICgoZ+1c/um4h+dU3F1+9OD8fMWaV/t1Pv3n1cvf7P/z+zc3SV7948dkvvjlvRTfu395Yns9k7ek0ZgwkkX/t+hs//NEHzXrl5Ohy99UeVdwiQNFYy7hL8+sLizP97sW//U8fe9Pw/Q/e3txcmvreVadnGC6khhCCYJNBHMRJvpDTMNQJunZ9a2Nj9ejkZOrFhVx5PJzYVgYR2u30C8XM3FxTKTHuD1mUSCUhhK7rIoove+OL3oQLRaHcmK9Wim7gBQMfnF+dbzbzb67X6gVT8EgKbmhGlJDuMOBQytRPIy+M/aE/jaUCShq2GaV87E11KmcqRVM3OecpEwJggkjKWBAGCWOCCZMoqWCUKiEApkQxNY2iOOFKKsd2ddOM48SfRpoGbcdwdWoQqFM9lWDiJykDCKMo4N3xRNOMjKnbLqkUMzlb1ymASERx6jMAAIdQEYhlyjRITKI7pgGkjNOEQcw44grEERtPg4gLQTWFgYSCYlXIWJYOBYuZVIEXpYyZpq5TZEDFeCoZBJBMAj8OU64AVlLTaSGjO6YpRGppumtqGtXDiA39IGYqCVPHsQ2qQY0ozhUUAACEiBfw7mA68eMUAj+J/CgY+dMgDoRSkCsFsCK6kgoCCRDABAMhgJJQMYigElLXDQygUkJIlkiRpEwKjiEiGk4TgQCUAkYJTwWXUnLOxLceMQBkyiEEacLJt1x+wYUClqkTQBUACkECJIHIpJpBCZcSKAAk5ClHEEGD6EohoOC3gUNJpRkWwAphIpRikhNKMSFSAgalQgRjohNdQZxEEeSSAyGYQAhpmqYRwpUkAFNMAYAIIp0SJYQESkkI5LeRQioFMEVAQcN0mBAQ6RAAKYESyKCaAkhCxRVmTFDdJJrGhVAAKAWZSBVHIhFMKUw0IIAAQELJ0sS0DYJFzqaIcoXlxAvGfgQoFljGMY9iISTmgGANIYlTDlIuYs6YAJRShACiSNdpPmNSjLKuCZQAEGiGlslaCFPLNEzdIEpKxVjEICAC8CBhAZMKQoFQAmHM4ciLoIAKE9fSbNO0Cc06hiSYcw6Uciw7FVxBqetWmjCikG3gaSiCNFFIIa48n/WDuOdFYcQzNp2v5uZKOQqxSHkUJJ3BJBaOrceVSi7nulyAci4PWEKRskxzMhoEoegNvXZ3MvJZZ+RpGrpzfcaw69uv9yHl77xzb2Ntud9thTxyc24YJYP+JO86toEMyzA0s9/3zi669+5szNXqeweHUy8FkgiRbi4vWK6193p3OAwgNg0DLi/WMRCYwsrsnFKwN5g+297JO9mFZsMfjXvtHgQYSoCAbHe9k6tpkMhaWf+Vd2fevlbsdCZHZz0tX0kkqFes3/hw8717s67j9gfB7tHQD4NoPNk5m/7s2eSyx24u5aap/uDJ7nnL14kqFs1m3SqXaCFrFfKZ1nD8+nKaSmgTWMljCtRld3jWGpdq5WLOHU+6EvBmo8AFz9iOQMZlZ6qsQm8w6JyeFCzSavdfnEzDVEEre3DZG055s2YFo67ioakbSgKVRmEQuU42ULTdCWtFWrM5Z8LJ2dNJTG0njKVSyHKyiGiCyxQAathJwinRvDBxXWqoSXcwzmZd29AsE3IeWbZx1buwLC3vOEAkQeArXW8Phgij+UYJpmMUTZYW1h++uJhbanz/nXeWqoVPP/ni+GzwX/3j3z3ZfjIZXG5eux4nQnEpksTJ5XVLszIlahlnBwcLzebl+dWr/Z3V+aXZZuPy/PJgb391ce7k7Gw8jpME9Af9pcX52blGc77hRYNC2SmVi53WVeTFx2fH9bqDdEgQCKIoiZRbXfpn/+JvPrpfb1YzURRCAbr9YamUe/j4ueGWyrXq2dGJQjJfrhTz9p1b120ThVzc21jcWl/KG+5cufbxz3/+8Pk+yhdPBj5F4Pra4trSUhBGX3zzQkG0ubnc7XUQhpNhj6hkoVGxKFtf3nDc0snp8dZy4Q9+832n4D5/tbezs/2r33nz5atXuxcJUwaxzIdPHo2mre//4PvZjJnL2ZZtPXj47OXr/fn5uUrRMXS0e3BxetEdBsnG6lK+WOz1J51uNJmkrXYrl3EkAN2r9nDKH7w8uRxPDUoyFGQd05M067ilrJ1zteWVNUic18enmWxeQY0B7Jr5hXLp3rXZuWYxmE6n02QasN3zyen5cNDrLMzNLC2vQ2gMe/5kMnq9t3d5fuaNejwcIwKnYeol0WQwHAwudcOmht5pH7oGaTaara7X6k9W1ha+88Hb1VIu61qLc43bWxvXN1ZNgxyetP/606d/9tOHAy+oFDM519haX3rr7beXllb6vWGn12vUa++/8+aNreUbK3Pv3Fre3NoIEj+Igs3NG6tr187PT8NoujhbX1loVPIFpJmam+/3+46h99ujZyeXF6edvf2DF68Ot49ar0/7RNOqWX257t65s0k08vWj50HApIIYiMV62dXQ2VX79eFVIpGAwB/3VxYatWqpnLXrWbKxWgUwubq8cKhecE07kxkE8mc//avuODm+GnW9SZLGacpYKjmAczVrsWZnTNhoNgq1xc4w2Nk9nYzGt7eqtXzu6Yujk854EoHBwD87PX/+fPvk6GA8nOiGlS1kmYBu1gYQnp61PH9qmC6TDKjE0lW9lLNdHUB0dNneO71sD4aFUsOPxd5JqzUMUgk4saZBOvH82YWZrWubXuDFaTqNYs3Q5maq3jh68Oz13Oxcs1Fqt64ktPP5qkFxLHxL12bKtR9//7t3biyqJKnWF3K5wpt3lmcr+s9//qnhNG7fvj8edGwnc3g+PDg9K5fz3fakN5hEKQoSCKWCXNy+uR6Eyb/9N//RyVUbVfv6SuFPPzvoDZNatZCl4L333zQMfWf3+c0bN7tXV/nSzPlI/vlffVZvLBQdy7ZpNutSt9E6u5opucuLC8+2g2EcGjZdXpzRNTCeeMsrS4PxWGH6+NnTjaXm/+P/8tHx6bmVzSxsrEz8sH3Z1s3s3bfe/R/+2b8KErmwshAptLt7OFtx/+Qf/IBCsH06kYpdXpyu31yfWWh89vnnt998a25h6XD/oFZuIMSLxQJQcDTx9/eP3753X/HQ90ar61uXl71Kyd7cWsAGOG23Xu28Xqpbd68vHF5GT/Zbup2NgqmKxvmM83L/IlcvQygLxZof4eNWt1qpSoAkQNv7HQZQNotrVcIjNWiPkGaWS5le+zyXsRUisUT90dS0jeW5cr1IETaUZly0zo3UX6nR/eNoJ8jcXioVJC9iaWW006vwsu2XstatrSpn/NH2YGfnkMq4WTarWajpKmIxAKpWwguNrDfC+6fjAOAk9QEXvZGHJf+tH27+ym1dY2rvwN89mXoR+j9+fvbFp49/8P7t/+qPbgQR+Pc/Ofziqz2qIVdj4WTSHU4e7Pb/9qsnvTiZmZl79+03yvWKF4xNC5ayxeHIe/HimavhH//4h/Wq/cnnj867rYX50u1ra6V8oT3oKq6W6jmleKs96veGgiUEoXZ7eHh45rh2o147PDxMOa8U82EwNXRdprDXuqw2mrph6IQZpjAt97I9iZJkY3HGoarf7rQH8cFlXwJ2c3V2tWmOQ95uTU+vevV6+c0bc8slzaEcKS6wimMGhCxmHNfSIUTdQTgJoA6gpaGsY2CVprEwKa46Zt6hijMMFIZcpziKVd+Lpkx5sRSMabomlIIYa0QTkeAIQ0i+deMmCQSYWKaDVSpEKokGoFISJSoNeSoFgQTzRPTHUwmoTlDe0komyWg6xTRIUj9WOtEsUyvkbNOgQkqCpK2DasnWdCON0ojzhIuEAyFBkkg/YoDjOGZ+xDHRLF0vFhzHUFjD4yCWEOVcl2JA0beP0ZLFIk5SKaBSyCBEg8Q0oYkpT+KC6+bzWaUEFzBOeZhGSEmLEk3HlOoQmwoIk1LDwkIyqHASxzqlmmZqmk6RhhRSTKVJoliqadiiGoI4YYwpySTnqUpTSXQtERJCBBUhUNOITjXCFVZQSiakZApiihGXQnJBMSWIIggoQgAogAmCUKcYYsI4C1kqpOJMKigJxVQphClAiAkhpSQQQAilkghRBJVKOYNKIIQhJJDgVHCgFASEMYYRRgApAJkESRoxJkScAikY4wopSnUBgIYoQEApIJVkLMEICSWEAkpKAAFnEgAEAaQaQQhiTaOQAKU4U1LxNEmVAGkcYIChhEBwARQiiFAKEWCMQQglk1zINJUCSiWlQEJSBBXGQBEMKJIYAtPSNUQtK5tIlDLZHkyCmKVCxExApRhPlcRhnMapilIRRpFAIPSTMAgz2ZxGqOIsY1mlrOZQqBs4DeIoSZVUfhJLJjEWhoYMgrBmCgEABGGUTEaBF0RjP4wlYEyCVHIpOUBcQZ6mBEmdQoxBFPNw6kMFbNtBQBgEA6CiMDQMzXR0qBSXQsO6ToyQ8SiJIyYR0XJuvlLK5XPmYDweTSdMIgWlZKlGUDHjlAuFIAks2xh7fhRLzdSn3kgynstm4wjEUhuOQ0s31pcWoZBHx6fFYu7WrbVK1h70R+eXlyyKCxmH87iQzWXzec22WZqOppOTi5PZ2WalXOr2ezxhJiGaoTXrjdFk/PzZ8zCNZ2ablkGq5QYhhpSIJenh3t4oYL1+v1IszjXner3x5eUlwSqTcdxc6bIdPHtxPp5Ma0X9g/c233/r9mjQ297ZU5B643ESjO/dmltdzPXOz549fPxy52x2trm4fOfwfPR0Z2CY9NrmHIvCo8s+xLBZ1TdXZmtZK4NZ2VEZTQGm9o86HMC862ZNZRAx9v1Y0HzOQVJMxl4cp4VsJgo9DSKlGzunvRjQYDgwZdJoNvwUeinEph4Drd0bcwEr1VzCVKfTL+SzGkHYyQ6iyKSgkctMg2QQQ2BQnQ3LjSaLYiCFbWleGOhEJ4pjhDqTIFGASSG4kIpDgnTdmPrx1srG1B/rCJmGDqVKFcw4uVqjkrU0N5e7aA0MI5umyUy9UmvMbO+3fM+rNUoIJq5tIoqePn388MHuP/iHfy+aTp8/e3n//Q89L9h5/UJy4UexSPlw0G2U8sP+IE54qdLod/rDQX9xfTH0o0ePnhVzWS/09w9OOJAKyh//+IcI2s+fPZ9Oh7mMc+vWnePjw3yu6Obdo6NDE7PN5WUna/zy66NfPDr8sz//Rb1sfPD2Ww+/+ib0Ij8M3nrr3c9//rFjkB9+/6PhYJgy+e7bd9vnxwACgmkYh1TTL9vnuaw7vzjzxVdfSaEbdj4cTxBX8wtzvW4vZvzpixdpEvzeH/7B+eWJbmqObVXKhWKuxFjSqOYrVfvJN78sF/N/8Pu/9uDB408+fnJydvb7v/c7x2fnT58dvfvu21CpvZ29i8ujt996bzQcff6Lb9rd4aA/Fgpcu7Yy36gtLMxMxkGn3Z1M4vHI67T2ulcn52dnYRKdt84yNsZKdC67Xkp2Dk8vu2MIoK4bq2uruYwRTnuGTjKZnFDgqt397Jtn05Blc8VywUEs5el4drZSLhXG06DVGj7f3n/6aj8Iw1ypWMiVgih5/mLv559+5oXBd7//vfvvvlUqFTSN6pnMycVpvzdMEzAKg97Ib52fYpWuLS4uLjROTvf3918nUXJxdnxwuPfs+dPD3b1e5/LZs4c/+/hnD1/uO27me9/74M6NpUq91m5ddq5OU79/dvz68OB1GPlBmmRzuTCJCvlcrZ7JWIDzMWAeEOkvH3z5auf1xdn4YG+/Wi0Wcm7oDXkYvHr1ZDzqzS9WhUh4khIi683G7Tt3LNvMWUTGgReEw4A9erzz/OUehHR+cT5fyAcTn0vmx9PB2KeGlnXzvj/J5M16rbS/uy+5uP/ufcaUNxkRxQhO+u3T3uVZOB3Zjru1vnDn5urW1s25mTXGxO7+8XjYuby8qlaqjVrj+Gh/b2/HdjLLy4uGRjxfvPXeB/OzlaP9ndOL02E/gHpeIFMIVa+WbA0FgxZFHEgEsKk5LgIIUZrP53NZV8mUaFgKlUSyUKrXa9WsnVFEI2ZmMk3DVGFkpamYBsl4mn759eO917uFSlHTrDBKv/j8K0LMazdvmDpqt89sy3SdTOfiEqpIYuKNI43auzvPv/js44yNZ2Yr0+nV86ePW+323Xe+k0L82WefCT7JmxhgfWNr/fRyLAC9trmm6Xg07kdJOkkRwTD1/Y3V5qOXF/unfR3Tu+vNUn3mf//PX+zsn2fzbui333pzK2vbaRxnXWNv++Wda6uPXx1s7+0jwGwTRxPPNjEX7PHzp2tb1zIZ8OrZvoTw9PRwfan56KsvgARBigejcNibPn7w4M6tjfX1ze7V+a2NleXVmcFw8u///b9fW1q8+8bKz//y/2i1WtMgvH5n6/TodRhOb91cH/SvZmcXleLnR0dIAWpaP//4FytrG6Zjzs03Xccaj3tCsv5oHHEV87SQMzrdTrlSzmfs06MLCHUo0Y++9/00lX/zs88++uGvb6zVQcSuLlqm6Xhhsr40f3HWDuNE8JRHYa1RaV1FPI3WlhZ0Ux8OVa/TV4JphDgZrTvyLnrhQrMYeBFLYts0A3+EqNlu96bhtFktaBQU8qXJKBxPBm/cWOACbL/edXSetwFnMohBikl3Eu0dtibDQSZfrdZL01TtnrcnPieWWStWMoViEvp8Olwo6X/4u7cbdXM8HGccZ2GpWWuUe5cHk5P92aL10YcbqyuZ/iRNAdby1V8+vfrv/9nfUGr8N//dH64s0RcvTl+8OCXZanVxnhJtfaYhOPrq5w9//vGjp58/7IbAi8lwEECIbt6+zbj4xc++5HHy0ftvrazWd7YPt1/tXl2dO7a+sDB/fHp6dnFRKteazaZjOb3hVBHNzLhxHPfbLSi5pev94TRMRCFXEDzRdNTv9QNvWq9U7IzDpbQtLZ83R9MxIOrWjQ3GwNnFEEJ3NPGSNHj7jRvFcill/PS8s7t/QJB2/dp6rewYOnE1S0k59RKE6NJcs1wwKZLTNBp5YZIg2zAtXScYSBlSJAiBhuMwxiUEiBLdNgUAUSpDRghGUoIkScNpmAgJDY3FScJZKrmUUiqlJGcipYaOMaBY6hBmHVNDFALohV4SpApo0ygejiZRzARPXMes5LWiY+Qch6WJH/EkTCBgJtUwBhBKQqBBsU4gRDJNOVMwSljEoELE96PuxAtTMAk93w8QVAbAtqlrGMVxOJ76UkpMEVTCNCjAyDK1VMIwSaI4TdJI14lGqGNTk1KWeBZFOdsiGEmMJYYJ55RiyARBMm9h2yAYS1ujhFBJkZKISySkQhhDBThSkkAIQSolwpof+UIJJQGCiBBNIYARAkLJlDEmFVBCcaCkEAoCgREFCisIpeBSCdswdV33wlBIgREWSiqpMEFCSgGgEqmmEY1QQjBECiggv0V+Aomk5CyFECCEAZBC/P9Z+q9mzdIDOxN77fb78/54b9JnVmVVZRU8GkDDEN1NNjkkhwxyZoIhXShCulCELnSlK4UUoVuGNBNiaDijaXIAko12ABqmgPImfZ483p/zeb/9fp0uoH+xYsV6ngUglIgBlbBUKMnTVAIluNAQTFiCMYYQKQANW0dQUgx1zSZYwxjHiYAAAC65kEoCAZSS0rAohEooACBIk4RzpoDinEvFFZAASslUKqWSnCnEBJBQacQUEKRCEkIJJQBDQDUlBGdpHCWCp5BgxhIFAMYQYYgVBACnMZdJAgDXDYMiTSopuQAAxoIPe6PJOBhMfY1qXEAuAefc88I0QRGXQcz70zBKGDYIhchwdcd2CQJpFBo6ybom5BwTgAECEHMlYgGRhCkEGsZcMEqoZDxJRRRxLxSTQKScxCkBigKIJ2EQxzFFRAGZMTTLIhDBRAAppWXquawuZZwkgisJBc/YpJRzCdWmkUg4ZwpOwtQPYogphlilQshECTkcTKa+T6lm2QYigFJIkNJ0OvYmCKLpxIv8GAA0HE6hItlMLoykz0QQTFUab20ucpbu7x+ZJiiVMzyJR2N/4k2xZs4sLBCNEIRM0wFSjocT34tYzG3LnJ0tT6bTwWRIKFYIQYif7+3v7LxGCG9vrFsW5CyZTiN/kgZB1O306pVCPpNBQFTKpf5oeHTaQZruOKaFyFV/dNrqYA2vLxfee3ez0agcHJ+/3r1MGORJjGH61bc2ZsrZo+NWsxt4AZxfqBHT/u1Hj/dOvM1Z7Su366aKphGcb5QWZnOLjVy5YHDBkjDFihPER2MPQtSoFLIuNEwzFShKGEQUEtyfTpJY5ByCgULQoLre7E56EYjCtJLVGvVM3/cvOgHSsywKFReFrLlQzUIRT6dRoVrWCRaMhTHzQzlby7g2HPYGIQOWlSEg8SLl+1E5ayWxLxQYez7VSRyk7d4YEAQY1DQopcq4NkBqGgS6Y19dh9VGJYwCIRQGKIwjlcaGqfd7k1EiO5edjKFVK5n3f/fRy+NOrVY0MGRxCIAs5bLedPLf/Zs/F2n45dPXC+vrl6fNZ89eFXLlbDbjeVGmUJibX4qioNVqXpy3CbJ3dncyJr29sbHzat8wXQ7A4eu9hfkZxzHXVxarpcyvf/+xnSk3r6+WF2ZVKq8v+83L3tH+Yc41/+RH3zAs9B9/+pv9Nv/0+YVU3r/8r755eNGcTqb9fmd1dc00tevm9b/57/7Fq8dPP/rwA83UV1dWO+3eyenx/nmTp3g6GMdx6uayh0enk1gsbW2zxEN8urXojAejYciH48DWyL/45//o+WcfOhp94+b6cDyoVKqTyUQpRAjZf/m8XDT+7Iff3tm9/OLFxa3Nle9/5+u9fufJy7M//bM/YtHw6up6plH5+qOvdtv9v/nbX0cCFovFnRePb26ulYq5Xrd3fdkdTWKISLN5PRhM52dqxWzBNDTXsWdq9Ua1igkSqXr++qrTneQzum1YiUj2z8+mXrA6W1VKjf1pzFUsUCZXcJxSs9MqFDO3lsvzjaJC8PMX5+cXA4msgRc7hcpMOQ8B70+8g9PLi2Z7ptJYXVod9QcyiufnG5WZuTDG5dL8XGOWc4Z0Y3VlqVbLY5AG/iSK00q5ePPG8ubqwmASn121KdVnFmaz1Xp1bq06M7+8tEQAmyu4d7YWk2k/mE4yWVdJqCGVz9prK2t3bt1sdnqDsf/5451ma0wArOUym6sbs/Va3jZmy5mNpTzGpN0cKAEHkxFk6Rt3blbybsbAm0tzf/To9myjnoTxeNCrZ/QHm/M3bq77Cd69DK4nAiL99q21jKWNBr2BP0ZYQxLnXLNgm6YOs7aRzZRGI9/JulGafvjJi4OjSy8ILV2/ub62MFOfqWbX5isLdcfV2cMbKzhO2+3W+sb2D7/71kwlM43Zr9//9OiirRCGAKQ8sgwz57p7l5P3P3v64M3b7711s5I1U4X7gyDiMJF0NBplXGt5sWbriCLIk0AxblmuTglgzNZQtZoPgnAapD7nSTStlnM51/An3UzOnZmbQQpPvUkh787MzkUhH089gdXywnylmLl960aj0fjo0y8n4/Hqwtry/OJkMMzmHcfVRsN+PlOYTALX1ZdX19rdSbMzWpid+co7D4JI/H9/8usnO1eFco0lUTDqvXN/PQwnPA1u3b21v7vvWPTmyvzKzOxgPB358WAyNTFfrFUsk1x1upqlXxztv/nGhmWg33/8kkujWir70+Fbb95BUBZdo1HNOZb42ju32hf9KMYKwYWZvAa9d9554/CoPep7lVIuTiNKjMOjq8WFmW9+7eFvfvdlbziJEoENK0rB4UknFrzd754f7b314G6+4E4m3qsXT29vbVmmWSwULk8v3Xy+NrOy9/pwvlHSYbL36mkhV6jWauen5/Pz80DBk4MjJfDV5cXc3BxQwnYtP4gGIw8qkC3kNGJSkAIl++Pp6VX7+ORs2Gv/1//4Tz76sveXP/v5Ww9uj0ZNbzLyYqVrRs5Bd25uREE4v7Dyem+vkrN1ql6+2tGIfmtzhkN52Q6cXCOIWRxHGtVHDAIRVsr5bn9UK1ezmQxASiKz2x0WigWgYgi4YWq9zqheb3ztUXk88ChChaJLEIw4i6X0YxUC2BzK894gChOquRGjByeT61408GIitZmam89nzq8709HovTe37m0vjHv9F3stpcTa+u1iKddrj/pXp289uPfW7blg6MtouLa1OhXk//Y/PNnfffYPvvro1poDAPzo+fmYm1YhVyrr33i49eiNmTvbOQXA0xfXr/euWp3w6dPXnavmnTt3cvnCq2cvppPR9vrC+tqsm80xDl6/PkZA3b63dd2LO92OSAPTzcapurzuCyaXFucbc5UkDkxKlQKXV9eXF9embvM00ggKvCCMEilgJpurlPLrK4swiTpXzYXF2dn5Rqd15QehkNbFZavZ7d3ZXjR0KpEeJtrz3cPzy7btZPMZw9A4k9xjauDHnu9X3EyjlHcNNeWoNej7caLpuqFBXTODNPb8MOUqZjJMlAKUIpUzTARBJIVSwDB0jdKQJTFLCYKQYAyhYFyjmqEDIUEQp1Igy3B0gBUAEnBqapQgjRKJERcCKCiVClORCCRFYhsko0ubqoyOE8Emvh/EqYISIxSnPIxjwJVpIsMgVENJlPgJ9xMBpXAsHKcgEQnUjFRIoKCuU8CUZRgEaxFjfhhBhDK2TSDmaawgMgwDAQWVwkRPpQKcu6aRy+mU6mHqE0wcnVoUS5byNIUQY0JMQlzDsg1MEUQAagQpJgVQCU/iKIkTxriQQkAgEYIAgIkXQIwIhgpIADH4/4MAkAGANAqQElICALjiinNEScpjpRSCGCipBIyTlKepbugQKqE4RAhCyFOGEFRAKEgUwBAgXaN/EHgSolNECCJSCqjgH4z8pmZqGEIAEYLY1E0IoFCKEgyUVBgSgLkUhGCRchYlCGAloVBSCqUAIhgoCCAmAggFhJJSQ5QJKbnUCEWQAIQJxZhQBQACAEIkOYzSVPAUAaT+sLdCiPE45amSAkCcchEHEQRSAQQBwhrFRCcYI0QghHEUIQgRAEABABDGGsU0SZNUpYpDCEmcimmYDINwMA0VoEmUchHbBsxYhq5rXAkulURQMuVS4hiukEJxzhULAs92rWw+53tT07F0PZukLAUAQ12miUap5IGmUSzRcBo0W/3JJBxMI9+LQg6646DdGYymSX84goQali2UVDLFBCtAwzBlSeo4bqlSRABIDijRMSSEarahaRiljPUnPhQq5cIPYgyVAoizVIpU16CuaxDgXK5garpgQnBmGCY1XQSZTkAum7ecHMD6aDy2bTtbyl+1O09fH163uoZmzM1lAYj8cDqzNFspZ5XiGOu65RJAludm87bT6/WpZlFTu2q1mpeXf4A9auViPmP3+p7iimpIQ1oaRjJJ19Y33n74oFzKX11cdTpDiOFV+/r07HxxebFaqrQuz4hu+EF8sHdKdJwpVqnh7p2ettoDDYmbK8Uf/fE3KDU/+eTzg4MrFoNSsTC/0Hjz3g3b1V/vH101x+0xgHoGxP7Z8QHUyL278/e259i4NZ70UyMTTrpBdwSi4bB7AkFSX1gs19eRVsCGkS/monAaRIkfJc1+3OkLDahIUD8QtapdLWSF9HXDHCbg6HyqA7m5Xi2VZ1q98OpqihFWCE1CoQFeyZKsBYlMKnnqmHp/NJ2maWfgYwhcw+13hlHEKSHZXJYLNRz0NMM0M1Yc+Y6llyvZfKHSHQRASsPUBeIGwSqW9WLen0SGbZ9dXmME6o165Pmuo3GeYBGXSxmi6ZOpXzCMTIbeu3fv/Lz99HW7Ui197evf+vLxlxPPe/jWVwdj37Ds0PP+x//xpzdv3pZcXV5fGIa+uLQwmUyGveba2jLVjavLy4O93a3tG4PR+NXLy29/+5vHJ6d7+/vhNLTNYqZUIhpamZ+7c2vz+PT88ZOdXD43P7d4596D37//wcXlZbfXy+RdJ0OXlmaeP9277Ebz842cC/7ZP/qjuarVbCenFx2gYpbGF+cXjbmFNA32d1/Ozixsrm9dNtvNq3MI9MvTy/Oz0/pcfW5uqdudPH35ulKbubo46DUv11fq7777zkcffVIqVfM5d3Fx8aOPP+h02t/97rd3Xz29bvYur6/CNFEYVaoztVrl5vb6zs7OT3/29/dv3ViYr3784a8+++D9/+1/+09qWffXf/vXb7x1r1Yuvnrx4uDoVEFzeXHlzYf3Hr377uxM9fPPPm33+lfXncDzCoXMo3fe+vrX3t3evMGVsrLFTCbP0vTsenh27bfG4c17y9/97tdWV+Y1U4tiPgki27H7nU5/GFiOi4FwHCxEOhwPEEJRGORda21pdhryg/Me1J2xH8RhVCnPGDqUaZgxzbWV1Y2NTWpa55cXXz55enF59uWXT//TT/7617/98tPPn2CCqrMVfzq9ON4zdDNfrguiTaZBxnTK2cLV5VmhVHnwxldLtbn+cOKH6cVF62D/JPEGpoV2Xjx5/vyxRnC1UlmcX5xfWFxd20YYd1ungCXDbg9DZNlZL+StYdi8ugBICxKeJKySt7c3Vmq1+jiMEoUstzi/ujxXnzNNq9vvBeHIhKzXvpY86XTaTsaanW+cnp5/8uWOFybZrI0F61yceoPLRjm7tbLFmKAamq1mk2DC4kCJtN+6Dqce4AIpgYDKF8uN+tzMzNzF2ZE/HTYq5Y2NRUfDyXSy++LxycnO3uGhP2znLEuFgW3a9+7fK5cLtuE0ZucbtUbCombr8vVR8+j1qT8aliyrmHXrM8XRdHR+fZVIyZRqdrqWkwEKR2GQy7lxND07v5BMmLZj2IYSGgCEMWHquo5B7E8K+Vwhl2+dH48G3bn5imNY5+eXE286CsKZubVKce7Vi73r5vmzJx+tbq5nc/mXO4cHp2eSU8NxFQTVWiUKU0SwpuG9/YP5+eVqbeai1f/gky9Llcqf/Nm3KuXS+fX16sJc3lD9XgeauZvrM2enF52Lc9u1/Unv5bMv49SfnZulRPf85LLZqtdn5mbrQAmooJWdmV69vrG1OI34093LSQBKpcrF5aHlmO3upJTPlGz5zs3V7c2Fg4tuGEM3m3EsZJvGj/7B94ajTrmYUWns5jLFUvbk9PLb3/3j7373Xcd1pEwnw+HpWbfZ9xhLi/lis9Vvdto3NreUkAjiMApu3bzhBUG338FKn19am0yi07Orja2bb7/ztaur9o1bt2zHaF9eV6rlbqdzeHQMqXlxedWo1dfWtqrlcuwHQEGCzIgFEIP1tVWi0YSDdnf4wW9/l7L4X/6r7+4dnl5dXc42ymGcVKu1lAV7L54Mx92r86t8uWxg6fXaiwslL8BXF63ZslWxwXmHB9BZqM9rqVA8kdLuXLRnZpdSLjrjXj5vB4EvJXSdfLPTymUdHnlJwk7b8dnl1btv3V0sKG8wUozpGszZhCKUKDhNiLQcjPB06EXDEdV0LZMZh3Jnd/DZy4P+JLCyxcpso927Pt15poL+4nzNda3fvt//4sVzu1B78HCz10ne/8Xvsia4s2QnQ9a67my+cbc2Y/zyFwcvXz1ZXpx/6+3NWlZ//1d7z7443jtrn553/elESvr9P/+Tf/Sjh4VijgNZn6m+fr17cXVRXajFEZeQ+8OuY2uGbdy6fefNN++2ete2Ad955yFj8Wg6TtKp49hQsebVdbPVIsSZaczky0UMeL1aS5lKUw4gVog6TsaxnXarw9IEawRBtL2xJpR6/Wp/eaG6vbHQbV8HQYSt/HSSCL9TqxZG00Babq6yNJgEk7EnOXcdc75RRUikcdLrDYIkdF1jruTms66EuNsbjcZTCTRDI8V81tQJS2LOWBQnSRQBqYSQgnMuVSoEhMAwtHw2qyGQpkwJKAB2bFsjlGj6HxrbVLBJEjEJFJBJnBAJCYQ2JRrkSgkMlQSSczkNw2kQQaWyjp0xkWshQzdN0ySUEooNHRmaLhWGCBMlTUoyGrEdk1IiuAwjRiHJZWwouUhSoBQAgLFYSEYx1g0TABwLmCQySmJKleNYknMNK0IgF1wpmTAZJakUXEMEaxgJhKDkkivJivkc1kgchwIqpVKMmQYJxhJCSRDACMZJQBTRNUMKyXiKESFUxwABKBVUSgEFlEE1TcdpkgDBCYZACsk5AhhApQBECieCQwkVVwACBWXKUy45BgIgpBEqFAASKQQwRAAJzpjgUkFFMBQJZylXEBKqSwW4UknKBYSapgMgoBQpY0oJBQASgqUslQJgAgUTQgEpocRQsDRhKcAKE8okl1ISjITkUnGFMYAQIkAJxQQroBIpEABSSSBhFAVAIcGlEBwDxIRSAms6oYQoCSAmBBNMASIIEY1AHQMIgJIAYgqBAjFLICKAiyRNhQRASi4kQhhiCDBMBQMAagahCEohOJdxymIpgiQOE0Y0mslkDEoJBJZGXccBECdCcKWUYEhw16IZx6BQQgQwJjrFtu1QzRj0h1zKMOWtfm/qBUkQS5m6hqZRUC0UKNU7Q785GCVKi7niEoYcjMbBYBJCQogOqG5iaERJwhnXTINBPPSiiHFIsWlpk6nfG04FgFxxLDlFSEowjdllcxBEKWNCRIlpEs21JRAEKNPWs7Zm6ZrjaJpGOJZcAQHoYJIOw9g18fJiI+vmptPo9Pwqk8kUi7nJ2D+9HrcHqRcy26EZm0CWlErFKOYEo6xtl6sVBWJAACCwPxmJlLXanVevXookmalV3YyddW3N0CejUehHCICM7TquMZpOKrXyTKMRRuHjJy96/dDOuJHvjbud2bmZXCa/e3TsBRFL8cn5damcubu9bmp6qztOuCrktVs35r/19bd6g95f/u2HpxeRrju1qu35YRiH3VZ7f+8kSiHEuqYToqWOgx7eXXvjxjJM+eu984EfFytVkXo5g87MZypld3nWffvO2vziwuOdiw+eXByejff2B8MBAwr3vHTg83rdLjl0NPYMDWdcJ5j0EGMMkPOrgaPDh6vZvI7P+8NX12mqQL3kdlu9LjNm8tBCMI2DQtbJ5p3RYBxFKkpBmoq5mjuNeWvoaxRlM4QoFjOhI2kbWm/g6bqjQ9mYqb08OJ4yYLlGGqUIAQCRpimKRcpZtdbYOerPzBYggkGUWJZu69A0tJyTi4ViLLB1vrE+2xwNfvrzF1iB7331fqcz2Xl9NFOr7u3u/PrXv8PU/N2Hv793f3l1baHdvpqOp4uz854fPH3+YmV9OY78q4t2b9RfXljauLn52Wfvv/HOjWJt6S9+8jPb1P3Yu/HgxtVFixCNUtUf9J+92Pn6o9t37mw15moffvxBwkLLsLdvbmdte6HhKGRcdjyl8PZS/kffvPPw7u3Xe+ft7jCXcyrVYqVSCOKo3etBbEqhHj166/nLZ9dX13OLS6lKi4XMN771dSdnN+o1Q9erxbpGQTwJtlYrP/7xDz///MXY4ze2V3PZfH8woQAtLS2+evacapZumLrhMgE4SymSFKNO1/vg0+f5QjGfd5rnp+VS9r/51//4xsbCixdPFzduUAQHo4EXJo3ZxbmF+vravDccTXqdX/z874v5fKVYwggUSwVC9Fcvd3Sidl7v7+xePHu1/+mXT6/aw52D9sdPz5rDyKXmsHfdbXemnhcEwcZS496t7UqpoNtuEoaxN5mrZGdrZUqIF7CY8+thsHfaTZgq5HLZTME0qeTp/utXZ8dXc7XKmw82ijmSxKHtWksry3NzdcPIjkfem/e3/umff/PWrRsnl91xe1LKOsvLc47rDHojy7Bv3bgrEX6xu6cITVOwd3C0u3fk+6GBCKGqUdK/9dUbGcsdemG754dRCLkQLGm2Wr/97SfjsV8q5KuVkqNBb9y7sbmKCPQTeHk9Pj5peQn0w/Sq2bm8PLNNzHk6HQ2KucJkyo9PLxMmitkSS+VwGsSRNztb2dzYerV7+Fd/+/7+4fl8I//m9mzZwvMzufWlmXI2U8iYjbKbtTXDgKvzNcViKXjWcTBMbRPMVMvVUubN22uVHB6N+sPhUClkanQ8Hu8fXXbHUSrV6vrqN772xv/pf/8vG43ai50dqFs31+fv39y4ubGWzTqD4fDV3qFEuJgrfuWtm28+vHl8cv309V6n1c24+tbGYsY0gMQY2dl8NQyEAsCxbSVFvVScnW9AnbR6g8Or3jSM4zQt5DMURKZpxbHoD7ppHBiWAZQMprGbs2KOu32fCzAYjAeTaczifD4/mcTdZvPO5jpG5IsvX//u8y8g0KMw7Hb6cQKbV9erG0vX18P/8rOfz84tW2buxavj//Wnf2O6lc3tzYJprs5W79695XvBVWu0MZufq+UHnk8IWl6aRZh0WsOUSaBkLl+46kyf77xeX1uEaXTZ6kLHfXhnjYKE6DRk4JPHO0cHF3Mzc5PRqNkbWbnCgzfuf/DBL8v16ijwr67HP//9Z41K+ctPvzCpPlsvry3XXYN8+fiTO/ff6HWHP/vZz9Mo+Oa7b3r+RAJ01Q0gIvF4FIVJzMFo0K9W68PBuN1nV91xmor5WnU4Cp88e26btFKy273+v//J3x9ftPPF6un55c3btxbmK41GdWVttdcdjPrDfLb04UefaIb2xttvTL3RZfMqiFJEzefPXjIer63Ors5VXMudxHBn79iB6OZKqdM8zhbrUz/t9wdL8zOmTgZDNQ5V4EUz1Vy71XUKjWlKTs+7WYqWGtYkBZfdsFDJZk3lj2OcL0sEmOCGYwXjwHVtKqXkYaFU9SaRFDxn28VcLhbgs6dn163eOzfLUnFdQwVHI4wRzgnRJJB+xPIZZ3HWyjgq8KbBYJLJFpUDm2P40dPpzmELCLw8t+jmskIwSpJbm/NLi+D5kfj0xaFC5o9++KBeAF7/2kCJk4Odnrf/6ulSvdiYyx+dex8/OYqD5J9+78H33jLbY/VkPzq4moxD8sWL7k9++6RoJO/eXZ+tZKvl+vLqUvNqwIK4UM7zJAEmNSydgOT13o6TzVSrjWdP9xgLZmfnLVOXjC8vzMzPzlAdn5xef/bF88vWQApVLhUSFms69EIfUwMqenV1alp6sZDrDoZpLAbjQZqklXJ14vnXV+2bNzcLxexoPEhCzgGKg8nMXHl2trq/fz4a9teWl/MZWynhJ2E+a24tVDMWChLuxygOUpjwrKVqxQwkZByLIIzDOMqYZKWWnysYbkYDSCkMAUJEg5auyTQc++koCBFQWUNzDGJgBICYTr0oShOeQiGAklyIiZdGAYsYFwojQsIoQAjoBs1l3EzGxhARiKhOMEQpU30vYBK4pl20zYxBDAMTCQxCCCYaAY6JEZaCMwIgABAJbmo6lyKIE822TQoNqkkFBANcAarpuqbFSSSZFEClcUJ0IiBMuVICAAgVkIRqlm36Xsx5Cog28MNpGEkAFMZcqiRmkkuMqWOYCEMghAJQSGUaxLFtgjHEwNSRrtkAIS4FNUwJaBzHCUs5VlAqSihCNE5VHMcUYdsyJAE84UBwyWWcxjzljDNEgKbpEkgEkZRSSYWgjgAWCgKl4iQBQBKCoQJAAYx1SDDBGCogpEQUQgIRpoyncRxInlKKlRIsTSHCxNCITrkCCkokuUQKQASBhFAJjKCQXHFlGiZCgFIaxwmABGMklQBAKYQpIYxLIUAYxYIpQijVqWAAQAQIwpqmUSh4igFRShFCOEhTkXKlAACCMSVwEiZcCCUEAEJhKSQXPFUCCsGlVIwzLiWmmGiEK6FhkrFzGCGlEMVUKSGZSGSqGZRiTBEEEOqaTQCACromliwBGGYzWSHFZDpJEk4UtDRkm1rOMSBQYRxKpbgQgilCyKA34kLphMqEAwUxphACipBmEg3A0EsvLzrt3iiVCGAYBskkSAHChmnbru24LhBASjmejkaTEEGVRnI8CiSHnCVKpIqLJIolIghDrJTjGLajeV48nvqYWJmsq1mW6RiUksSPRMqppWVMwzBdxuIkiPzxmMfSi1h3EE296XyjsL2+miTs008/f/7qwDRzGMN+rz/ojzGysEbnauV81izny9V6vdttAcEopQrRIPD73a5gURonoR+FftJtd13bWV1fs7LudDzCBAIp/TCNkpCayNSN0XSSz+dN02hen756+XIwDC0nC1IwGo/m5mbz+dzF+Zng8fLKmudHju0szNW8aPD85bOLq0GhVLi9vbSxsnh1efb+bz6LQ1ms1NdWVqM4GE8mAFCk2Y3Gimvnuv0uUMFSo1QoVP0g2n22c3l1rWcz5fkN35/UC9qNG/O5nJMvltc3Nkaj3t/81a/2DnsJwiEgRpYurMwyRaZeUi8XXdOcjIciiopZK4mDjG3Xq7UwDhxDv701k7Hoyen1ZXvCIW3MVpPEj2NQr2WLuQLVCKW6z4QXilbfMwxNx2ihZuYzxf4kiAFlSuUtTUkmgMoVMiM/gdjIZG0dwVF/0moGtWrRdUyIhID6xE9rVSec9CoFI0pTyeJGo358cKlRqlGoE+DYTsB4vzuggGQcRyfk8rJNdfz9H7w9X7aevdzLZLPlWtWfDm/fvqlrdGNt+zvf/vbh/mvB+NrmYrFS2NnZa8zMLi4uP3vxNInCUqFSn23s7T5LIv8H3/ve3/38Fwqqd99991tf++onv//k9OJ6c3XJMvUXL3cX51cXlmZePn/6/OlzivDbb71ZLOTcTObZ8+fri9vX192/+F/+9v6Dtc2VsoPD8bjz0WdHq2vLk35rY2lhbrZ8fnm+ubWVy5Wfv3rNJPnkg8/m6zNzc3MEqG9/6z0mksl4rEHYuTwdDzsUyUePHjx67+v/y1/85MmrztaNmbWV+Vd7L3q9ca2xenp6yTFVRk4z7ThNoOQY8ELWwBA0W8Obtx6sr9dHzVPbIG8/eGM86P6H//DvIIaWadXL1W5nyBUM4nDUbR8fvj45OSzX5nRNe/udtzIZc21tlmK582rHMPX9w4NOf1qdaRQLs3fuPFhbX0+UZFJBIDlL/TAmhqZTulgvayA5Oz5stjvtbifjkB99/xt3tjYKRTtloW0hx85ddcd7x5fhNJQyal4fp5xt37p568bm2vZNSNFk2NYgmI7bnW6n3Wp7Y69Sqd5/8LBcrI7Gw8nERwhlS/aDOzcMhI6OjscT7/To4GD32XQ6dnLFQr6cxN64311dnp+v1/M5u5rPzJRKW2tbhl3E1Hjj4RsL88uAkKnnV2qVer1YKWVsy+p0+1bWvnVzU9d5MBomLGrMzwMC4yiKOXez+ZmZxTQIcq6ZRtHZ2en+4f7hydF0Eu8fnnphks2VM26OYMSiMIyZlS3+8fe+e3dzYbWq3d9ayOYsy3IB0s5PL6b+QIkUc+D5QbaQYzzJF/Nrqys3btzaWJvr964cykwku93hRatTr1c3V5c4TwejySQU42kwHfYjf/zy2Rcvd54T3cpl3Mmo+dEHP3/y+MMgiTChTtYiBAdJBMNBIWubuTzXne179zI2rhQtQ8PdfqfdaV1dtfZPjuI0Nh2ayThSiuFo6gVxmpLuMLjo9qimGYbeqM9olskRNi0nVyyVC9VcLu/74167BbVMvlT2PC8WKkkkofpb7zx68+27z5+/opp2/8GdlZUlIOE0jDJuYTodaYYpBV9enPvWN96EjPVHrfv37t65e+Pg2P9Pf/FXzctLKIOPP/3Y90KBjOtWV8T+3Gx5dX0z4TiIpaYbECPMkDftEkztTOXksi+kME2UseiXXzxbrNfv3b3VH3jdwSBbqH75ct+L1R999/ssjj7/5AvbzmiOc3Z2ShTfvrM9maS9XlibX3385OV0NFycnf3mNx9dnHbiWNy8dePl88effva8228Vc9lsPm85gHH1ra8/um52IiY0rNkWCaLo9eHR+x8+++LxCzuXn59dvDxuLi40CIGK4Exh9if/5e8PTi729g6ODg+iKCYAQUxWl5Y++P1HlXpjprHwV//rfyrlM4uLMwDCk7OzfLX+1a9/T0gRjPqOiTdvrgmExsN46nc3VmYdI5MmKbVzp2eXXInF1U2uYJTEUTDKuLoXAgZpY3l5FMBeu7W5taAAOLu+isKkWjQ8H0Qxy2Yzg3YrY1pQKoQIIUAlYRzEkymLUmBmHQj40kJJ0/Dx4WEceLV6PlfQHNs0Deg4mobSOGZeEDe7wzDWqnMLKwuVcp6MmlcudaszRVN3L3ve8dnl+eVVY25mZnF2NBgqf/DeW/fml+zWWPz9b54G/ujevbX5lUah0ijX125sVbJYXl5cBtNIutlhCB6/2Bu0Oz/43js//gc3Y4Qfn4QRdrZurR3stP79//ys0x5jAF/uPNYMbWlpdjgcriys6JoxHEaa4ayvLWtAvd59Pteor2ys9loncTTN5AqFUlFwFsfTmZm5ta01QtDFZas3GDPB67VCrVGTIj29ODN0W6d2v9PNFwuAK6mAY7nNXtexjPmF2tHlabPdvnv/npvJpKk/Ggyue2maqtvrCwt199Vue+/oUgHNsjQN0WGvXc5YlXyOYJowwRGKwhAnwsa4Us6bmhGkPE1lOB05CBQdUnRNUzMVxkwpDCHVQMF2BKZRyIIgTrm0DJtoOiKagihOGWMMQawbFEJEIBYARjHz/alGdMuwkzgOoyBlqUhTiJAEwKDEMojpWJTQMIqjOJZAOBZGUCRJIoXEQCRxhBUrZIxiLpfJW5ajA6gIVpamuOT9UQR5CjFkjMeKpUmsabpt6xqlEkrJAOM84VwixLmIeQIhTOIUQUQpoTrGEgohEMIKQc6ZEgmhGGhUKjTxfKFkxrIJhJggTHDCuBKKYGRqOqW6SUnCY0gUkEzxlCsQhBFE0KAUKskYi6NQKJUmKeOpZDyNEwEQpsQ0bI0gCGQSM8GlkhJirKCCBEGkJFQAYgD+cCGME8ZYnDIpBZc6MQGQCCHGBVBAKsWSBCJkUqQAAAhABRFFECqepooDSjQAACKEQE1TUCEIJSJSAgAAQlBwDgHmQmJCEQCcK8EVJZrigjMBIDAo1ShVUqRJwuKESwERYHEEIQoTJgFSUEKMAcBSwSTiUCkIIICAyRhTwhIZRglBWEkCJIAQAUIARDrGlBJKKQaQJwkCSAIuZcITliRJkjJLNzHGf3BrYkR0Q3N0UycQazifMRlnhFJLN4IoGfYjxgGBRDeoSfU0ioahz7lChGhY4xzESRQGPtagrutMcCSVY2uGoZmWBRFJEuEz/vq8f9XxAbEIpokfIYIogkyoOI2RlEhJAICUEGOSdy3L1JlkVCOYINe2bc1BCEAALNvKWWahlMcQTqcB0jXOpG1hy6ASyIgp348gRvmMXXBM09SiKBpPgigRChKfyWkiBv60lNUf3Wn0A/XJ5686Iz+bKxgG6ffGtmYPx8Ek8osumas7S/N13Ta6vWFvOK1UyoLHtqVRjWoarVTKUipv7DEhS6XCytqKF8S7+4eWaeayOUXwqD91s065UE4Zl0xKmXImeKqUILbtWibhIM06LsH0+qrFGV+Yn7tuDqaToWuhIPR7173ZSuXm1uz8bEVxdLh/8vz1iYR0tl6YrWUurs9eHXZLBb1YdDIZ6/ji8vHOftbR791cLOUdLsBVa2AV7Ntbi6VCsd8+LWS0O+s1b+o7hOQy9pOXpx9+3mYCzNa0jbmqrUFLp4InnjcpuVrWhmnoWY61Mu/M1Oy1xbphWT6DaZSUXRMo8Ppi0pkqJWC9aCaMNdvRSkO/UTOvp9FoFMg0STm46Ph+oqQUroltQjqDsYA09KM0UZQiDUNLI7Ew2724UnKgZJqONIJMQ1Ur5TiINUiiiHMACuXKaBRTSvqt4cM7DUBAq9d1slkgZcpZECaDoZdEselmiGZdXPf7ze47d5fubG89e/3y5Ph0dm7GdbRs3jUtzQuCSrW4+/p1s3l98+YNbxzt7LzWiFpbXnj96nWSgIdvPoBAHu3tDXqj7/zRd1vnlwf7uz/4o+9qlv3xly8+fXz4J3/2fSeXPT272NxYx1j99d/87vXR0fzS3J27d/3ID7wpJeb8TMOpzxzuHWxulH/8D37w21//amXzZpogJ5tLYr87mDQWFsJourZU++Mf/vG//bf/dnZxLk34waX3xlsPW/3rW3e2j8/OAEDFYrnnTSml5aqdc536/OJf/d37/+XvDr71jbVHD++//+tfnhxc3bt7pzPouNmCmym/Oj3rdgdpIiEhK6sr7U47jgPbMUwDjjvN7dvbX/3G1/YPTv7zT34BFS1Xc7VKrtftdrsTy8x0mq2UR67rvPHmG632pZt3865r2uZk7Deb7Wo5t7q8NOpPFME8TbOuNh50WBjcXp//zjfu3bm9QghCShRz7je/8ubDh3ddywIgWlipPbq3MVettFpXv/jVr3d3dhYaxeVG0XHoTD23vr5cqOQtjZRKlUIhd3l9oZsagMnZ6dXRafO61ZmdnVtdmkVKLizMUkM7OD/52199/OWXu57vzVbLtVK+27208+bt2zcajRkNacPxeHG2Oj9ba7Z6BPHFxVoxo2+szwGWxklcqMz+5D//9a9//xEXotluP995/frwUgqScnb35gZPg9GwnzIx9sXhWfv6ajI3N1Mv5W7fXJ0pZxYalVop32v3xuNJqVwiSB2fXUKEfvSDb3713Tc21+dTxsfjKVTCcq3I83zPm603LA1G3tih6ury6uj0HGDz5LrZ7PXHfogFtCwdIXhyea1DvDBTq5XKzevm48fPnu0cTUZhEIZI12r53I31pXLJHfbOSzn95o3lQsZCPJ6tlYASj5/uz8/NrC3Nd7vd4ThOU4mwNux2kIwbBWehUV1dXhJKDnvdvG01qhWeJM3rLmfxW/dvvvPgdiGXNww9n8vZtqXrpmXZuVIxVyjoNKMbFkQ0mIZxIq+ue0fnLcbA8upKHCVBFEOMAYR3bm7dv3+TxX4ap5VqPQ5DhUCv03729Gm5UN3cWr28vqIIVXLG5uZ8qegatjZTK0EYlwq5j97/uF4ufutr92QwPtx7trI8/6d/8ojL0DLI9s0bXPFUwUa9dN0afvjli/rMfLd77o+HzeZg5EURFwvLtXq17k3GEeMjP37y8oyYpVKp7urwV7/97TsP337rzsJ41AOKabr7H3/6y4tW5//4f/4/1GrO02cv5xduzFQbNzfmYDq5ub121bws1+Zizt7/+NOT84O52cby0tLTZy+3b25oBGWzZq/HhSQijeaqld+8/xm2splMNo7CcrWxf3Sdy1pv3tve2FjcPR42O6Ol5aUoGCggfviDH14ctyvV3PJ8dW/ndalQ0qh5dHzW6XS94RATki9m/uJ/+g/333pouNb+7o6UcnNr/SvvPfzbn/1u/2B/bW3VydqtdqtQcAvZ/FX76tnLXaybq0uLo367XMqGcfL06WusG5tbCzYFPAqsTFUheLi35+i4mlPn5x3Xzi80tOk46XS6rusU8yBNebVgDwejXNbM5OzQj+bnGjFjmICEq93DJsGIIiGS0drSrBCMY0NDYjKaKIy7Q0U1ujbXmHGJQXFzoJ62xi8OW0lKFmcb66sVxX3FeLmoFXOOADRK+HQS1crVm9sbGE2Rul6dcaoFW0D09NVFu9PtN/s8YYQMTAyL5WyhkPd4EnpepVyOGf3Fx4fHZ1dv3lr8zpsVh4LeICiWcl+7ox2NwH/63e4olPMzixDIjAu9IPjNx1+kqahXGoN+t93vbdxc973Jq9ev1tc2trY3WBJO/elgPGl3+nEs0zi8sbH66I17tUoOAH5xdjGe+KV83rJMpND+6ZlhmRNvLIR0HKfdbjkZx7Kt1rCfz5cXZuvHByeT8XBxcW6mUnEs8vQy+PzpSwKT+7eX3AzYP+0OpqESNGMaOcdqdzuKwPpMUfFYicTKOwrBOPax4oZBIUUB40GkvJRRHRRsamEIpdAx0rAyEHFMnDU0hJAXi8HEH3u+VIwnXHClADR1XdegAYGm0VRwTDDVMNK1VCQQSc0wEEQQQSZ5GMdCCqGASCUFIOuYuo4BwYzBJE4JJMSgjCklkGMYKZNpKiyKAEt5mBqUGBjalJgaDcIwZdx2HEJ134uHk8APQ0qpY9Cibdg60iiNwgQKgBAyNB0iJQUOAj+MUtu0Ui6FTCnGBjUtXUcQx0lIIIpTHnhhnKYJ50inBBICiWlqAAkMAIRKJxhigAFgQgghqEZNTYOYJIngCmqUSgAhpoRoiRAAY0w0olOIgFRKQSkARohiRISUCgAuBZBIpFCkAgLIOVNKUUSFBAQiousEIwAEVwIjDUilYUQJIQApwBGAgFCqUQCUhIASpCRUCmMihJAYIKQQAJIrASDCSkkhGMYUKMWkgFCmKVdKIIgQhEpKLgEilPMUIRKyhAsOCYUASYghRJJJBKmSSqNUQyTlXAHBeIyAwgQSjJlQEEGEMEIYQ4gwYlIoIBAgiCAMgeAcYaq4lFwykUAIsYYgBgiQMI2hRI5tMp6mQqRcIqkU44LHLA2jwDMgBIorJf9wiezFPoMIa9gxTIqUBJxDSrAFMOWpGo9HCEJdtzGCGdfKurpLaSZvm5RgLGPfT6PIC9jFeees54dMsZR74ykgSNc1AkiayDCKKVKcpSlLueSYaJZhmBhADlMeQcU1UydYjYYToZSrESHiKBj7USQEmHq+FFJyliSB73kTL2GSmJSWXJ0oPhx5ne4khVRpOlA08MXES/KFwhv3t+qa+PLVyTAQup7LZt3BoJMrFdvDiVLYNbT5en6mnuWp3+11ri+71UpDAWRbBkBKpiKXLSJAeu1OwhkxtbmZmWlv9MmHnxCozy/Ox2nkD0YSwnwuByAeTUM/9CiilmUzDhCmbkYzDKhhyaXq9foiTl07E00mXhQ2qsVyMV8q5GuNRTebRTKKp71etzsJYs3IuhnLppIH/cvLzuzS9vryjElBq910HO3dt+/dvLWmQ9jtdA6PLhjSLCcz9YaEj+/dWLxzY73ZvE6TEGHwevdg72hg5sszs3PljOn1m/7Es02aMmERlDU1C0uAJNLtuarrINS6vDw+7V+1h+NxTGB6ft46HQumu7YB8ibp9Qa6rW2sLqru2VmPI0ocx/E85sXIzDilkj1TMriQp52JYRiSA9OAxWoN6YoofNzzkhRUCtnmxWV9dl5BGPpy1B/EKUfUHEwDyzKw0kcDoYgxjacoZc3zXpgAQohhWpquS0z9ONKJFsW8P433zpo61pbnZ58/f/Zs5/jG9kretQ2NWKZ5dnQee36/3ey2r99+6yuXZ1cvn7/O2oV8Lpcm4WefPF5d2NIoevzll9ls9u79N4MofrHz5L/+8x/53vg///Xv/uN/ef7223e2t5Z3Xz9rNOoJTw9e71Rnlr/3rR/P1mcGnc5nn38+v7iSJIEfhePJtHNx/J1vvNO9PCk3anfefOeXf//RXD3vh0F9bhEg6E3GX3vvK08/+2jvpPPdP/6vPvv8k3/0429MgvFCfaXVH11fdjLZ7OHufsiSrfvbq7NzKZP7+2e/fP/qn/35G/V6vtfvnJ71vvbeu3GUXBydzs0tPn/5unPepbYllHAN7WR/HwB88/aDJI7OjnZsx+VKPnv2/HD//Ot/9N2bdx9trmzZKO0OfMO2wkkUBlE5X6k26v7YOzk4owAeHB+c7B/o1JiZqa2tLF81LyRUcRhRgzDmS8GyGQMpP2cxkEy4UsQwh8PuzqvHn/z+d1GSrqxsLszPEeWlSWi67tbtu9/61tfv3rnhGDRnUhUnkkcSCAHIYDJWXGQdOw58bzKq1evl+uLq5r1KtZLwdGamknXdYDo9Pjp3Svk3Hn0l47gX5yfZnJ7Puiah3XbHn4y3b29+5b2HtmsPel1Tk+sbW/Vyod+6uD4/VVB6QXpwcdb3+Nx87ZtfeVSrFizLKlcq4yBtd/qff/5ZqZC/fWM7TdjRWf+6l7zcPen3+o5DXn/55dnh7qB3iZTUdSNJ4lRKL4g3N9ZvbG1Eo/75wfPj01e3b2997StvBZNupzOYBNNKJVPOmCr2md9zMw618q3+9Oj0MpWAKeU6OTtXTlIVRmmxUDFtXaT+oHeZpADoNtGczRs3BMmeXrZjnkCRIJBall0tunc26pVKbn21MdMoWUbmzu3b1drMuN/SkdKpU8g3ssXavXt3v/rVR5ViZmfnWa/TtSuLiNDEG3iDdr/XMUwzCVLJQpaO3QzNF7P5fDbrOoE33X21s7tzcHnV6U16MYtM3chlCpph61Z2NI3OLy/2XjyDBEZx6PmT6aj3aud5r9tcmq3E/njc65uGXSpUFucX+s327s5rL4wnURBEQwIS5g/LBVujkmoKSF4oVYI4PTs+qhbtza3FwWD06uXTW6vV737rfppGnd6AMcVZurq+srDovD5jMYPbKxsRi4hBdcPCBB/s7gGAFSSmThGlL4+uJgkQSmVtGnjTDz75/Y9+/COXAqTSheV1I1v6f/0//+Lw9eN/82/+ye8/fNzpT7uDbi5jzFTs5bkS0fGL/cPSzPLs3NLJ0d4k9OYXVs5Pm/lc5s3bG34iz9qT57t7VMFqo+Gn4Cd/+ZdzjfqLJ69dt5hywBLZa178N//sH2/eXP/k+X4YpqZD/q//l//73OLCw3cffPy73/zrf/VP3niw/eTJ44XF5Y2NdQBVfzAOgunNG7eXN1Y/+eQDoum3btwgCOzsvLpz59b3fvCj33/82W9+86u1zVu65Xabze98+/5X3n6ADff5y6NqrWYbwKRqbWV+7HPfZ4WSYWuoN5z0h3Fxtt7rBSAKsxkcxfKyMwg50g0wHIemmatVy9Nh33KzCMrmRWemWj44fN5oVEuFTBj565vLmo69Sey4Zj1vUO5nDCuXK2UKbhAqYlpYh6fnYRpN52brBsYJgLFWvBqJF8+bB7vnedNdWqizeOJPBiwUUZBGMfjk4+MnT57kc9l6vdHIOTldxVHEAIoAKpSrszPV4XjAJgMh4uHUn8bxytJ8RpOt82vXKsQk+/GX56x/9M6SdW9dv+oOj4/Pao5cvLM58ODvPz2JY5a3UaWQ+/rX3tZt7enL3cGwN1OpY2LYlvnm/bvedPq733/UaFTrC7NQSMakHzLTtOOEffHZRwHzq7VirlicmZ/3x/5oNHIcu1IpIcM6Pb8SDLAkrRTLgTdhLMxkTCyZ7082N7cW5mq7O7uHu4eajlYXF/L11cMj/8WznXw2e+PurWkCn+9djTzPsEipVNAsO0kVAjKfsQ1MoILItCChPPIdHUOlPMF9RYc+j0MFEw6R4kwJABSXJhVICgw5oUgSkgDqM5UyRTDSdRrHoZTCMQ2NEkoRopClgkdSpkowAQXQIbYMQyckn8tBBJNYTqZRkKQsTUyCXINQqAiFCOIwSaSCXhwxnihMTN2JkjiKIqUkQKnkEkiuW7ptatQgYcxDL9IpchxTYRKGcRyGJkU6hRgBiBFUkjHOmYBKaDolGV1KJeM4jhMmOMYGBdC2sa5hgIBQQEll2oYg2PNj3/NlnCrJeRKJRGJMJFIYS8kSXSOWaWKAmQAKAAUgASRM4zCMwjDBGGmYSsk1QpVCgjMAMGcyisKIpWmaMM4ABAgDBRSTnAmOCcI6BRAghCWCnHECFYYQQgUBghIoxqXgBEIFJJdSAuBYJsYSKCiklJJjiKQEBCpdw1wqCCECEAEOgyDiSkklIAQEESF5zBkEhBBqmjqAQEEllCKaAZSEADCpRJpAhXRNEyIBCGBIAAJCKA5EwpkSAqA/8A6AIcWU+MPkBiogBORCMi4RAVIpkQoMscASQgIRElIhABQCUigACEJIpMrUDAa4rpsQKc5SCAlRECoiFRCKJUCLU6GgIhgjSHiqJqHXnQZ+IAHgBsYEJTYhAmEkhWLJOI5CmVJNMwyoGyjr2rZJAYSZkqtrWiTVZBoFqfBjft0ZX024jgQ1IaKQUiNJRKcf9KZeKDmlWsRhDCSAoJJ3Sy6JomScUoWFjpBt60ipSRghSqmuhRwMPTmZgkmI+lMxjgQyrFiR6yEPhGYaeLaAl2sugmDoJZOIYx1DAIXgDHAIo6rNvvvW+upc/fePDztXQyix7dginsxV7LKLWMIUBsWcbTuaH6YX7Wl3GNmu1agVTRtqZoZAggnQdbPVHkyjqFJyapVswsTu8cniUuPOnbX+aOhFfByGpuUaGF832+1uP+OUbDc7Go26g66mo5xFFUvjWHl+ath5bFjHzd7r62i+ZDVqlTABOyft1yfn4+mUajRTKCulDMvOZE2IBdV1n8ul5fKD2xkJ9Gno3dyYee/uai2niRTsXqQfPusMggQANhpeV+uF7c1l0zD+/ndfvryiM3Pzg3Hs+XxhJl9wZJj4rQmbpmBxZcbW9DhKlGUDEwOclG2wXdemkfPZQf/JaSoxzRiokM9c+vTMAwYlFo5qJdcAccUC2/NWq99+1UdbTlrOOJ4we54yKS9r4eZcKZKy2Qzm6vUpMAY+qJRxw0lMTI8HTAVeIUugxpy8jRA6O7wKPcQxrFQrSeARoebn8iKMdQRsjWSIilMvW7QQ4J4XKslgmmiA6YRurK73W63m5dX2+nxjvtRtXWDJbi7PCcU5SyjWT86uzq5bmVKxWCjduLV9eXX1avf47r0tSxdx4ieSP3zv7kzd/uDDF4zD2bk5JcOIRbfv3oYEnV21T4+OvvedlX/+z3/88skXxUKhVGh8+cX+y6PJn/xgpVrRv3y697Off7y8vDw7mx1NB8S0ry+bPtAkMJ49e76yuPjp735h6Oy9d+8h4b95c+Pi9EIgIwX6559++qffeYREX4r43UcPxr0e1kWv1fz2H33r5OQ8UbDg5NpXzWZvfN4Z/dWvPvqjry+8e+92POk3r/rELrw67/7NR8+uIvLXH7563ZwmNAP8KZZMcm9mtvytb32107vOZMjG6vytG7euz65ePX2VyVpAhIPu5acff35+OewMwtE4AUS8cXt5pkwtQs6uppEkbsnMZ8niUj1bKAwm8fFFuzP0G7Nr3/nmg5myHURpbW5p4KURo65bWJifefhg88b2yura4uLmFrHMNIlarcuD3Vez9VypZAMulZD97uDo+CSU6KIftEZpykjse/5kmPheyim186mSqdSPzppHJ5fdweT0ctTreoenV1+8PBwOk3ox9+NvvzVXkUkUcpL78tXkbz45/Hf/8dPT6+5yvTjqtP7qFx/857/79Ncf7hyeDj/66ItWZ5JQN0h5LptbXazcXqr/kx98ZbXiXBy90HhccVE9Z0XBdDycOhlrsZYB6dT3RznXLGbRfM1cX2k4VNNc++bNG8W8I5kPhOeYQIPcNhGh1lnL+/WnR9hdtK0KUCDwk4NO2h8MU0mGPiOGdmNjw8rW9k5aIgVrK3Nrs9kHG/UsFSINhQJSpJxFQoDz5vCqPW31pvVKeWOxVi85V83rV/snpmZ9+903LT31xqNcqTqeJsNef3s2X8i6L/b2LUur5HUedjbXK9/65v2HD1eXFquOpqLp8MMP3z+/7pYbi1NB9g+Oj8+brUmIjOwk5K5bsmz3utXEEJSyTuIH3sRzsq7hONRw5pZvoUytN5xmHDOfs0LBO/1WJoO3V+qmYcYca1quVCqWC+ZMPe/arkDFissXZrJco0fN9tWgr2fLkaDH56PpNNKp3h/6udr8JJGtTs/SCJUAC0+jNJXgejB9ddKGmnVze6PZnnzw6WtKHC5YEPgztQaCon92upDXSnP1X/39h2vL9TvLjZot56pWXoMxk4cXHaAbOYfeXqnmTNQbT4ntUMOcv/XW2eGOyfo//O5b3X5/vpH/s68/mMnT//5/+InmLN+6fePx5586mv3p569Yqr31xoN3337UuTryJ51arSFRrnlxcXNtRjfov/t//3/uv/vNpVojHDVLpdrZ0G9Uy/OGhwDRTLM7UnvnJ5pNRpNxtz9KJPzHP/6OiuIw6N+487A1EP+Pf/sf791/UMzh3/z+99/4zverc/Vf/P3Pbz+4Ua2WbZd0+qO9g9eLszMP77y5t7P/eu9kfWXTcnJPHz+dK7J//ud/ejUUn3/56t6te3v7rx9/+ZQScGtzY+IFF+2mlCqcdAyCx8PoaiTnaLy8PAN0eNltcYkxAAqlMYC6RQquSXmCNbM3TQIOTAvzmAWhb2fM3nCUL5fGY9HqjqljsGBE0uH2QiGOpn4CIbWxSaI4Om5P1xeq23WoTS+3bixdcPXhyZQDuFGj6yWQTQbZrN5H6OUQfrB/lnC2VraJ6UIiiIEEIAHSPt4Jv3jdw8RYnCt97c78m2smJfy0K37/6nok9cxsGSHsmPDuVqWBo1LYfLA5UyyAw2YniuNIGZ+/9tvjZK6oz2hqPBodTUqrdvBww45C8B9+fvLTX1998qI/GQ4f3VzaWqru7x59/PRlzIgXxfWZ2jvvPPB974unZ/Ozc3fuLm9vNQyTtHojgXWkZa4uB5gQk2IphZXJDCee5/mNWuXmag1L5odxGAmgaYVyYTQYOE4h4trTF6ftdi+XzSwvzvtxeHx8jCD6B/ecRs3a7amTs9atCv3mndI44J8cxE9P4kASh8C8hfI2zpjIdRABIGUcEIqJybjSoShQkjE1JdOIJYwiTGGiWBCwUSyGvgy5gpABBQRnGEIEIQA4EUAqKrDZ9ZLW2AcAijRxNWRrhAM5TXk/Us0pOx8m7XEqMNGUzNmUUiiRNknkKFW9adIfBRPPV1IwzgzNioVkQiiIlOCSxxqGAGGIdU3TEFEKYwKA4kpyiQmNmEo40BAquwYQcDgKvERkHXOubMzkCcFSCR6nSZjwNJKIp7phQU2PY4kw1DDECAAmFeMIIgSUgkpHSEcASBBI6Kc8ERBoRAGmIWxrCGENE2xRrBCnFEkmuJBKQUyUjnRN0xEhQAkBeBiHgnOEoBIqSmJEia4ZECIFMQCYK4kUhhIQoHQNJyyRKYMQaTpBShmGCbCSQHEpoYYQgQopSiBEGACkOEdKBmGsFJKcUUWAACbVCCICQqAgEgITIAFHEAHLsjQMFFc8YQAqSzM1rGGKOWOKcwKRVJIiwjnTdYo1QiCWUEEoGU8xpAogqDgUEgGAIUYQQAgUgFJJzgRIAIVYAQQQIjoFSigIgWCMA0qJphEFFOQ8jRMCiZJSAAUBVlBQTKOEIYTjVLCUE4QZVABhIZKEcSl4nLJYoCAIKSGO7WqGESY8TFMuEGBc04hOSdY2slmXIRgnTBKMdR2LFHGg2ZaUII7iaeBPRr4/HYkoSeIwGEwAxAATP4ggQmWX5rNZioniIJRwHLJRwkOOkxQkQhANOrpWzrlZx2BcBZEfxpGhGeVSgSiUcKGIxgQeeaHn+1IwCaWAkkkGpJpOo8E4iDkwQVJyjWIxJyTsjwMvSDWIlVJSMMGpBCaGYHN1NufQl6/3z5ojpFPDNmwqc1m7UKz3x6mfCijY/ExJx/D8+DplihAtn3UME+dy2VzOlQoBgNudznDUWWzUSuWaknB3dy/r5G7cvNPrD3jCvclYMpbL0M5oGofxXLVo6Hg6Gvf7Q0s3a+UyJUarM7rqjBAxxpPJZDRwTNwoF3THvWoP90/a/fbY0KjlGFnXxZrOEdEMczgdE0QEAvVGbWG+cXZ21mxdNsoVrDmvjq8fvzx8/GL/5OR4cb549+amZYAba2ulYunwvPPk+dFwKubr2fZFq9cdZXMVP2LtoTccTaIoqdeqmPv9XtfSgYaSnME252fn5xqdCO1fXPnjdLWMbm4u6ro1DqLpaFp0acGB5YxpaIbnh4VCDmuFTntSzTl6IRcjcHZ+hQFfWshVSqXeaDQZBVYGENM5PW+HEtSKdX808acTzwt0w7EtaOkkm7UShQfT2MmCeqkwjtHVCJgAzOYyw6CvZQ2qWfN1PVdZH3a6gKc5y7YzlTCNp6Pu2mqjPRjvnY2dYtV2nfPjU6KTje0NBVDv+mRufuHiatC8HtqW6xqm500hBBenR1/72sOFpVmBJMbYoMZsfeHxs6enJ3tvvPkGJmg6DUuZDJBw4kX12erG1uKPfvij4/09JoTk5OnLl0+fvf7me7eoUXvx4ug3v/0wnzW/+tWvjSZTyzEm/b4U4u7N7b3dPd9LLNtpt9o//NH3X+4duJl8vlxsti5mq7P7e7u6XZyZX/yrv/5bqpF2uz0Yj3qDwdsP39rf39s7ONjaXKco8b1UYmcwHJdy+Ltff3Pq9XdeXtjZ8sHJaa9zVSvZWLI0DOZnZ/L5wkwj+73vfH1pYXFhdvGnP/np4c5+o1bPF2pHh7tQxo++cu+bX3/HMOn+wS5CsNFoTPr9Ub+ZLdh2xomVcXo1Ors4vHtr/r333r15826xUD46Prq86p1fdJMkpZpqX1+eHjWn0+jV3kmr3U9jPg15mCAvYK8PToejwBv42xvLjx49WF9b/t53v10oz02n7ODwbG9/NwwDjDUI9GDiJ9PxbKP04P6tR++8vby+CQi8PD/tdLqFXKZYymiG7A+bzeuWF3FJdKKbIWPF+szZ8dn52aWhq1LOmgyvNcn+4Q++8rX33ksUvHH31g++/92339j63rfe/vYfPbpzZ311pV4pWKViQUnZ63R7ve50OjIy1sLqikHprVt3skWnWHIfvnHjm+++RxDmACwvLhYL2Vs3btuZwvVVyySa47gqDUoOffjwzXypIaRZrlQyWffs/PjJ48dECAy4PxkPe8PL63MZ9ja2N9YWFnngnZ6c751dvHrxfDToZguurukK0sk0WV1drZSzvc5ZvVZpzDb86USmYn5uoVatRWE/DYKr5nUcMMFC16K+P8i69ng8CYJAIdodeqPJeDQNkgRLCTFBc3MN23Ynw+H1xfHR/vN+v0+s7Nr6jYzrjPudbueiUsi++dYbtUpN10nKWLPdQlQvVupcQGoYhFKFUKc76LbbmKDr5jWMJyqJPH8igCAate1Mpz1gAri5zNzcTL9z3es0C8X8H1Dj8ahTqS9s3Ly10KjVCvazL3YvrnvSLLq5gmYWhMJJlF5fXy/OzkOJh0M/lkij9sL8XLlYjiMWh8HZyZnjOsuLtW73eux5m2sbjmW2eh1AaLFeMZ3C3Y2F0SD6zfsfbN1/82ocA6hX67VSzpqvFPr90c75uZmrvvfoTS2ddtud/dYASRQH6ZPHTx7cvj03W3n/d78TlP5X/+qfuq79q9/88h/+6Q9MA69urc3OLPxPf/HTTn/0xsPNb763qaE0DHmhOrN3dA5J/I//4Xe/eOq9//snD96812m1bFOL/ODG1uY7X3nz8HxYb1R+/GdfPz05AsRY2tiyTP3Dj365uFSbqZrn19dBmP7xD799uPv653/717du3P7ggyf/6ac//ZPv/4NiJvPrX/waQnXz5malWmZc+837n2q2tbm18urZZ6cX19F0ms1mnx8ctwajH33nm1988Xjq+2+/9W5/4D15/HTYuyrmc0nEFdaxbliGYWaM86vrnhdvLi+uzM9qKhEsBAhEfljM2H4iYi4lBJIzxkG3PzFs09YZFwBRS2JoUjtfsLwgJDijm2YqUyfjKojDOMbU9CdTBJDnRYxFjdkZFqZlGy0X9dFEff7sYuipxdWNnKWjJF6oOosN1zXsYOozIQsOMBEziMjYJOPoQILnO8cvXu1/8uWp0pw37j3443e3vv/uGvCC81cHbugvLTUMU4dQqy5t8ELFKtS++s57796rYsWi8ZhFSQxosTazemtu7ItWu3l02VSa+bWvPrh7a6HTmR7snn3xdG//5KqxeGvzzm2L4P7ZPpuMj06OM27+G994tLe//+lHn3V7U8fONmarhUIm9Meua7uuxjgzNF0poRuYYBLH0TROiuXs2tZamiTtduvq7DKbzadMpnFUrxTDcHJweHrZHCBCC8UqQPYwlBuzdLFe7Pni4HRw2R7d2178zqNVILzLi+u9/VM/kYJzqtm6YSBEHRsDHoM00DUsWJwypVGdEmjqVKO6oesl16q7pkEBUMjnLObQIrpGCQAwTHjKRcqlkkyIII1ZmoAgEqOQI6hBSSiW5YxZdMyMBqCK/lCOT0aenzAINF3TdCgcgxKpGOemm3XsDAQYUY1zhoXCAnohT4RUCBmmjXQKASdEV1ywOAIAZx2zaCANC40KnoQxS7kCmmkzSJrdbm8ylUrH1KgUSplcFmAUJzJO0yRKoeQE0ThJGQcxk6mCnAWmQW0N65hiAFIhIJS6RglBUcTGYy+JJECGlJIxSIAiBEGCdISRFJRiAv+QiJFgaZzEECKlFCXY0IwoSeM4gYQiTKCCXEkMEVSKCy6YFFIKhAEmaSo4kAJBJlLGOQIwjgMJEBMCSqVSIdQfjrck44JiAjGGGFqWQSgmmqaANEyLCcE5JxAKyTAkkCuCCBJKCc6VgpBARAiGJE1jqQRnnHEJAFQAEIC5kkKkSRyHYcSlIFRTAENAuZApT4QAEgCgoZglQMEklVxIADBGhBAoBJdMpAnjQvhJyhlDmoaUVEIyJjEmmGi6ZiQ8RVhLOIcIK0wBBBgjihCEkKUyZdIgNGUqTiWAUGIgFOBCOYaOIIYSRV4YJwkkBEmpG3rJMatZI+OYYcymXhSEqZAwiWImAKI4SVgQxgJAgqmu0Xw2S4mWJAnWkeIy9iMFsaHRjK0TakY+73vhOGCDQAynUZRypIRr2iaGeZPmTUMCGUQxJDqCWCNSKilkKhkME9GZ+GkkqaJCAYxIkrCUq1iiVMGEC6ppC0W3Uc1xqS57/eFkChGmGjJ1XdP0JE2n4WBxrjY3U+13+6fnvUiahNC8S3M5nLGs89bg6HzQHXbL9Wwmmz05v+YImabj2GbGtQuFXKVY6Hb6k/E4DMLBqFepNgg1Ot3e+WVLKbG6vjKdjsaTIGaMC7C8uECIarWGlmtijMJgEsdc13U7Y/p+0B/6rdaUapZQcjL1dcOoFAuFDPJiedUd9XpjoGmLc7N5x0SYXl/0puOk2+kToKrlwlyjYhra5clpzs3evLU96PvPXh8/fXXxfGcAEXzrjdsrjeLEH9mmnbHouN8VgE5C5Vqw5jLLtcqz1c7Y643TKISGadcrLkZJNJ3ONzLrqwsrZfPW2pJByJPXzScHA6LT+TpaqjuIhVedaRhx0wANV9d4qiOZxqHS9YCBi+tLioFjY07MdruvmHrnTmO5VmJpNBp6mGhMgXa3d9UMK3nM2LSYMaIghNSSOsFEcm9kGbnWOBhM5VzV4Cy46k2nCVic0RyYnh55qW5KTVuqOu1JPBqF5QwqV8sc2yfnndXVBQOB45OzcYoEtHdfHeg6XNlc2d87aravvvWVRwiRj77YGU+SB3dvpSwQKgYI1eslU8feeFquVi3T7XbGr16+vrxsF8uFeqMShrGhkYOjs/E00Kk57Pffund79/mzw4PdxaW1vaPD4dS7tT3zgx99dTBif/mXf7cyX/nzP/+hSNnpyQWWikD46M37URjuHZzNzM/yOJqbX2Bp+vzF67Wt7b/62V/lc8WZudmDvSNTs58/fWbZLkF6r9uzHLtULAMAf/mL3yKloiiQQqYIu/nSdav/6I1txyLPn3y5uLLkBaFrOfdub80Ws7VSdmF+YTIc8shbXKgPB729w6Of/+I3jpF/+Og9SLTr5hWPp7dub5YLTrN5uvvq+Z3bN9566/7p9RnnfHtj8cbyTLMz+vv3n/71Lz6rFjPf/MrbrYv242cvnz5/USxUypWaFyTTkZ93yWgcOPmiwjrnwC0UdSu79/rgl7/55N//L393ftmXwHBcOjs/I9IYKtFrtS570/HEr89UlxcXMQZRHCnFVpbnbt9erJZzLBFeEMUMXF93u52xmylqhrm0MPvjP/6eQTQvDAdDj8Uik8kUS+XhcHzdacdBWK6WTJPMNvLvvrnpOiCNJ14wPT27SBN/a2P53r1NHnWrlUy1kol8fzLxF5eW19aWiWZcXQ+uu/3JeLB1e9MwzcgPtlbn63m70zkxstmL6/Z43K9WXNc2EUSJkqZjlctlRIFGyXQ8GU2DvcOLi4umhumtzbXbm7PLixUeTXP5XDbnWCZ+eP8ugmjYb+dzTiGbsUybaDifzQSR93pn13BzHEBKjblG1cRQsen54b6O+fbWmpAiDgMIYL1e0imZn6tsLc8uLlaZYITauXz+7Pj07PoqCNKLZuvyuttp91gqM1ZmOpm2rtujid+oleYWlrxQ7O+dXF31EEa6od/b2oAInxydaabDE17K56M4fPzy1WDsEd1gnAkp8rmc7Th+GA3HY0vXlhYKG2t1BOR06g8GQwgptZyUKwVQEHmzCzNEkYujMzdbXr+5oRukM5gEvlfMaUv1Ur2W/eLpQbcfdPzw9LypAJhbnDm/OD8/v7LdnB+njpujNur3OxqS5VKuUqlNRuOTk9P5uZmtrRWqSYRBEAXzs/NH+8d7B6eGW2jktdvbuV7Hf3VwypXW7PYdx0QiXaxbjXrxuMX//tNnCOO3H94KvDGxKl7oYV2TEBwe7d5cW3Yt45e//S3G2ub6ZvvqEsH0K48e7h/sPnjj3mASvd65wJgtLc+5thVE482tuzv7nRcvX339K29tbBd++/4HuYK9vFSAILJN/Wd/8/OtG7c3txZ/9Te/rBTsRmP17//uQ4yNuaVlr9vyxiNq4C+fDySyaqXMv/hn358MWgBqs42Z0J/87oNf3H/zoUSZL7980mr2b2ze0CnC1Hjy5Mnq8oKTzwiedFqtZqtfKpS6F/v5gvXD733tw08+zBfy1VIhW6r3B5NMxmp3upphsVRajr2ysjDq9Z++OuUClTNovl4slXJSgWa/X6nM93qq7/NiOScCDpWaJgIj6lIQpwwgMpkmncm0Uqv0R2MvCDUtqxvUpMhxnVbbK5ZLccynUVyvNQajoW5qGYdWHDxXQiUXeBK+uAxe7pyU885MUYcsDD2PYKQ5NjJzFCsJVOTFBGu1ij07YwEue2PybKf3lz//8vDkEiN8f730j//kzp2VrE1xGEfT8bDZbH367PDJweizpwfd3ulKdeYHX7+1ulYZeNOr824U+3P1+uZKseC6TKJne70vX+872cy7b22Uyzmm3I8+v/7Nx18AhgpZy3XMYX8ohTo4PMjZ7ttvbfeHw6fPjnrdccG1Z0qZ2WoBQsEl50JCDIuFkoF1KQSDSERpp9O1DGt+aUHD0A+mfsCo7grBF2erd26sCCb8FLR7XhjGXsgvOgFK4rffWC9m0dVwunMyefz0cG2x9IOvbs5WNM+PJ16QAjkcDJgkkgvd0IqFrI6hkKllZwDGXhQTTTN1PQjC8XAMgcyYqJyxIJKCw4gJCYBjahomKZBRKqIkpZRIqThAMZTTREz81AuZbhqWRknql11ads1ixilmHcvQJISTMOZSIS4tE+QsolMKFWcswbrGVcIlgBgjrADFUsmUIy9mnuezMCRA2gRhJBmXKRPUQDkbl3NZi4B8xlFSeEGcspgSDCHtDrzuNJxMvDgMqAQSKF3HUnAEkcIIEYQICuJo6idxzBXSFAKmRjUIKUEYQZ5KgqGJCaZayuRoEgdRkjLOlUiSlECAhbJNw9I1TUNeFKQCEIwskyKCmUikgFIqLpUSgCuQKsWZ4EpiRATnEijOlIJASsBjliQcI6RDomsGYwpAqDASAvKUW5YFFZAIAgARwhAogBVLmYQSAgglZKkAUBKKGUsIQgRDjDElugAKAiABQIKlCBOppBSCEsIF54JTjIQQlGKiUcGlghApRYgBISYY6xoFnEvJBJeCM41qWEMSQMGATjQlAMUQABVFccJYkgqsGQAhriSXgmAEIWBMIEohABDDOI6DMIyTWAJEMBQp5yzVCAYCYIQTnkIBIAJRGnHOlRIQQAyJYpIQomPkOtQ0CNUJg4hJyBKlAMjYGkWAc5H4IRQiZamSgCexlClGOIlSyYRUQMOEYmRpRNO04SRkgiqlRUwmUUwQlUAOBn5/4A29NBDaOEjCINApKmS1nAkdEmc0YmpGkMS93ihiCSbEcTRD09KUMYnCNPUnnklwJuukWBGdcK6YgKnAMUuhFHlHa+Q011a+P2n2ugGDSnckUhBglsooFmnslS26OJNPwuDsYjAKRZQgnSS5nOW6mZHH+qMwicOlucbq0ure0akXKts0eTyxDGTnc5ls/urq4mDvSNeNKGbzs8uGae4dHfaHEwRwxnFY5Pf6IwGkFLBRr+rIPD6/olhYhqMU1PWc5JKlSeInYRj3h1PLztoGDqbTarFsOnmFaZzEh8dXLAWFrPXOw7uIsJAlKYfX7S5HLJezHt6/ky86QRKEgb8wt1Ctzu69Prtud9OYOLq5uVF/+OAhV8He0aGpo5mZSpRyhcD+/mkUsLt3btVK+WkIXu9eXV+PdMvI5m1Nk6YuLBxv3l6bnZvzRi0uBRf4i92DnUOWz+eWa069mnOLpaPzVppyw3HtTEbyoFF0y66NMQojGUQJBtKxKMKc8Ngb8ZUFq1bMtM9OAz+Ogd7qJ0zp3cHI0eFb91c1lZRq1YBhYBR7nYENRbWQtS3rcPdI07GumaenzXZ34tpwa3O127mMONDNnG0a4aDba3nFvE4IzGXs3b39USA3NjaDKI6ipJa3J71LKOPvf//bFJFW8yLr5r0I/Mf/9Jdx6L358LZpa91e29Qdk1pAgfE0GI0Go/7o9av9y6try7bnZ2cbM7XRcByFQRilEKlMzun12guNuTCMXu2+fOvhu4+/+KxWm7mxsfGtb309mIx/+5vndtZ68OC2aWvv//ZXUALGBBOq2x1+9vjZ3Fx9Y305TqJyLr+7ewKR2euOR9OwVq5enJ/mi7ko9SulYtbNXl9d1krlyXA6Go8BAI1adX5xwbbNyST1B92d519srlfv3333Z3/182yhTA37ZG/v5va6Nx0qwE3bOjw5kkysrzRkHH308SdCksWNLeoW/vJnf/t6Z8fS4KN33kFSSC46nc69e3fvv/Hgsy8/Ixhu3FhszFQjJjwvMjX9z/7sO9//0XevL08OD3cH/VEhV+gPevtHe9Qgf/6P/jRr29QsRCkXaVLImiqNjk8PudIW5pY3Nzdv3Nz2/cnR0cnjx89evdppdVpMqTie9gcdJUWjWl1e2chm3NG4lcbjxaW546PdJ08fv97b3Xn1slTOPfrKO1Nv/Or1y+bF1cHxSW84LZTrt27ddh0aB73To5eT0QBi4+79R/Pzq1dXpxTjjFMMg0DTaRgEo9Hk8ODwt7/+3W9++XOkgD8Z7h0cU92FyHz67Onh7n6a8JChvG06buFw//Tf/fufBH6/07oYTbuFfK7XaRdyuWw+G06mr189OTx8rSSHGgzCtN8ba6auGdimSoh4MJxARQwTbm8tlQrZ1ZUVzmLHwpV8oVywR5NJezBCiNjFMtHNldW123duOZaVz2ZFHB/t7lyf7RmWWSqV8/nK7Vs35hpFb9KZ9DvlYrFarh2dXSFKbENvlF0dQ6BQ4AXVcun+/TuLjYbp5ubn1jZv3HQcOpz0fX+qEezmcgtzC5VqY252bnGmlrFNqOT1VRshZJi2ZTuHB0efffbJxBvFLFpeWt5cW479OA6jUX8geGwSUs3nb25vLy3MPXzzznw5u7648NYbt13HBpBEjE0n4cXV9XAwHA+nnd6gWG0wYJyeXobThCJydHreub6erecth967d3N5YTb0Jt5gjDHTKSpkCpVKbTCOuAKua0KVUJoFSFVq+eGwVakWb9y8iTAa9kbry8txHHlTHyngZt133nlD19HpyX4ua2GkHn3l7auz1slZx/NiSnDgTy6vmuur68tLy2fX0YefPeGhJAQl3mhlZZkBUJ1bQkibjIfvvfe2P57+9d/9MpNzKznn+eNnD964RYlMWbB9c2137yQJZeilW+tb5bzhmihV4Be/3u976Z/+6DvDwaTf73/zG984PTj/V//6X+sG+eC3v/zf/W/+mWbof/uznzu29vDhrY8/fpIv2tgqnx0cvPfV93QNHR1dt64uq1n5L//pn3DBdR0tzq+zFL3/m19pBGq6/cknn1MdsiTyxldBGHT749nG6q37D6YJGgfJ8kwplzWfffH7RnVmNBwfHR8UKqUk9C7bHiX2QqOcs6BtwCePX24sz9y/VTw59a8HA8WRpqOFkmkZYDhkgGA7o12fnuVtB0AwTVUgVJDGOQt3B2OMSBwp0zQcuzSeeN5kOhn0sMDj/hACkM/QxJtYth6Fajwdc88LUkZtgkV8b6lSzWCoEbNQABoYdwc4Fcsrs7VqodcaNy96LA05svVMrVBZkJKBcORiQi2a6K6WzYxG8cdfHFxcXHQur5onR/mZutZY3lhoVIt5y8i4Lp6M4tOO9/nz1quDnVHnbLme2dxa0QzSOm35rSPMg1LeevvO5tZa7fzC/+1vX5wdHHLJiUGWt5eTib+3s+tFENtlgW0h6HAw2T3a2VxZu33nHiX66VmzPxoGScgkA5LqxBAAdLvDIA4KtUKlWoomUcqlkqTX6zqWkStmCUUsSYaD8cHJcbvX3N5cXludT5L07OxSAMRFcnxy/OHeYGV97htvLIpUXE3Zbjf88tMdLMWtjaVSMa8UUhILIYNgrBQae5HgAgCApMIY2KbFGW9etxVE1MmEHIYCKgWRlDalVEd+ko79GEBiWZQikCiVpIgrAjTNoDqCRszEOGF9P2kOpgJCJ1/CBBi6ThBSilmahhGGEgqhlI4sqhuG5prYsR0pUsEYxTSKAwSAYZgGwRqlLGUJU6kQFGu2abo2yeVcQokXRZwJTTOgSnSEXIPkM7Zr6RBDrFih4CSpCINUs1zLNqQITYQIVIZBmYJCCp5GukUp1ZkEXszHQRRHXEEiIYAQ6TpyTIMJzliKKCC6nrB05EWjiCeCa7aZCikJUogiAg0NWxqlVAKElJRAKQwgBIoQijEEOoYAQCWTlPGEAYAUhBAiZOhMSAmhgABjrJkGxJixFCEguOBCEYI0QgWTTKk0jjhjCAIIiZJKIaBDpIAUUiCilFRKcQgAVAoRJKXinGMEMQVIQkQ1iiBgTCRMpiwFGBGCBQCUQAgRSxnGEEiuoJKSCykwxBgCAYCUACCg6ZoSEgqggJRAAggUUgpiBRAmhFJCMFZKCiAxUlJITDSACARQCBkkLI25BAoTnUmAoUQYU00nBAGpmEiEFJhirhTjQidUo9QgWtY1qU6pRpASGccwEAQATvx4NPWhQoggakhKIAAQQsUhCtM0ZhxgYRnU1DQAgZCCEKBjlAqmaYQL3u8NY8GH07g7iYI4pqYOkPT82Jek50UpBDHjSPF6Jb9YcecLxvp8vlqwDZuGjA2mUcyUm8naGgKKszgOgsRPRJxE1YI1U8oYFGAlKNAlFJ7vh3GqUwMqmTdwPa9JQAfDKGaaElgJJTiMOOOSK8ZLeW1rvWqbtDf0r0Y+4yiX06tFvV4q9cf+aas7GY8qxcJMubr7+ujwrJ3JZbI5s14tWCY1DIMJcbB77GazSCeEQEMjX3z2eDIJy8UiQdJyLGrogHFH04HiGMDdk6PBKKlVMrm866fJ5fX1cDyg1KC6FgQp1QxNA96036iXsWEEoUpSfH09gAiUq+79uxs8HTebzVp97vjySrO029ury8uN0XTc6w3T0J+v1RQ1/vKvP24Nxpl8EYlwdibzxu3Ng/2Dly+Pl5aX6+VSHASDiX951jGRePONW7lMcfe4+bsX5wCCzZVyOWNYhmiUjJmqs76+biKtddWMkygW2tHV+MvXaS4PNpcz8fDatrNnXb834dm841iAJXGKVK2RC0O/PxgLocqFTFbHc/WcaVoymK4vO4vLa2enBzwRudrMxZCfDliK9SQE79wummxk6FosUWcQ+XFoUzm/NJdINfJG3jB0XSNkcBwoJfnGfF5K0Ozwch7omDkOPTgfOy50TS3nZpIkHPQny8uLl73x+fm1Tlklh7Im+yd//keuk/nwg08l0ABALw/b153w1q359dW6Px1DSe2sc3XVEhKNun1T1zwvOL+8LBRzhWIuk3GUBFESWZaDICjk8+2LiyiNY558+fjJD7//g8Ojk4ydX5ydiZNUIvr+7z7xpn0CCTXMfq8nBF9YXHi5s7e0tPbqYN+xzK3NJUMDWVsnmvG733+yurwshbAsaxJM6tXyzEzDcey121uD3nBze1MA7nkTg2pccNPU37z3YDAYv9o7Y5ILPvrv/tW/frqzd3A5rTeWjg9fFXI526Bx4AtJH7848yI1P1vLGTJixDJsDlTK1O6rA0r0Wrl059bmcDy9bl4nabq4uDjTmH38+YtcrlwvV0N/HMdBEvGsYdy/s5a32Xhw7cfcmyYIEUjNjO3c3tr+9lcf9Yftly/2m72g3elubyxtrNfLeadaK8ZsmrJgdXk2axPfG7iOszS3sLixns3kCNaSYHJza93S9CfPXj578bLba1Nde7139NOf/caP+Ttvv700V9/aWChk7EY5szpXX1taqNRmh6PIcIqWoQ96l9VSrlEuOC6+d3ujVC1GMrm6vHr3ra8vzK+cXDaLMxWqwVqjdPPWdmNmLpPJ1KsNTbcts1CtrDBgtnodyYUXBp3BkCuxurw0GI76o9EPvv/O9uba2uLi/OL6VbvT7w/LebfXajPB6pXK8nxjplI93Dt8+XIv4NgPoU3xXDV7Z2thbXXh+uoynI6jwB+NxqcnV2EQSiV73fb5VXd7dZVAOIlSkxr9XrPZujg6PvM833DtOPLfe/ve+vZqKtTJ5aA1CovlWj6fU4AbJvTiKYO42R0qISeT4XgyhQAcHJzuHh2zJCpkjayrX15cHh4f6Dp49OjNe3e2oQZTwUMv6vaGu3uHw37HzRoLi9VM0bRcw9D1IPIJkO88vHf/zk0EoRIg9D1bI65JS9Wy7TpAynb76ujsMJoG183m2cUFA6oxUysVc2sLCxlTT+LIMvVatWo7GSfjhmEScdHzvZPL5njiTaaDgZ/2J16n3XENLfCHrgln6vajt+7c2FgJPK83aFcbs2HCBpOwXChBELSbvbOz8+XFecew9ncPMtlcpVQwTOOLJ891zY7jOJPNnJ+dta/bb7/5YL6SGw06C/Pzr54/3r6xudSoCcGiiGGs7114xxfnSzO5uku6o+BiyuYWVsCoN+k1N9Y3W61ufXY2m80YGvnj73yzXrLffPCgP+qnIi0W82uri5VKdnm+cdHtf/bFzu8+ftHqTdZW5mU4+OF337vog88e72xurd9cdl8/fdKYXR5H7Pef/P573/7W6Vn75Rfv/8t//S+sQu7s+Pk//MFXSiXzv/+fP99+8CBXylAA5udyQTi+eWf7+PW+6bg3tzd1Co4vDufn5+dqc5x5lu3U6/WD/YMf/8n3l5dm/Inf6YYffPSEYD3S+0rbAAEAAElEQVST+/+x9F/NluXnnSb2t8uvtb0/3ubJc9JnVZYDUPCmQTbJHmlaPerxo9FEKEYX+gjShe4UoSuFIjShVve0GbLZTRIAARCuqlA+K33myePd9n6vvfz6G13UfIw33t/zPNY//OGryXj+xv13KNFSFrz95ju9Tg8JqagkAdovP3hUrJa2N+v379yUXLx4+ezN2zdVkx6ctLiSk5FXy+UWymoUo9EwrC5mAIswFjEGMQcRwynjKqU6ISrGMgaMJYZlmIpeLOQkpoaluUE4mowK5Qw1FJ3gai0zd73hwEdCKWQLSTQt6mKlpJYs4McxwtgoZQd+/OLZOSb45s21SllvXU6fHfUuu7MY8GIxky84KY+kQGGaylRijABWR9MoFnCe4P/v37z+178+DPz50tauruK722tv7JUxBMMYdmaoNeUXp83pdJatFOv1gm0YBRUNu51o1rqxsfCNtxbqVTyNxFXPa7amc9fbWmtkstbjF2effX7gziFC6tbWer8z+PjjT6qVarFcHM/cQX8ymc6TSEYJG4wnImK+F3db/SRmK2urpUpxPJlFfgghCTy/WCpl8xmqEMfRKTHbzQECJJMxt9YXstms706rlfxy1f4Pv7t48Wr/u/dW37uRG43nE6a1fHjaag0GfciiIE0lpGbGEUkcMy9NUCxhLGGQcsYCy1ANlQIouxPXi5IoEv2h50sYcmboWCcop6oJJ5PhWIHSoQim0pd86EVxLBPOgyCQUkCEvTjtT/3LUXA6nI29dDwPOcAEEkQRIUjBKBYsSYQX8zBMpJQUSds0FQoA5BCghKWSp6pGHIMQJKEQYSTncZLGgUy5o2uVYpYSMZ24PgeKouqaSiEwFWpoqGAY+aypQWjbVsr5dOxKiVVFMRWSsQ0FQYihSBKEsAIAVagUfBpGUzfxojhNEyAllCmRIJ+1LV2llCgYaBrSTSIxiKI4jCVnMorZzJ1CwBGkTGLTsoBESRQjqlBCAaax5EmUYkh0SuIkZQzohgYQjOJECJDyFAMhhWScQS45EK479/0g5VDFKsYEISkQTAVnnGOAKFU0UwcSJizBABNCpOQKVRFGUEKWcgkhQhBjAsTXES/JGQMQASwRFJIxTlQFIsCAxAgyJiFGAADJOIQAfd03kwAChCAJwzBMU4VSiCRCQEiYpDwWklCFJzyKUwAgSxMskZRACokpTqMUSoQwgZgIzjlLgRA8ZUACoiCICCQAQcY5ZDyhCHIGpAAUUSSl5IgLjiEiRAmjBEAOkcRIYCgd2yRYYkVJOZu5LoAQIGjqyLEzgnEhZZzCsR/Mg9g29ELGUXQ1jKIkYvlslhBs6JqBcMqBFwkvEP2R5yWJH0dAQN0wI8YnQTpwA4kE1RRFxAsFe6Vs1gqmoykQkjQVs3k68TzOhEoVIgHkiYpBDCiTJI5jy9ItVdEpjBOecpGKlHOhKdRxtKxONxr2Si3vWJqfyISxJE0jHnORapQYumqatFJSb1zb0BR1NPSuRsnE51KGyw17c3Wj2R2+et3yg7Rer5Wrxc5wen7Vy1p6qVLKFYpJEgOAsOTNq8t8pV7IZQPXL+RyrXYLSLBz7RohOsGwUCiwNCaEaIatKHQ6G3HGlhYXHN28Om+dn1wmcVitlWq1ku8zJgHGwHVnpWJNQrD/aj9Jo8l4xIGyXK9aFg1j7/zydLmxMpn7cZJc37lu6mg6HGGCTc0AWLvqDD746As/AfmsEfuzSr2wsLj05OmT3rD/xt079UqteX7h+hFSTOoUFpYXYTx79vz5/tU0n8mura/VCxnI3UJe1VWqE7XbaT578ZKJFADNi+Dz46FU4O29WkMPrHxp7KcPHw+JrugUoHguOatVFoIg6U6CcQAa9bJOkaHB4WBEQdxYLPkhffH4ueeJQjnv++L40kVUoQqp5kBBx/3+lFD1/LIZM4ER2K6ZGDMBoUJ0iRBR7WEA/ETaKrQdpdntSBUTBCuW0AgSirNQtpJUCmKfnp3nLdSo5Z8+fsKl2N3bK5W1d+/fzNv2X/3l33rzxDQyKhFpmr7/9ta9u3eaZxd+lGQL2eZZO4mTr79hhKhxEN1943Yhn4GMx1HEAQq9wLT0y8sm55xqtmUaQMLbN28ywU9OTh+88+ann30yHPXOL5ssZosrCwjT5eWlV69e39i93m61ZuO5lPz8vFmtVZcXF87PDiu1hYOjE9s2f/TD7w06bdPUl9bXFUs/OT3ZWF0FjDWWqoVC/quHX97c3UUAdDodzVBm8+nDL55iqucMfXt9RbOMv/6Pf5+xC+Oxq6uG4eROz8/yhbKdyw9nQWNxkbEYxAlScwIr/njujgaNxcrCYoXzsNO8urq60BSdIKAp9OTs1J2N01QcHB3mcyXTLgvOGQuPXr1QKKLYOD1t5rO5xcbCZDDq9ca9VvOLzz99+eS5qhtSxNe3F4r5/GQw8KO4Wi5/7xvfKThWMO9gkbz55t1vvP1AyKR/1fVcr9fr50v11Y2NbKVoZDIzl6dcvXXnwY9//KN7b9wtFhuDyRgjgBHWNNE6P+GxP5+7r49evz46bl01W+0LINNysby+vvr+O281KrmSo7148Wh//8Wz/SeffvHpVeu8c9GZjsZUVXWdOqam6SRkIpVkMJz/5ve/Pzx8ulArXbu+vby6WcznLVNrj/yj16+LeXuxXnMn3jzwPvvkj1knc//OPddPc9liY2FB0dXq8rqZLxeqtWK54sdRb9BmLMllMte2VkUSFUv2+vqqbTvNXs8uZDd2Nq5tbWazmZWV5XzWHg4GgLPJqLW7s1UsV6dTX9dzxWLBnc/COOYJnEymhyeXXz188uWjp5PxpFGtlAplyThLheSs2+lt7ew0GsupgEvLjf/kz/60Xqs0m1cKkdevLdUqRR7OB/3LwaBlW6aTKeimkYIIQqTqChQxQdxRDdt0FKL0Oq1RvwNlDDjLFbOT2QwosFDMIgJYnHZbvZXl9Tv37pmqBTCqFCpJ6A3H008++fjZ06eTyaCYNRUooGSaTiFnnLNczvG90cZi7fbu1u7etc31TcMq3rh5Z2ttg0HhmGq15hQtGnqD0XgGgObOpqE3UhC6uOg0exPTym9ev3V2fHFyul9rlHu94f7rV1HsqwQixRiMXMM0BEukBAzA8Xher1Wb7c7m9jXNwM8fP7y2vZgxSBqMFxaWHMdsX/SDYadaoClXnp123Ijdvrn69PHjcrX05ZfPXzx/KaLw6PCVlGx5YVkzqUb049fng8GssbwIAAMs3VqvH7x6DJHzr//9bznUDEUUHNoo44efPPzot79bX1po9gZfPHz45//kJ+dHh08efvaP/tGPf/7zj8/Ojv6Lf/bfFMzCcHj5f/kf/9vRJP33/+ZvaoU8i4NqyTo7O/XcZP3mGw8fPfLC8bfff69eKQ76Hakpa2urUTSvlmvNdvf49OytN7/JUiYw6A283/zD7/78T/9UBNHf/ObjgPOVza1X+68qlbJhmFjBimqvLzUGrvz7330ahMj3p289uJZGoRBod72QpglU0WToR7NetWj7sRgEXi1fwFIwf17OainEs8HMdSOJsGAxEKmRoSeHxwaRaRyP3ZlhKIIBU9MVovheIuMk8LyFWl1TpC8IUnW7YKcRU3S8vZKFaaqK+KIVHlxMrfJyoewcve6fHp0W64v3v3ljfbU27HmPnlydnbXqC4s39jZzNg4HU1VXTdtRLCNIUGcQLNQ3tlYzvUHyd786m01djEmrfbizmr1/rTKZ8XGoxtDhxLg87+zvN/cv3RhmNu+8cfPWIsZ0PjhW2axcMZx8trG4RBAcD3rDqXd95+b169f8KGh3L9rn57Px6PrO9XZ33O60SgW74DimYWUzeUVVMZBpLOYhz5crEqCXL1/P5369UdFMIgQQgLd7vd5waKo2ksw01fW15YSDr568hDzWCC/mM4ORF/vyW994Z2G5/nd/++X5Wfs7b9/87v1qOp8N5qkbaUDLRDHr96bdoY+QUi4XddWECLIoCL2YqppKdJnEtoYzVtYyHEUIjJI44qNJ6CdSSGErNJ/RNNNQNUwkzNhGxtJ0BQkG/JBJKE1d1aimEOJYBsRoGsSuL7rjaDjz4yRikkkuVAWrKtYVhfMkiGIOSSLw3IuEEJjSrJ2xTd3UVSBiQ8OKgmxHM0ydSzQP0lmQRixNRKQiXi5kozhlHKgKJRggKG1DwYyDNISMQcg0qkgpp1E0mfkCE6KoKsGWqTm2puoaxpASBJIQEUgpkZikHCRMcghjBhgXMQsVhLBCFYSwECqllBKiKEnCphNfSEioHoRhksZYCiFSlQBCCRcAQSQ5I4CGPGVcCi41XU05k0JCCQAEEEIhUJxwLCUWMpGCpwISAgBAQHIokySVQgrGVfJ1h0xQjAXnEggiEaIIAokUVQgpuZQQqlSVAAKIueQAIiEgQUjRNSgBlAIJCIAQiHNT01VKkiSVSKCvE7yCUYSB4EJyIBiAAmOAKQJMspgJCb+WkmKEEQRxGCOMuJASSEBQnMZC8DRNkyiCEksgAABSiiRNKdUQwAhDSgiGWKRcxkzBFGMgJWIggQhABCVEBFGIIWcCEyoAQ1BChKDEXEggEcFYCDmaJb3BjEmoqwpGEnApUhbGSRSmScrjiMUiFYJFnp+EMdHUrKOxOEZpwhmThAZhNJoHkygEBKsKMahmmOZk6s2mAeMCIlyy1ZwOG0Vzoew4mi45HPrxac896YX9sRclUlcIIdwgwNANkfDhLB67HpMIQCww7U/9IA4hoBDjlCUYYUfB+YziWIYX8cv2vD8KU4ZViS1d0xRVV5ABgaPhSsGZz71Wz3t9NWh1+vWS9c03d/dWi0et8YvX51Lwct5YrlcGnW6zPSKasrJUNgk/OT72UlAsFBPP41EEkTIeD7KOgSkedAery8uEkk6vmcvl/DjsDgZz34tCl3GWxMnqymIxo5y2Zr3+pFKqFXNZw1T9+VzKmOqqNx6WygvUzH/x+CCTUa5tNagii8XK3J9LBqOE12orAvHQm11bXxci7nR7mXy+1Ry/PLpCwNx/cRbH6crqYjFrF0vlIIGff/WqP2XVWoPq5MnTL3THrtdWhwMvCFLB0MFls90fY8XJ2RILr9W8yNlkqV6HBHX6s9CPFxeWwxS1el4cAwaStbpTyGWCud8LCx8/bBKNlXIZk4BK1t5YsBVKJj7oToGhUSnjSX/AMco6GdNQP3k+PmrNUmqmDHSG/mm7F0qgIJklYcOh49F0FIJZDLqtflEHCyUH40jDlBBnOpx+jYV0Jj6TopqTjkqSKJaQaArKmTqLI6NYTtMwBciPeXvoXdtadftnpoq2r10Pw6jdnlUa9d988MVkElVrdYxkzjKuL+cqjfr5eTMCyJ353jwSUHIhwjC0HXs8muaLeUtXTV01TM20DcHiYrbQvGxmLYdS6+j18XA4rlbyQeoPBv3NnTXB5cuj8/XVNcjib333/avzk0a9dLB/oOu2APTlq8OdG3tfPXy8vbNVq9c9b4qIls3Ys8n4Rz/43uuXj+bu4M/+5E9zueKv/+G3iq43FivtVhNAcXx6Ul9atLLZV69e2qZZb9TPzi+iNHnrwW0rm8FYO9k/ULDYWKtks4WZF7u+NxmP93Z32q2rOE4TlkZh3OtP98/a08lYVcXacmltsZozraXGYswlRjiTyyZppKnItvRKtQh4srxQW1iqtJunVr44jeaFSgZh5cWrw3I5v7G1cXJ6fHJ2pai2JKoAeGFxaXljbWOjHiXho6efzf3QyeaSVD59/nAWTkv1OtEtUzU6ncvRaFSpFoqFUtayDaswcaOTk3PORczFRXPwyR8f9wfz9ZUVXYOXzTYTgEJpKorjmBlH0TTd1J1cxtjZbvzke99bXlr56JNPHj57fdFsd9rNKJzkDZ1QlWDy05987z/9Jz/WdKM/Gr548fL05CRbsNZWVq9vrQ277UfPnpsGWsw7BuaFjKNqGEGpQB5EjCA0HfTnkzHR9YPDk8Wlhc3N9ZPzi9ncB5jO5vHz/bPf/O6Tx49fz1wZRfPra2vbmxsM0teXrZPzlkJxIW9pigEByjiWZdKzo5PRZMpkylI2mfZu7C7vXW9YOjApXFmoJCyMkiAIXcvInDa77d6oWMj94Ns3CgVNUZT2cDScBkki0yiyDXDn+kat1uiNg5NW//KiiUQ0HXdm4zGTIE7SRr2yvr6kK1QkKaH6/sHp1VVLsDRnOjnbtq1MLpcXEPrz2dztX56ecAmq1UImk/W8edYpLy0unh21z89azfZgNBhNw+jVweHpZZNxOXanHKOpN5uN/JTTbnfKRdKoZXI2BiAZD0dcMgrB5srC9kotb6NayZqNO/PJ6PXB1VePXwzdoHnZ7XbGlUJ2e2ddweCyPW6PRsXigmNaEktC9eks/Ztffp7L57e2V2TCS9UyUuHc9XRVTdJQpCKOWTaXayyszLzIcEojdz6auSLhrXbvzt23Xd/zZ5ONlVLgupiC5aUKQqyUz5o6ATIxLfvicjCL0lqxfPDy8M237h0fHJUXasuri5989Bnnyotnryr1BSeb+fjDDySHukYH487yQnlj0X7zrbf6M/Bv/upna9e2MgqsOzhfqrR7V2q2tLi48tvf/bGcLdzb2X765PlsHn3npz/8l//y118++uR7P/nWr3/xIUf8//l/+88//qr7+vjs1o2dv/jT7y5Wzf/5r/6yO/Qbja0//OYPo1H3xz/6QS6bi93J5VWzXqs+ebW/ubXbbPb+l3//l0TXrl3b+pM/+dbx6cHF2ct//r/77icP/d/94TEWkKXs+PhISDob+Z47hWnw9s36/lFw1HW9GCERLtYKvfGsWC5lHbVRdHSNv3x92FjcDjhwZ24+k63lIU7ntqmYKnWjdOLNNCs7j5maq+fqy/uHbVVVarX8xOWS2Iqu9wcTSoipG36UzBMRxIGuK60xOHp9lAQR1nRvGt3eXf/xG2VVMmqifoheXPQlNhaXreEUfPTJ0VnX22vo798t3d5Ukoh98flLEYv33ljZWgDt/ngaJklIMhk75C4E8x+9e/3798AgBGfnvdrKcmciPnl8vL1z49279XZretFyQ2nZRQcS+ulh/C9+ffDwZbOQsXK58ubG5u5Gw8HId2dSinypQDTlrOU9PjjcWCx8483VWk7BmPWHvZTzN+7dnU17hgoQkZPxFBJsW4ZuKLqlJb4fhb5u2rPR7OysVa0t6hZFhiolUhU1TcTMdbGuEQx0jHP5wnQ+m7meAJzoZn/qf/Zkv92ff++tRdcVf/X3L85as++9uf2jN6omja/6s+7AVXPFFJHx3G8P3PEwAIA5FrZNzcgZnhcmAiDMEIRASpPyfNZYLGdLOaITjBB1536cMEopIpAL5KeMA2hQmdWwrnKEEgGoRBhCoUCpUWLrqqOrhECk6DEXacoBlAGLgyBKOVOQNKgOAPD8IGYSakoQsvnMdX3PpMTUaCWfyZmqY2pYcs5TiKDrB/1ZMolgFArJgUgSwzR8P577cwgJF1yw1DQN3aIISIIUCmPTUA3VmAVxszuZuOF07gEIHIKJ5BgICIBpmwZFmmQaRTxJY5amKZcQJlzEDElC05RDgDBEFBMDEyhlPmNqqqJqFElAIQEAASBZKriUEAAkhEw5QVhRsIpRlCZhGAMAoAQIYQQJQlJ+nRaWIkUAEBqlKRcQCqCZOpNcCA4ASHmCERYCIEIRlFwyICRGCmeASxZHSRKFnCUAQZakEgrBUggAS1gSxV+fGYKlUkpCFASl5IBzyRiLBWMEQQURKL/uhMEkZYxLBVNV1b+e+CCAqKJwyQHjCCGFUCkES1KCIUZYAkSQAgEmlBqaJgGMUikgY1LGaQKlJFjhggEspYRSpBwIgCGkWCLAuWCMsYAnjDMJhBQpAGGQSMkJhRBjomIkEY8TCARGKE3TuR/NAx6ngCgUSGmqWLf0KOEpQ0iBYZykAEGpJCkQAkoJKVZSlgiQCoiYYFEUBLFIJPs6O21gRUo2c13fTyTFBiaOSbMmsVRUyqmOhoIwOL7sHV4OzjqjWEjLMnOWTpHIaFoun49CNpqz8dwTnKtIKlSZenFn4jGmYIQwQIQqCAKFQsnk1Is73eFoPIMQYJUIAqEQCKRcpJDwvGMIkE7mfrcXjKZ+zqF7O0uWQU6Or07OLnPZXH2xUSoVPS+86gz8MF5eWCiXy/3hSLK0UapQlUyCuaJQKFkma0NEz06bUAKARLfXKmQdrJqJFwOGLcssFYpS8mwmG/jh5XkrDNNcIRPGUw6kSEGr24WQpHEICNEM4+jk3DK1b7zzbpr6CMgodjWF1moN1wul4JblOI4BQRD7XqW+dn7V6vY7t/ZupAIPZ0EmY6/XHUzV0Xg47o0IpVHo1aoWEFEmV1YU9bMvPplPBxoh/eHAyRTtTE4lwkTS1pXrd3fWN/eGw2nzsgeAyOQq00i22h7VqUaRiVkpC6kCglg8OzxVCXj79kK9pFu5jCR41HdFFFx0hrEEpqng2K2UtXyxEaTi/HwwDZPl7SUjmwtSoBt6lDIsSd5SKxa1LGUWcaplNc0mUtYrOQSAppiaoY/7ncl0xhLoR9yPwkZJK+VyIg7jNHFnSblMKUji+XQ4nPWHrgTKfD4pZc1aox740fLiYhJNX758dev+3Yv2/KtHF4sLxVoxk88YrXaTE3l6ePrVo5cZK894nMtmu/1JrzOw7Wwcx3GSuFHoTqcYQazgVIhyuSwJLOQKrWb397//sLpYVxUlCkLfi8bj8d7uzc8ffrm2XK9Uq2GStNsXuZx17/7t0JuvLi89evhkZW21WinZjpmG0XjYzOULtdrCYDDRDNLvXx0eHL73rbcUEz579KjXHTx4800I8Nn5pUI0gvDq6man1TENwzANdzLTjaxlZKqN6t//8ndRGs5dd32h8M57bx8en3SG8VK9/ODeLsL0yaur1aViJWPk8/mEKpqhGba6vbbw3ttvnp/tu26fYJymSaGQlyItF0q5bPnyvG2q6uLyoqIbTx9/effOPYzxsDt9771vz6czTVPzuZw7GQTeZKFRtRyzVqlTTI9eH0ZhmLc1dza+vrv7zrd+6KXgj598mQj5nW+83+t0Xz1/NZtPEIQ3dndKlbLjaATxf/tv//b3v/2NaaiVSjFra4auz+bui9fP/+Pf/MfpZKJRyrkslIqj4dgyC9niCuMQYLC8uJamYP/48B/+8McU0np1oba0rlqFFJpvvPWdn/z0T+7dv0919eRoX9P173z72/+bP/vz7c3d6dQfDwaDXn99c/3P/uyH/+V//V9/54ff2dxaOT97Ne617tzczTrWbDx488073/n2NwxFnc2mSytLm2srTx99cXZ6lHFynVa/023aGefBvfvzaH541Sxk8yJN/Pk88cPL87YbsnJ1AUN6cXk+6Lazjp53MuVCYTqaRQkb9loJA8VK1TD0nZ29Vqs77g3XlyoaTkfdVqFa6nYm1MxwFquIvfPGnWq1XM1VKBRAJPm8zdJ0NHEnM/fly+dXZxe2k81mcoILK5fjHMwmwdXJVbczIloWKRmCtcnE0/WMEGg6m/ihLyW6vLhiUVSrVR7cf3Pv1s2FhY1SoXjZ7aYcHB2dBXGqqApStbff/YbEtFFbUnSt1xt2ekPVsHI529apJNQpVIv1ZQQ1gmEtV1ys1xZWlpysqZjmZbvFJbdMQ1G0bKFAM8aNvcXxZPLs5QnVcgkHvb7b63QbjeqtvVUM4dNnz1rD+XJjbTIacJ4aqvmH3/4iY5mGqcqYlau11ZXVfL6Yy9iLi6VO5/Lp468Snowmo9OT015nOHYDM1M4Pj3SNZLL2JfNc1V1dEuPwzkBIEmEF0Wl8lIKgGBMNbWzzqxQLVvZTOeqv7F74+T08MG9B1u71z798iOEkZM119YXCuXy6cnZ8tq1rc1NrHBVMwwVvHHr2uvj0aMvH9+8ddM0NFWhy4trL1+9/t/+s3/OBdw/eBknyeLaErXVdx7ceHBv6Rd///vhwN+6efv//T/9y7t39v7P//s3vvjqaavZyxdz/8P/8b/POMajL581W/1qtfGbP3xycnJSLNqZQiH0o0Khaqjqi5evv/3970VAfvjpYevy8id/8tONtfpvfv3h1tLi7Te3Hj8/vLpoZ3NlgkWz0z6/bFbLtenwYnGxksuqr16dZS1bJZIi1B+7SeSnLIqZ9+43H8wnQKFWrWpfteaTqasQQrE0KTINLZHAmzGIEWbA83xFNTo90O+1V1fKaZRctDpekJSr1TAMCJAAIcO2ev0hECyVRsDwtOc6ln3WHHz+6VfffP/en397qWEK28GjefjksDsNZG2piBXy+PFF+6oTM5FxFjauXTMz5PzqgCXpD77/jZWyGadedzgYuy4E5vNnp74//ck7tzd28u5gWLPojZ3Fkw775NPP3rmz8ZP3y1IE563WbDi7vbf13psLI5c9f3n84R9f/+3vXvyHX3zZn3o711YaVXMyaCqYZUyDieTs9dX5yZFMouX1tc2NvWy+2mldMp7WK1VVo7vXr3HB527khxFLfQoxJtKdjQiGRDe/evI8DsJGuca8SHKwtLhQKxfnc9ebeePZfDKb5C2jmMmHiQRQUwm/ubMcpezxk7Oqbb/x7t6Q4YfPm2dnze2l6ls36mVL6w3Gk8k8m89oBI+n4Vl/NpjEAlNbx6amaBqNIi9JBaGqhDwIPAUBy1DLjuaYRFWhqupREsZRCNIoToUXJVGUcME5EKqiUaQkie8H3iyKglRipCAmMYAqwRJAhBQOlASqSYL9JAUCcCmRSgjFmFDfc6fTeSxTRTUAlFGYRKE/cecx54auY4RSHpuGYTi5MBXDaTh041kUJwJADFPOfS+WlGBCvDBORQqFsBzdtkjG1DIWtnQEMYxT5sUsTsVsHnpBkHEcolLJuAKhQRVdVSjGqqYqREuSJI7RPOHDsTedzZHkKRccojiJuRRfqwgAkXHKklhEjKVMcABUzeBAAiAQRhwBCWEiBRMcQogUIoTQNCp4ygETAjMIpIRASikATxkhmEsRpVGcpEBKCCChlED6tQ6Icw4glJAyyeM4whRIADFCRFGZQJxLShBGWCGaRAAgoCgKVRQEqKqoGCMoJEpTiRGGEAsJCCYAAAik5FJISRGmBBKC0iSWnFEEeJogCARPKUIIYcaEBAIRDAiQAkjBKAIsCSUXGGLGGJZSVVUIqQIwBFQIAaEAQggpsEoI1aWUAALOJII4YSlGFKiUEPh19DeJOKWYKjSOYwVhjCmGABAKAEwA8+MESMKTwDJVBUgVQoJx5AVxGmm6EnMgIMAEsJQlAgRcpCxNk4gJmaYSAIkhlQJylmiYGpQKCBMppJCEUmopgAsIhaHQjKbpiuqHvNmfjYMYERUBbKmklFFKeRvK0HaMbNbqjwadadDyma4p5Zzp2DRhSf9r6y2PIeSMpxhKU0EKhjyNXC+UhGqmnctZGkFQciglhMgytEImE0Xsqj115yJkgUnF2/evm4Z6eHj1/GzmGLSYd0wVz1z/6LJJNLpzrYGoPDq+SiN+/fpmoazN5jOFqgIQTcWmme20+knCyosNqtKdra16rdptt9y5izTUWFiYz6aYc13TTy/azUGMCJ/M5oSQejk/6A8wMiHUYl/opiHTOYvda9trAoLzo9MwigVAiqIM+l1FASsba1TX5m6oYK1UrDQvm4Pu4MbWBpHgV7/+gkO4u71BUHjw+oxLmgLFD6Z7e+Vbu5ujSb95OXx1cNaoVDa2VphI6gsNU0U8cVeXSt940Fharrtz8MEnX/Z7nYVSrlCsvDrtP315EQGh6goQ85KFS4Vs7CcXA69hy2+/tahK4Y4nKtWbV5M4FVxRQMLLGa1s01o5WynmBp3mPGQCWzt1hNJw0B/oGnU0ahmGaUNIDcO20zRMEoaJAGmcz2mKrpy1WlQj3WaXpYCaDicyiv1SRqVUY9Hc8+KQYTtHctksUlkQBN48jCIeJwnjoFQtzyYTjmgg8OVVc2Mht1qx/uEPf2SUlqqVah5iMXr7/u1HJ+EHX10tNvIZK91eXQAIX3X7ajZvKGowTyjNnp11Nd1O08gdzZvtWbvbev7sJef4+OLiwdtvLDZqSOKEoc8/f3H77oMwTk+Ozt5+8KB9ceHPprpTevvBfQxIpVZtt5th4t++c88P4sl0rihgb2fNNJTxdDYauf4sRBCsX1sp5POvXr7c3Nx68OZ9S6O9Zo9xkCSAxalKZLN5ls2Vnjx6vr61msnpSRz/8cPPK+XijRt7OdOASD0+ufj0ydP3HlzbWqrU60t/+PiJlbGzpQLGoN/rqUahZKOspucLuUePvppP3bXrNxgQqk4hFUQlpUbj8PTM0PWUcyHkweGRpqVmxv7Zz36tm04qGaHSNkmYSEBpubqYy+Z6w8Hx+XnMwML6YjlneSGuVmqxH798/Gg6GOoahRJeXBxlTLNWLURBHDD52ZdPHj/dTwX0gunbdxvf/Ma96xurOmHFvFrMaGuLi/fuv3l9b71YtAtZa2trzbGyDOCDo9dPDw69iE8mQWcwaA0mF+fdSrm0vrIhEXr88Mn+UevJs7P/6V//9b/8N/9m/9Wzg1cnD190kIjiwO/2WuNxO/BnA9fzYkmoGszCn//NL//V/+/ffPLHz4MgevD2W8NRr3nVbFQXCsVSu9s+u7zy/dDUdCCEaearC0sxi6ysqhGhAa+YUxvVvCUj15/4kacquuvN19ca4Xx8tP9aQFKuValBV5YriiKoQavV3EK1OhxN/dBPAtnuB7/81QetwYCqaHNjqVzMb6xvZ03yxp3NjWr25cvXWHUgg2I+KFeMce8qm0ULjdpFq3fVGVOq5By7XC9ki06rOzw4upzPI8fUs1mLKvL1Se/ly+NuZ3x52V5aXymWyhxAKRUmwGg0UKgmAVGJ0mpd9Ab9R89efPzVUwS0KJaA4MHApYoyGIz7vYGKsa5rxWxZCMgAnLk+EHJna5snSRjMu4NRszf0fB7zNA28RqkGJA7mvqpbR6e9L54dX/VGgoGCU/j+u3u39rZm0+k0SBOGkiSREHOW7G0s3rq5YpfKr16cToLAMqg3n/63//1/efvmzVevDiIOhZQwSQej4WgSz8LEsfRb2xuM8SiMlhtVikPDxBBp2WKp1+prOL6+3oi4aHZH2UIVc854AhA4uWwDzbZ0FbFIATL1/L/6uy82VrdMSzk7OAh99Gz/+E/+8T86Php89fCL69d2YsY0Tet1eiwSGUdfWqisX7uhUVRw5I+/cWM6Dz/65MvdO7d///uPFV3XNLT/4oty1jo96VLTPjvr3Lyx0zo+/OGPvnNj79q/+nd/c/PO7bwC/h//9//rd378pwrVm73573//ab5g/vSH79cXy812q1QpvXn3zYdfPhyNJtVyLpMv/uaDD3/4k+/nbdxvHv/4G9+8fm3pf/73f/yH3/z6nXff6E/9D563/6t/dHt1IdubhrPJcGGxdvPGjeP29Ko/2tjc4P50uWyxYBbFs1q1OJ708vmKahoaBs2T82qlbGVBnHqLFdsXqNUfC4gVBSxVLI2HBgJBAiYBBLo2mU5KGZrNgKOjC0XPrq1XUsZfvb5Eulmq1t3Q5ynP5SxDEQbBhRydToJxmCYcLi5VP30cfP7h4/ffvvWDB43rdZzRcMjBRSfsu2yxXlx05PPz8LAbPjm6evT8tW2QeiXfGw4nM/f7729++42ljQUjSVG77+dKq4cnFyeX7be3q1JET56+ur53/Rv3qycX3tPHjx/s7r29W60UjKuRPNg/fGuj/oObBEiAFm69uZvtzvBvHjYPT/s7y/WVRuaqNSDI/sbdze1Ne+LOD5vRB19eng/jWqWR16kfjMI4HAz7mq4Vi+Vev00wgpCaGi0Vs4apQ0jqtXqv7f3+Dx9UqosLq/U4DlLBNEOtVMoSQChIKiRAfHlteT6b+a6nUdCoWDtr9dZ48PrwWd4m9bzGEPjsxdnri7ZMWClPqjkTg4gkoWPQOIm8GI3mbND3fV/wJLY0giRKBRQI6poqEB7OfTcIiarKNI6mM6JSqpucJ4RzSHgqxTRIZwnyAhnGkaaopqJIIaNYjoNkGEQRwByQVELAUpamo+ncD3gkSMoIAzhMZBhxTJScqWWzBkUkjSBESAEqNQjWHD+Go/Hc9QPV0KXgke9ZCjJ1LRUsjMXcE3EKEg4AAJ5gc9f3o0RCHERSQGrpmqGpKgKWQgxF5jKqpqsESwyIgjBLZBAGgjFECecsjiMAIYEAiFTXoGM7UsTzMJ7HqR/zmAPGpYACEiwwpIpKVVVKyQWLkgBBiAlMYxbFIeYQI8qlYCJNBIdcYkiSNCGIwK+zVwhLBKRgUgAhWRCGXAqJEcYEQCC45FyoRAUQMs4BAoQqhBAoBUFYipRAAiBkAMqEI4wJxAqBBEEAcJymAEMEAaUKZywVSRB5YZBCCBhkCGHEGSeASAGlAAhhlnKIIWNMQgkBAhBSlQIoWZoSBRNMMJJCCCmZEInkAmFAEYIQAwwwJVTVU8GFTAAGiGImORBSAImgVBSMMaKKmjKZhkmapgghxlKAeBRHGCsQCQyxZIgADAQAMo2jmDGBEWY8hYKlKUMAAAB5zBEkKeNEU1RKFFXVLE1KwLlAGPlBxNIEIiIijjEEgFkqNTQVSqgTbNuaYxqKgnRTVxQFAMiF4Ez4YUAVrCkE8KSQtXNZ0zZpKmF/MpvOA4kVLqAAXLfUcj6TsRQZjHOGZmjmYBZ0h74X+Y6KNqp2vWjnTN1RVEunlq45poogjONYQgkRYQABVSNUQVDqOuU8ZKFHIMAEGRgjKMIw7E/DlMFQcELw7Tt7CIinXzzpjVwrl19brPI0nM9mk+GEELqxvokBaZ1dDsaTcrWQz9s8TaMo5gKoqkopnoxnrjfTVIohyphmGPjNThcgRFVasJ35bOYHoWpl293hoDsqVRxF0xQNrywvjybezAtNU+92m5jySrWSprxadPI5s9vpQmI6mSzBqoRxrmTd3LtGITk9OYeQCAmeP/1qPp+vrq8nKfrog0831nN/+ic/DpLwqy9fNlZW514UeZPvfuP+t957Z//otNcJ8rXyvdu3V1eXx6OBZZnjyaDdbOlUFhwtEfTps1ePvnguGVhYWNSc3GVn0hzOsjbZWKpldd20rVK16M1nY3eomeabtxYR55NxX3NyV735OADFajEMfMOk2+v5WpEUM2ZvFHRdxlI4d10gsEU4TkOqm11mnF/MgknCwz6lRhQDCEBOBSphVDdOmsMoQoQSgVCYpn4QAQ4wpirVvckkiXkm7yAJMha1daRTlUmCqO15gaEbCAiRxK7vB2Ea+WG1VN3cWP/08f54klSL5VLekCIuF2vH54OHD5/f3Wm8/8132qcXSRL4XhhHwnasZy8fE4JPzo8ogds711vN1knzLAkDz4+u7103LCvjmN58eHl+GQnw6cMvFhcL1/b2PvzDx54flHKV08uzTDafs7KD4eVV99zJFA4OjzGEa9vbX3zyFSXaQq2WzRdfPn3d6ffn87lmwkIhm7XyH/3xg7t37ss0efX8tWDgq8dfBWHgzsc716+Np7M0lZPJZHlp0TLN4+NT1dRzWWdzYyljGJPJbDhPPvjoMwjx/VubAoj/8LNfAIJVhUIBXu+fBIlw5y4RQalcOjnvvDo82Lt5PQympuMEHteIIRL+7NHTy8tWb9Cfz8Ojg2PLcH70o78Yj2a2ptfqRSkSnvCZG3h++PrgVDBmmxriqQJhtVKulWoHx/vPn79oXQ6nM284nmqmXq0WKlV7bW1VVVRKtVzWYWGaRKmt6xSDu3du//SnP8061rPnL3VNzeeyK2vVqTc4OXjRqNTLpaqiqe1O5+zyLPBjomQXq0ucy8FoNBpOKTWMfJ6ozvHp8fHrl4ZhjgZDyUGlUNnb2sNEHY+mi5U8i2G720cY2hk7TQhnqNXpfvjBH8/OjnkS3X/r/e/+9B/fu3930OlOpr7mlHrt1/svX01dRlSdgMjWLXced3oDKQFPw3ce3NvY3nYyTi5rLRaLd966p2u2kysyILL53OryaqVUiFmS8nRpcVEmzJ1F83k4Gg6hRIwJJjhMgkG/e/T6JFMq/+gnP6xVS5dXl2ESEMRbzbam2a9OzwkhGBABU6dYBhLv7t2EnHYHo1yxbJgWB7xSzi/Xq6k/Nwwzny9dtq6SlDvZXCx0IWQYBbqpMs4Pn7/4/W9/0+13gzgEEk2mwWQy9/zADaZASM75xtY6hKqZKQBM0zjNZh0BhEiY5846w8HR8cF4NDR0jarKbBp88ezV48Nz3dC7/VF/MJ55gR/FaRJfNtvtq2MKkjicGQpaXmlUy9W5O726Ojs7Onry1WcKTra2VjUsVEWG3qzf7k7mbrd3gUT01oPbVFVbF61qpSaS8Fc//7kfJ9d2d3QCheQCw6tW++zibDqahJG3e+P66uoKxWhze6NULpuWkwoRRVG5Xnr05NX6tb0bWxuOQQT3B+NBkspCzvFCdnZ+IgEIE9afum+8+65tm//yr/6XNx+8MZ95mMBnLx55o/F/9s9/2u0Mjs7Oq+VSsZQ7b14cvNonVH/25KvD4+aXDz9bW21MRt2VlbXD10eWRlYWnWfPXt2++067Odi9vXN1dra0uC4h++OHn16dH/fd4B//2Z+O5/Fnn378n/8X/8OHT+anp61yPocJRxD/8ue/oUjd2V7+6Y+/9ezJC02hC43FOJXDiffGvbvebPqbX/9qa3vTNHRI5a0bu6s1+xe//AcNZ/67/+KfHb786uPPPv/mN9+rFrNhmP7d3/59rVZ/9429/f3TZrutaLqiK6qmuFNfM4yMne+e7yNE7Hzxot0L4rRcsMb9k3LeRkC4QaiYVhQAQyUlm5SzlHPgJSljYDYeiyQpF+nMS8ZT3zCQY6gJx61mH8vYhkkSzhlTqqU1CkXeQYCB8SR8ddIulBvfeK96cDr83Qdf1E3x4zc339u0qhkUC3zVdnsDt1Kvra7lTd2y8o2IK5cjPgoVz01fvjrs9cab66vfe/f69dXCcBietJrUWjq5vGIArK/Wjq76nYvWgzeuvXe/+tWz+aP9VsE2bCwfvHF94vOj8/1b19dMCk5evchn8t/7zm1dIaPJZDIery01lsvZR48vWp1eMV9LYmzoWZGmv//tk48/+zzGGcNwJECcE382KRYz2ZwVcx76iWLphuUQREa9ZiFXuHtv7dVB8+SktbmxauWM8/OLlEk749gaqdUrAKHz5oWq4kK1MQ3niqKohJYrmeWq3Z6mtmVeW646KizVlgdT4fpco1BRQM4plMs5TcWUQoCBlMRL4onnK4pmaGrO0SLfd2ceF8w2bARRHDMIQS5na7YahjGCCFNV05WMmVUJQYBjwDASEZMpj21LLdmGaSqpEBMv8KPE86ecM4JxxjYRwkGSTqfzoRu4UZoK6Qdx6PsAcI0oikpSHo/Hk4gxyGKWBpahK4o2nc7SmJmmjTBkka9iibiUHCWSRWGKOCAQcIbmUZwwEHI5D8Le1HU9Lwzmlm3pCtGJogGhUhxESZCEHCJEoBREU6lKkKIpMecpYwlLCCUYgJytVgtZQpGU0kt4GEpVU3iSSCFBKtI4JhRjiHgsAVYFpEnCAJRxGvtxHCcJwYRgYiiqEIIxqSqqF/gyBlAC9HUKF0GCMUYIQxRFKReCcY6AxAQDKYM0iZNECI4ABFAKIBDGCCAOhJQSSoghUlUNQMmE/BrBFTxVCKEIiZQLmUAEOeO6qqo6hYBgSRCEAEAoEROcxYxBiYhCAGAIIcE4wpAlMQBQSKgaKmAcQiAA5kIgiBAijMs0ThiTXCQIYSiElEKjOE1FknDGJaGECYYxQgixFDDO0jABQBKCCAEQYY0ohmZRrGACgMSciZgziQTCRAIMCaGUSiABFyxBAKNEMimloWmSCS4lEJILiDFNUh6n3E+TMOJRkiSpSGIOgbBUpeSo+ZyuKgRLCQE0FAVAEMZsNHa9mKUASQiF5LqmqhTZhlI0NQJAEqXzIOxOvJkXQYwFF1KmGUsr27qtEpREhZxmGkqU8PYwmAdpOWvuLpqNqqEriKXCDXyNYsfQNV2VHKqmZljZUIrZLPT9KEliFSMkUx4xzdRLxbxOMFaghMANUy9K534iknihXvFn/sefPApTWK+Xl/KGH/PpaBYGsW4ZGcdutXtHJxd2JreyWMs4BmOMxcxzXV3XuOSu51FKcjnbzuo5JzOdeRdXlwhhTJCu6QTDOA4Mwx6N3KtWz8nZOUvDMi5mM958fnJ2ZRm2P59RAq9tr0oBhpOJ6WRms3mnP/BTPpy4BMN6rVrKZKiiHZ9d+vOQUNjsNbP53MbmehSzy/b5tWvL3/jG26cnr/dfnlQaq1GYpoH73jvbGct4/vz49GyYK5ZyOaMzGH/65avhOHVnUyjSxmKlVMz78/nP/+Hg5MJbXjb2NitIiLPLdrszrmTVrZVa3hQECYhpHHFKcDFbBlBrNS9cb4KN4unV8Lw7yBUph9CfTtbXSjpIPNeb+OxqlA7mZOAFpoWtfIl7E1UAJ58/HoizCag74N52MUyEG4JaXt3dqCmUYDUTMgyRQhGAEPfHXpwAoqhIMgA4IahUyQZemCSMCpC1NUWj3d7YS5BjapapB1Fo5YpBEGsUmTpaXmxcDsPj4269ltncyGZsZRKw56f9v/71kxuL9p/99LuvX72YukGtcePvf/+xqpDI9wVDjPPZdHznzs1Ov314fNWoLmysL+5d33HH09cn+4ynkKNqvda8vFio1d5+637n4uzk5PWNuzdM08RUzeSyF1cXs6lfyBcTzrvt4Q9+8MOvPv1oPJ+UKyXVMD/57Iv9k5M0TJmM79y6rarmp59/1Vhp2IXcv/rX/17VncF41ul2tjZWavVCtpDt9wbzeZDP5W49uPPo2XND13K2UawVM7bjz0cRS5Gm94buj99/y5u7f/zsScbOVYuV6WRuZgpDPyUaxogbRJmMJs12b3lhlacR94Ljw7NXz09ms7DbHUdJVMzl7ty+ByAIQu/unZtegP7Fv/jLydRbWVpLYtbu9cKUt3t9x3GSOGp3upvXNsqVwtn52f7B/nwWXzX7fhIzKS3LolAu1Epba+u6qkKCEQStTtOL/Ju3Nq9tr0bu/NmL/V/8/JcPv3peKdcsK0swzGYypVyeADyde1988dlls10sVFTNkhhNPHcwHEAiTUOlGGSK2SRO2512PmvtbG+vLS1951tvXduqLdXNxmKpfXGJRbTUcKZhdHp23ml3RkO31W5bllWvVe/cvXn95s63v/u+apl/97e/+PjjL3Ile3ll+fSsw+P42vVdgkm/3clYxp3b277HowRamlItlRGkl5etYnXhqtnpjHrFrOMG/mQ6al9epWny/MUL08ktLiyqinZ+ftHuDw4Oj4/OWxkr7859CWDC5PJy+d6967m85c2GGdPyQzR3ue+J+sJCu9V5vn+Blcze3s0ocg8PD87POr//5PksQR6Dz57vG4qmaSQK4ihONU3euXH9+s6WaWv3790qF/LdzujxqyPbwtevbTTqpRs7Wz/8/o/2buzkM47gLAwCJ5thEI4GkzQVqqrlCxkYh2+99x7gzPdHxVJOsxQZx4WCXSwajWoxl8skIi3mirlMlqgkSsjzk8F45iGs5XNOIWupCpJUBQibpnr/1s6Pv31PxQkLpuWc+e4b9xYqhXwps7a6urJSX2mULEPc2Fu//9b92kKxUS02FhZn40n7/Pydt+8VsoaiYitrj2ejFy8Oe8MhJZrneqamLy0uO5aJIBgMxxIAy9TOz88uL64EpEGYTkaj2WjIGZzNxWefPwVAoDTIGtba1iaQXNfVUt4iGDJA/JSlSYol+B//D/+ZBiDg/NbNa+W8kzXNX/zy59evbSxvbHZaXYyhbaq3r984PL+s1Zf8eXR0Nnnx6qxeyOeKuThm2bztzgZ//o9/Mp3ND44PrIx2fWtFcMAhe/udt9vtKy8ASKaGSTYX9A//8Gm2WP4//Xc//bf/7l+srK8Oe53BqN/q9g9Pzl+/PEQQfevbb/7+w0/COMJEf/L85WDY/Sd/8aeqZjx/9uriqgkRBSjOV82dne0nz54iKG5sL/3+06tHDx/ls1qlsRhw9d/99c8KtnPjWnU0CuIEaKp5cTl/+vJ0OsfZfK5a1C4uz2wjl7GcXn+8ubvtugOKQSOLJ5O5opc0S5sOeo6KsqZOCCAEFzOWruLLqzPLzElEkjiUUbJYL3ApR27IhXAchwt5ftk67/ZmAXMMtL5TqTbyoR89f7GfzdbK1WIascvWFMfjd/YW3lg3CwpTdXja9z951RGxaOT11BvMvPTJa6/rJrWVtdCXf/ii9csPXkgI9raLt7etIEm6/e50Al49O0b2MtXzr5+/uHhx9M237y8vmX/7m4NRClMhvdlwdX2pNwrdgP3gR+/QOP3lpyeeP7i1s2AZhh+Jq6vu6uryu282Ds+nx60AGk4Qj9+4uXr3unlxGXy1P526vu+lKeMxF4ZBS7kchjwF/Ojk1AtSVVUhxKNR887tW/Wq88ePPz0+Od+7vqfp2v7+PkAIYQUJgTGAULlstrMZB0E4GAylALYGb6yVzrrs4aP9XFa1dBB4Yy9mkwCkQHM9fnra9P3EtDLljFkwEaWcIBKl0ovjNIpzGWdrYyGKAnfmp4wTrEZRMp7MAUDlbN7ScRzHQcQBAghD21BLGbOe03KWjhGdB6GKha0jiyIdYwUCSgDBmCBMMeCSZ3KOoSBNpaamBUHqB0wiEITx1A0TJizbdEwNADmLGAcYAyBEwgRXqAIlz5mmoxNbUx1bz+cMonIKIaFQNyhBHAIseco5V6miKFoU8nnA4wS684AgZBuaYWiGgmzb9gI2T9nX+kpV0VQCFIBzGZPqCkaYccklQ4wZBijqqoSQszRIUhmFKtUxJBjjVHKWpghDohCMURAmTEqCFQiwEDCK0zRlRNHiOIQIajqFQGIJYpYICSAQ7OuJDwAIY0VTFFVNYw6kBBIgSBkEQghMqKpgxhkUKOUsZjEDHHJIFQKRZIxxlhKEGE8FFAgjITgXPA5CISQUGCGkIIIpSeOY8xgIjiDg/+vyh0CMMSSQpwxi5X8dIgFICaUEA4hkKjgTEEgFY6wgiDECkFBECMUYcMahgBAiBBHjgFJCIMIIUggJxSzmECKEMQRYQAAxTFMJMZZCSIzCME4lRwBKwADgBALxNQgBJJKAsYQSzLgEGGCKoZAQwpjxJE0RQVywKIkn7sTzAy4kRBRAiqlKFUVTQD5nFzJKOecoQDKWEA1TBUdxOpkHE3ceptLz47kXzbwZ5Mw21IypYMEEVF0/mkRJzEnKuJNxbNvWVFnJ6LW8baiQYF4o5Cm1Ukm649HMm9TK2a2lStnR5m7Unbq9aTB1A5bKII1GE9eNwtk87PWH3txP0lSltFpwsjkbccAhhpiy2MeYmwQLAdNUJkEMMaqW81HgXVw2c6Wlcn0VIDCfd8fTMJJEtfJJwvvdwXzqqrparOSLBVWnFAB82epSquTyTujNFISL+YymayrRIuZP3cnS8jqQKI0SzVQhRQyAKI573bFmmI1aDUKpIJa1M4dHV5l8IV8oAAx2d7c5l53mVaGUQxI3m/1stogRCMP58lrNsoyYpcPBaDRo6joGHGyubl6/vndxfnq4/7qUK9UW6l999snF6Xl9qYapcX5xvrndyBWzB2eXB/uX5+d9b9JPgsnUnY1mMcTAyVjVRjXhMOY0SGKJ6OZGdWN9KZcv9CYj143KFXuhYlsqNxScsqjfH8RpHPjp4dF5uzWEyIZG/ctX3fYoKGf1jK2I1Lu2vpTXaL8/zBeXRmF6eelqSCyWc8sLZaSb3THPFNTpPDg7vSo74MbdDSRpr3vhWHh9o3F4dDQaD2ezuT+NMzowDBqFjEUsTrGhaWma6Aoq5G0Z8zAMFEXGUmTyuTiIZm4MMa2VzKxNgEAAUN8P6vXqQiU/9t3js6s4TAomvLVZNFV60Zl1h9OVhczb92589uGvu+3e29/9yUdfvTw+Zbu7O6k3r9aW5m54bXttZbl2dnK1u3e/UCrFfu/4+PXJ6eHq4urN2zdV0zq/ury2d/2N+7cBY+Nea315cW9j+9Xxq0Gvq+pESqkbjuPYzebld7//fsqTTz7+ZH1lZX11ae66iGEg0drq8re+9R4hyqcff6wQ/e27D37z858zAK5tb59eHL//rfdKlTwQUgoouHQc894bd49fn1+ct20rc/POrSTyu/02hSa2jEFv8M79nXo5/9Wjh4alv/HGWweHh/V6rd0aCg5s06wX7SQIFAgXSpl6La9SdXF13VDMSq3quX7C+cLSSrlSZJy9eL6/uLTARPj3v/gwCJKd6xsi8afj0cbGVqFYZFFQzBW6g6FEWAp4ena1tbl5+94buzdvfOt739zavjGeTnWdrC9XdUMd9PsXV03GYkXBi4tLN3e3VQTn3lTLGBknm3Gc73//R0RR//Ivf3Z4cDweTOdBaGSL2Uwlm8tbuuH6U8ZDQ9dN0xjPJiqQ3/nO+zvXtwbt1mXzyspm7Eze8wMvDcJogqnMOIYU8fb2+p037iyubdWqlXqltnvjlkKpqamOpRo6rJazTCSff/nJz//ub0USLTYappb54IOPKFE2rq0JwDWVrG/W33xw//NHT3/1m9/ZhpbLOhTxn/38PxBMdZr59LPHUcg++viPSADbsHZvbmczOcM0J4PJoD+6uGg2O617Dx6srV9TkKoqynAwwgCFQTx02WweLVRrC7Xiz3/2d7/73W/7kykCYD73TKd01ew8fvLCm880w+AC5Cs129SeP38lsZorFkQ0zVl099q6N/d73b6fRK9ePu00z+MwnE6HQeTfuXnz3t1bM3fUb7fHs2GvfxW4s2wus7Kxjik5Oz8XAC+tLgNES4ViwXEISn75i7978fyrW7sbBALBGAMgY+hra8u2qlumZWcyBycHSRRJAbK5nJXJNMdzqql528po2NRomkaVWhkIOZv1fDdYW2lsrm+KNHz98nExn1tfWhy681F/qmjqtZ1NhRAMxYMHb69urBiGVltcno4GuiKXFmunp2dRLO7cevPmrRueGwZhAhDy51MooqWFWrlUGbR75xdn5XymXs1rCpqNBpHv1WuLq0sVUzXHXvjJVwdJDHK5UvPyCkgchXOWRCrBmCiKQgHFmazz8KuHk9brn3z/7V//+lfZUmE4GXz3u++7XvDp54+vbW2eHB/12oNmu3f3zfvd7mU2n7m+ey1bqEqJ/vjFpw8evD2ajLiQTx8913T63jff7rV6OZu8fPl4abX24tXZysqqls6GY89zJxriezfvQK588Nvfb6wvVfP27z74aHv3XqlU293b27mx40fRz372d7dv3PrxT74zdt0oCixDf/7k5Xg8XltZKVUar/fPxqOZrhILSn88MZzceDwp5Y3bb92+aA6+ePiq0x5Uy0uOmTs6PbAMvZi1IObFSsa24el5NJzFfhg3GrVyJjMetErFjOcHCoJVp2jyoF4w5nPRHIztXMULwpQlCNNClvrzXppGmWxWUTSAIUuEqljuzEuTpFbOpBELg9TOZLKZjKXhlCcxJ+cXPcfSDZ0SDLypf3nZObvsRpI1Nu92+u581LqxXv7mvVKtoOdzlqT0+eF05k23V5ylCqFU/fzx8PDsanG1YRXKr16Pf/2br6hqvvXgdiWnm5aysLZw0Upev3q6sV4nhnZ02Tq6uPqLP//B6jL8w2dHUHM67f6038pm7M+/PJ1P3R98ewMb6sV5D7BEV5UU4qEbP3n5tFS09vYaAjEAQOdq1upcXtvZ295dHk0G5+cDiBTGZK/bDz2mUGzqSq1WkwwdHZ1AqBYK2cm03++2Nrd2ysXSr/7uN8fHF++89U1FUY8OzziXYRhUKrVavU4g5iw0FJ1QZ+bH3VbH1MnG2uLjg/HR5SBbrKwt1yLP3b8YnLdnqp1RnczF1eDsqs0YMyhUidR1DRHIYuCHSej5mYy5tlzOZrSYRXNvlgJEFIWlCUwDk2CCBAQgiZPpZBKFCUhjAoGl04KpmJoKGTM1xdZQwVKyOjU1RddVgqGQPAq9yJ8jKUwNGTqREDLOCaGqZSUpmnlB7AYQIkW3OcQRA4ZhGpaWMQlVEFVUCgXFACsYS6Bi6mjU1BSVIAykSomqUwVrWCEIA9tQIQKDme+nkgE48z3X93Vdw0BQkSKVjuYsjOIgCYIwhIhAEKsUI8YAwnGSJglHGKoYFnJmwdIUBceMpUBiDCTgaRQTqLBECg4EEFLyJI2BkFHCocRSQsFALAQLQ0pVqtBUCAkgURRV1TngQmLAOEJQQMA5kwJIzomGMQQCQMZSBWEEpEiTRDApUcISiKCmqYwDqlIMKZeCKgqiOIxjICUEIOUMQAghQJRSDBmQAso0jZI4UnRNQiwlQJxLxhIIEYZEciY5F0JCkQrBAYRpGAH5NY+QQIxVXQMIJinDEgEopYSSSQgARJgqCsBSAg4EJxgxnkKEIIKAQ5kKgBiQAgLJ0hRhACQiCkkTBiQETBKEhWBcSgihlAKhr1EESCBEVJEMQIEQgQDAlIlUiihOwyARAKdJIgRKhaCQEqqkAAZB4s3nQHIVg7yjlzKqQhAQEkDMhJACRomY+qGXglkk5yETAkAIbF3PZrMaSBWM/ViOZmHfE+2+1xn7sWC2rhZUXM5a5YKDgcwYpqMZEcA9N7xqj6MwWClmbu+uqhodTeOD9vxiEF5OglEoh140nadBCoI4ZlEik9ixtFrRqOVNiJIwmCMCiaJEcZJyrmIlSXmcSNfzJZYL1ZxKiT8PTTtLKOn1e9ORG6fYj4QQqDcc98YeJNhytHLRWq7kCxlH1Wj7qgslKORKo2GfKrRRWxjPZv7cw1gZ9Wd2xiEUxXGUyWR0RU8iFnr+aDzJ5x0M4Gg6Hg7HlpPv9noYo1Le6XevshknidL91ycIKbbptPt93TYX6nWexHdvXF+oOc3+uD/2Li6vSrl8o1q1ckaxsfDFw+ftq961jdVipfrs+cHYDXf3dhVMJ9Pum3c3d7e23El8ejQ4b84WKpnbN69n7DyVeKlRrlWKmkqG3VEcx5Zl97uztQZdbZQIzRycdy6bM93Qs6ZmmWqYJMOpmyRpIe8US2WBNDdg5VqB6tbx2aUfgrV6bmuluFAyK1kTIHx5dak7tpuIXmfSKJDNFadQ1Ge+32yPxwxPmXq0390pgX/6J+/MRuFHjzsm5luLxcnM2z+PnHLdnY4tHS4ulUyjmEYRYCkmgEGZxEInJKOSKAqsjJkwaRhKoVCcTCamBgiUpZwZBREAYjxsEwwth5pO9uK0CThVKLxzZx1DOvPSZtt1rOzt64snZ1dpAqu14usXpw8fv1zfyMdRhKUUCB1fXJZKxauz88F0IKCcT+dMSs/z3vvmN5Gif/rhl5PpeG/3+kKtNBqPzII9mXY2NtaCNHz08OHG1s7O7p3haHx0eByG8fb2lmEbj796XCqX19cbk9lApNLOmq47/s6f/Pj47OLl0xd2Jlut53vdAU/FUrV8cLRfyuZ29nZFJF03PD0577WbWzub8zj64IOPAj+hKlVUeXZyrKiZ2vr6qxevMzB548bGy2cPM7mSbedH86A3HGLV6A77lWpOpziZuxxJVePLjVy54pSrlcODU3c2KJdNLsM7d3csXQUSHR6d5gvZXM6J5rNaOb+xVs06arff023b88L5ZLjcKHt+hJDhOPnnL15GSZIAXioUR8Pus8cvDw9eTMeTlbUGVaXvzXuj8dQLVV1lILlsX4xn48FkCBGdTKbufGqaxJ+Nx5Px9sayrpmKqueLZXcyer7/wsmUtq9tbG+tLdTK/tyNkyibL6cJf7H/+vHzQ282vbW1dm9vK4rTwcw7ODi6ak33tm8ziQ9eH9ml8sv9s//X/+cvhYh2drfnrjududVqiUdhMHdZEp4dnUghv/fdN3/0/XftjKZo5NbNvbfeuN4ZzvZfvUwid3lpuTecDcfjtx7sVStOsZzTdZnP2dubW4+fvZr6SbGUf+P27s61zWIhnzI2n/QL2SzGYDydhlEy6I41jFWdjMfjhEeqTiDiGdv4q//46WcPn8dJvLa6WqvWtjfW7t/cMXS12xk0auX797fKeTWN0+nEq5QrW2v177x7J6OR2XCMBLNzlqJIy9TqtWpjcV0wWSwWl5cXTo6OMVX2blyPg0m701V0QzdzlmohSBdX1q6uRleXg2yhWKuV5t6k2x2eX3Y/evjk8OTi7r0Hf/HTH965uS25TEK/2+4Rwzpptz777MsgDjO26ZimhOCy0yOaqilUE4IqKgKQElkpZSlkJgUlxwnD6Ojk7LzVfPT4xVWzpWg6IKTbGwxn89APj08vP/rss6cvTl68Ovroj48eP3/abvYBBAsLddN2eoNepZJbXa4AKWdu6Oggl8tcNfumZSzUq3N39tnDp4OZv76+Zuh0PO2vLC9U8hnH0KLIZyx0bH1ra6FWqQUBG7hhpbFqmVqvddEoFbOGGqXc82MuABSccW6UavtHRytLi9d31jrNc1NRTU3Z3dn+6tHjQrG8sb7U6g0uL3spF5TAdvPi+q29Ufvs7q3rg1aveXFOEJzN/EnAD44uqELXVta2VpekQI9ftK46U0VR3nqwe3oxjUJ+dPj0/s17uUrm4OAoCd3vffe7L1+1fvWbzwRQZ+OJo6p37u4pVN9/eVgt5d64cz1n61vb274fNFttQ8XVWr7SqKmaulAtbG1tcgAODi8inhDNUGGg6EoqyGQ+VxVuKMjzU55iTEHkzg3CV5Yq+RwZdge2nrm6bC1Ul2TsT2bDUqUUxZwDpjqEQWlZ2PdiwDhjwItTpBLTckjKOwMvCHyqEZlGaZREKbAqC8eX7ZWFKqHyyWE/RFo+oy0UdZlEipV1fRz4cb1YdnQwmYNE4kI2e3g0e3Vyns0tAKITJHIGtomvw3ChZLNcYf90amrW/e3G/TVrtQoePZtcNAfrdePu9dpsLv/VX3088aLvvP/Ad8dOrvqf/tl9W5P91qFiV2KYb161vXn3v/pPv1vLgYSLa9ubycSrZ63lJfODj19eDOO1pVqQyGcHnYkv/BSbmWzE8PHRWUbTFzKonlFXVkovDuefPnyeyWbu7C0xibv9qRTIdcN2v6MYNmMCIlEuORipp+dXqmmuLC5gKNrtS93KrG+s/+4fPnn6cv+Ne29gKKM0HIzd1mWXQkJVArnMF3MAg9l0btr53mC2u2yvLmd+83BweDEsZzPv3t/UMLwY8e4wyOVt1TLGs3g8YzHDjKPxdMaZUFQKIerNpudnl5pmZC1To0jVTYyxFJyolKpEU2Eu41g6MQ3VMDQm5TSMZ16SxrGjE9tQpl6UCqkqGpSJEFwgmiQwTBIhpapaCErJOcFAxchUMJQiCEMBJFKIELA79ec+m/lRGKU9NxwHcRImQGIosBSMCSYQ4qlkQkapb1marhBEOCYAE9UkRCAIOQAypRqyTQ0gOPMTL0q4wFGUAi5yppF1FEtRKUJhKuJERknKODdMW6GUIMmSRKNUcJZygQkhCGmQO7pqaEQIEKecYkpUVUoQcREEqRCAIKqplKUiiiMOBYQoYmmYpImUfpqkMVcQxlIyKf045imPWZpwkCTia/n+11N2CCQUkAvBJOBJCgAEAKeRhIhSqhBMwiBVKEEAuTMXIikFi+IYSIQB5lxQQihRORepYAxAAiRIJaQEQiyllIxJBBAmiBIMpCQYUBULKbGiAIyoQhGmSNUkIowxgqhCsZScC0EwRAhJLoVMqIIxghgCKSWCEEIkpMAIAQABAhghgSCEEkFCMeEpUAiRUgDBhOQYI85jIXnKmUoUgBDnAmMsZYogIpRKKBDkikoFlCyVAMIkZV4QhSlLBOMIYKJIBImUmOIkFVEc+bGfzVh5R9MUQJCAgGNEklR0etMoBSFLwhSmHMz9JAwZlNLQFEdXilnLxMjQrXks2yP/fDi/aA8ShLlEGkg1DDIZE8TRdOIGURwyPnG9VrMzmrgC4rydqZas8bBzeNJ6ftaf+DxIYBRzShVN0yxDV1XkZMxCsbBUr24slXImjcN5GgvNsKWEPGFCAF13pBQxF4xziVApX4BADoaDKOVBknY73TBwiW4mnAjOUh5LAfPFgqropq4u1XKqLjRdj1M5nrlOtoAVxCXKF0p+HLheXK2tjKcTgEQ+mx+PBsViXtXUuet3u32QQtswoITNy3bK2OLiCkJGZzAs5jL+bGY4ppXJnpw3mZCZYn7UGwCRLtRrp+enhMDF5Xoce/48Cv2kVq01Vpbn8wkBypeff3l+2Vy/tlWr1JqXl612b337+mg0HA+vNtfXMxY+ODp79PjQTdI3H+zcf2P38qr94sWp53sYpEkU97t9IUWjUhgNu4aql3JWt3P1yWePTy+m+XJ5sVYniMzn4WTuJ5DkcqW87XQ7w7Nmz8wqlgZbndlgmC5X4EqjmNGSeBYkjF90+pblFEu1g9cnGIHGYiEK/MvzbrPpznqjfLUySWS1rP+T793qHn328Gl37+by5krDnc9fHvRry0VFscdDvrZcimaTVqcHALecDCUoYULTsRQBYywSWhRzEMut1VLgzgbjECNVU6QbuBJTAJVEyJWVCgHo/KLLEpmk7p1bS1vb18cT7/j4vNecbK9UAIsYwPX60nwQzmed8WBSqRZCP0ihmEyGhZyVs5xWuz0fTwEW23s3/EAUsxlN1z/55NOIi/u3b9mW3r68mEzGPOZZu9Jt9ztX7enMvX//3nQ0+cMfPnMy1vLiwnQ8HHTauq6/cfP2ZDxQdV23rI//8OKdd9/0B8OHnz8qVxuhF2RtazychH4YhYFp4Jt39o4PjwbDXm8w6LZ7m9tbImHt88vj04vbd9+4vnvjy4dfvNh/9c5733yxf9y5aL95/xYBcSZrFKvlk9OLTz7/gqvWwekFxhAB6FgOVRVKZNY2shkr8dnf/Ozvz89P11bWPXewulAVSfDJx59cNZuaoW1tXuMSumEcBPNGrYQVvrK+MhhOu+Ph0uLi4tISAtKdufuHR7WFRqNem85mw1H/1ctDUyfLK8v/2T/9MwzTKIoXllfTlEtAKDXDOFlfW93d3dN0h6c8n8tknEyhkOOAKwrd3LmGsBQwsW1VIahUqL96vf/64HWv3ZtMp4tLy5piuK6PFSIEqTfW7j14p1YrXZ6fNFttITAAUneyv/jw4//lP/5ec3J5s9DuDTe3b+dyzovnr56/el1r1G0nr2jqzt6eplt72zd++MOfbC+vpTxd31g/enUyHnbCcGAp9urC8urGuhfEf//Lj9bXtnf3rmecTDZrRXF8bWtrNp0gCH/8o+/uXt8GPH719OkXD7+AXBCCPM/TTDMV/M79tza3No+PzixdMW14//7dQsH25t5bD+6sL6+q1Oj3uucXZ8uLteXVhu/NMZZpGjo2vnNze3drOVcsjSfj05Ozg1dP3LlbqZUKuRzjII7jldWNTrvZap/t7x///sPPklQQqiyvrlh25uDwuN1qffzFk9ksRYS0O/2rTnPqBV4YHh4enZ9d6Lr+4P7ter1ab9Q5JyPXf/TiuR+4tWoZEWxaDpJIp0alsprPNhAmCEAEeKlQQlRJ48QxKeRurzWK4ogqOEmileWF1cWGokrG/Y3N6wvLG9lCMeFpkiSabicskSxulKr/6KffvXXjlmBCSlLI5gzDcWczdz6Lg6i20CiWalzKxeXqbDpstS6TcGaZFiJoNHZVzVhYWGFMqhCnTBABVM0gBAuUbq4tXdtYoSD+/R8+v7o8v3Fja3k5//Hnpx9+9vG3v/V+o5yZT8eV2kKSCNcPDZXoCDW7s4HHhgFt9/zvfOvbmVwmTNPTs879+/crJavXba4uLWZMrV4qnR4dbKxunJ4eb2/vXtsq6wbKV8q1Wr1RrSBKNFO17OzDL16cnb1EAP3kp/9IYvr02dHh4enK8rqTKbx+eTSb+lO38yc//X6ne/WH3/5WIvxnf/6do9Or0Xj2u1//9ujopUK1a7u7/WH/5bPnqe9lHT2JomyhxlIoJA8mI8nZ0fHr0E+EBCvLiwoFV+f9Z88Oaxkl72Q10xr1R9F8Zpna1AtnYbK0ul4s2aPupYyZoZKT4+NMLheFUbfXXFhZ0YnebrUhRqoCTFMhBMw9DgjECsUI+r7wXH/qeoruICUTxmkpV3IyBktBdzgpVxfdEYiCecYxm33QbI0IJo2iyYMEUw1S/eigMw+CYqWQyUHX9+1yrVyzOmfdF69e90cuh0qpnLu/u/TWXkULR2PPPeyB335+0hm4lSzd21zavVYeT4LR1VUpqzx45369Ufm3f/3Q7bXu377z0YePLvvDv/iLH7z34DZkwcx1e1PvxaOHbufi+9+8BmPXcszqcjlJ43t3biwv2QGLsiRAgLcn8Lw3n06n0/FUNXO+1AaDkQo5EH6tXr9zbzdMksdfPNOkqFeLg37Hn4e6Zs+nYX8wsTN5SjBV1Xo9z1j65PGzJJZLqyumaXS657dv3bp3/8bv/vDhs/0X1VoVY5WqymA87g2Gk+EoioNM1qpWc1N3QnQD6dbo6uhb772VtdX9w/5Xj17knPz739rLG3w4nre6k3yhpFuKm6YhB1S1AOcDN+RSarZNiDaeJp7raQTZBGUNrGLMGRcJA0ASSghPBAsRBLau26bBEYoESjhIeQSYTBn2Y4GEwBBBIIO5zyHgUsZxGjGm6oauKQpBlGBdJ6alQg5iL2Ys0VRqmkbMUi54moRxKsfTeXc0YxJRjQDJMSaWZgIkkyQVHATzCADoWFmEEJBJksZQpGmahH4SeCESwjY0VZGcpUJA3bKiOJaSa4SqhEERzv1I1XWMqe/7vueHSZjNZHSiAimEBBJIySWWnCoYC444n3lJHKdBEnGepjwWTISMpSkHGGFCENVUXYECIgAgJUnE4zCKgiQVIkpSgQhCUtdVpKgASgQwIoQJRrFqWiaUkiWcE0wpoggAiABAXEisEAmSKIowoghAwVOWMqoQBBQpEATw65iyFDBJGQBAAoQhFIwJKSGGEmAhpJQAYwqlQFKCJGYQQc4EBhhISAiQHKZpwllCoMRIQAiJilLGJBOS85ilEEEAoEqJBAABJAFACAohECIYY6oQBCFBEAiIgAAEQwTTJKEKIAqhCEMMFUIIwJSouqYgjDDBBEEEJcZUU1UJZBRHEBDJOESSCa5qhHOeMG5otgQkihlIuZQCcKFoappwd+5xxrO6mTVVXaGOaSIIwiDhELdnoZsIHglLVROBepNgOvUAArapZC0lZ+oQyJilY1/snw3Pe97MYxBhFSIEoWVQR1Vc15tFaciEn6bDqT8NUy5wzjLKJbNUyMRcNJvjuc8BsWxdyWikkNELjlbN6wULNrJavZgzicRYKADoCmYA+4lMExZHPoJYpSRKoqGXuEHqzn3AuZBiMvESTgOO5+5cCL5YqxCEhpMQQmlZhmkRzMM0CR1bp5h4fjD3w8lsmivldROrGioUKixllxeXqqoMRt1hv7e0sixFqhGNYCQZj5IEIZQr5oOYnV42dctYW11xLNrqdIv5Qso50UjOybZbTaLhcqUY+Gmz3V1Yasg0Ycl869qGZpjNZs91x+VKobFUPz45QIoaC3B20tzdWWuUa73x5NXry+rC4mjQH4/6mxtbhqm/enUymQaWqW4s5LMZ7fmrk2f7LS8QAEtN02Z+OvNErphTCKuWnY1rG32XH19OFSKvXWssVgvT+Ww8GqqaUiwtYmx1e7MXRxf98QwDvL6wbBJuGfragrpaNQoOAlyBZnY4ld2Bnyk3nr84UhW0sFCbzJJmLxx6LBJgaSmTM4CYeZvrxbN+7+VL/KM3zN2lvMfoac8LQrC+vDDqX+VysJC34sCbT2alUhlgECSpYehfP20AQdOIHTfnSzWyXctcnp0mUPclkDyQjBGqBWFo6la1XJScTWczP2E5A+3d3X39/GDmepOJ16gYCCsHB2caod3hSKp6exAhKTOaXFqsD4cTCMWf/elPK/WFs7MuUui3v/mNzz/5+KvHRxm7cHRy2m23/5v/+j9/dXj4xZcPhUSOk0+T1I/CVqelG/rO3o7vzn7x8/+wt7v13W9+azgcnJ+cGkYmVyrNecwY27m599mnD+2c8e5b7/7Db3/WqGS8KEmiOIgi0zTH7oyo4N233+r3OoynxVqDKMpbb797edU9OzrlscAEb22vT4aD2dT/yY++rRrq6/39aq0YJPN267Jeq8/m4VlrQo0ik0gimaSJYFEchaOR600GppMdefHf/vwfVhar/8k/+fOXh88pUVa31ty5R1W6uLi4ubE2HvVYknhu6M2nSGGNesNxrHazW8xWWCoTxludFjVV2zZ5nKoalEn45OmT1Y31u3evqZJ7vndydOJkspvrq5VKVbIICX7rxg0J4JefPczni42F5Vy+audyg2HU7nRXVhrFkr2xvbK0vOTO5oPxBCO2t3uz050eHJxomlou5BnnXCgLy6tSiiAOOu3+ZXvcvOrUC/nYH6+tbii6eXLYvL6zU8iXLztniwvlvevrvcEYILS6unl+fnHRaboxOz08fflynyNwfnL6m9/9cTSZX11enV2dbe/slEtVzSSZYpUD89/9259vb9QajerjLx5fdQZRyIJQ+F40nUwMU5kM+x/+4QNC9e3dTce05l6EqOZkLYz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UCKmgYxqeiQmCUpE4FalSAsAszhCQjapXcggWjLMEI+3YFgEwYyqMcmxalkUBxHGWWYZNCIYECwkTzhKmBWdSKEQQxiYxqFKMKFH0DKVhtzc7uRxNI64oAZAMZrOEqTduLtuUXl52Lcda6HSyMLw4u6xUG6PxrF6prnZahMDzq15/FBmuBwDQMuwsLxFsRXO/US222zWFAcE0ivMszaSE5WppubOaMTYcToaTsOJ5BCQyixrt5nVvctUdrC8uszTf2z9zbPu9t16Hij1+vkcKDbdclyzpDkPF2HuvL9c8eH09MEoVhNVi04MIhmkEaOHLr58WCvb9N27NBldM4HffejP0w2fP9+vNtuRAQS0UQpggYF6cnSPAbt/Ydt3aV4+eX/Qjt1Sul/LXthubG+vPDi9//tFexsD2dvnejabjOsNxGEfBrZtr68udV09eKgnqlUb3em6YeKndtIulGacPHl+2a/Cdezd6k/TVZSQlqJRtihh1LNcuRLMB5xgoNpungyC/nrOpnzdKoNGoRFkIFLYsQ2Xx+lLdIpClYHW5dhFn11ejEoXthYWDi/mMwe3NTsWWnWq55BVM046UqpZraZpfD0MmbGqRxI8qFafZriJgGKad5wk2aSazcqnklr15EDLGXcfwXGc+nhpQ1GomQcY85lGW93q6XrM8i9eL1LK8vcOTjfVqlERBpMvV8tVVEAdBa5EuLnem08loNitXPIO6BOv5dP784LDT2ZoOB55nlUqlnMnhNI3DoFyr205h/+DFm2+99XL/WRLKp08P/upnH7339sabb752fX3y9NHD5Z2darP98NHjN999fWd3pzscbayt/frXH5cqjTQOJv0hFODo+KzeKFsOrZYqH336yeJC4923bxyfnS2traWhLFfqnlt6unesAKAI9q4uCaJfP3n5+dcvkpT97u/+toG0HwbFYoGJPBfq0cOnJjFWO8sWtg3HWlhYWGi120utVrt1eX51eX5lITydDD7/7Kujo1POY9um25trN7bWtlYXa2UvjP3lldVKpaAUc0zj9u07BKPLq6urywvBU6/gFkverZ2dOIymk+nW9pphIs8xbm2udJrVO9urRdtQea60ElIhhLUCSmmFlIJAAQAgMDACWPuRPJ/mvVl2MZqZxeo4iKmRN5ZWv3h6ch1qjbQEigsmhNJQC54xLtOMxymLMh7FPMl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cfC6W7c9XtivztW69w994x4Qmeq1m06QwGosEnj2/uDY4OMSF3uy4xXA4bGpbW40rc714caw5MztRoLl45OyVdQapEiJEESFYMG9yvI8r0AmaoQhOpdOr87M935eA1us9Fqg7jw2mwuD8uXkazTds0ewE0Vg4CNxuh3PFdk3oExlyYc1jCgWCg4CqQGhYdVrVg7tHqZVt9NzHnjubNP3PferdO9XyhVMv7mzYr820xzNwKJ/0qisxqmWzSUVQu163gdKgxiivVjoHRlJf/pv3hoYGHvvKl/7mX34FpRofoMf35XzJry63CQoGipliOuoItdxG59ZaSIE794NPv++G+976ltdfOXf0yMMtDooxcGsqrEy+tN5BYfuVy3bNAYNx9Jnfe9N7P/4J7raWzp2rNrev35+756aPZIpj2VhYz8XNwjg0+wBUgFV6Myu/79ejGjDzkyzoLJy/8PTPf/L4Z5/fXGvcc8PAnbff1Ku151cWQ5HQPTddJ6Vc29xs1uuZdMp17c21TaWkYUYGx8dRyLIZy+YLdqfl9tyh/uLW9vq3//3rukanRkaZ8MdHh3u+HyiWzaVDkZDnsHa7u3dyiuj60sL63v17hFIrK3Odnnvw8LF2o3H58nQqW4iGw6lUqlaucAA1iMKGUcxle13/8qWZVCo0MTxR77R/9vNfDo9PfuAjH5ecOe2m7wee3bBdlusb3FhaWF2cCxm673mJeNz12msra/FYYtvdwFh3BE8nEsWh0YunXpEY9A8MGJZ+5dLFTqN1z333nT19lkC+XalPz0w/+OCDyUj8yWeeKhQKvuf09/WZhpWKh990/30Cy/W19Xazne8rtrvdQqHoem6lUr75xhteeemVJ5997r3vfVe1XGt36+FwfPe+o51ew9KNhVoj8ALX951O78jhIxjy5dW1xfnVof6xV1+//NCHD66urRNCrszM+r5PsZZOpxfnF4Sk9Var57m+EKVGU0Mwk4galsmQr5tWr2vvbJU1DRX6R8cmJy5fuBAKhTY21w8fuGZ7+7l4xBqdHF5eWbn55ptHhgafPfGS4nLfnne94f4Hv/udb2m68cZ77/j+T366b9/u2++866knHn/+uedvuvUmBEG90332ueeOHz7kByybTv3m537754/+7N+//p/Hjux/4C33uI69PD2HERoYKk7t3XXm1MnyTiWm+OsnXxs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i0Te9/4OvPPXE93/wY4SMD33ooWQy+Z8/+l40nH7bu9+5sLBktzumpWWi0aWF2fXt6u6pXSdeffXt733fvn27//vRR/NpLTdSbNvO5StX7r7jbiukObb30quvvv1t7zp7+lwmm81m8k/8/MlMMoUgHhodXlxcuXLxygP3PLBv3/7F5ZlHfvzzfXv2bGxvx+IpLlXEjHAFl5avJmLWnj17lzcWOBO9ngOx5XlCM4xms9vqMZ/bQgLmcQgg0giCAgAAIARCAQQAAIJxDwhdM6XjCikIBFypeqvRchxqGDrBtWptoH9A8CARjy0sL09NjUoBkCJdx5ncPXXp8uWXX37t7nvuvuxdbM91DIDXKTbDIdMy243a4FBf4PeWFxb788VcJtPr9SLx+Nba+qYUd9x59/bmxusvvzQyOtw/UAg8Z31tfXhktLSzc/nS5eGh0YGRMSMcabvOxMTElQuXpq8sjE4OXnvTTXNXpnc2NvxWY6e08z+Yi7Xljb6+fOAHAQuOHj3y0kuvrSwtD/Xl9+6e7LQ71WYNSUQoCnznlVdf27tv3+233bhwdUVKccM1xy6cnj73+ul8Ns1d30rEZy4vPvPUyTc9eMuHP/Lwxlbl3//t+7//v/7tTW8ZPXToaDy607Fbm1sb2RQ7cuORxSsbL748Xy7vHNw3kEmnEvFQreoRGlpa3R4QGRZMR+OhfC7LJCAIXnfTzUuL852ODRDCkneddtcOrs4v7toz+cC9903t9V597ZWrM6udLrM52L+7+NOf/uoXTz1z7M7b3nXfBz77pe989s/tv/uD33zrw386lQTf++7f/m4If+YT/+8vv/mSBM+/47b85z/1mfJO7WO/93/mloOH7op8/jc/8cmPFr76vV986VvPH73ro3/40XtvvePg6ycuKq+HEIemhUKhrt2KEqybEcADGQi302aKU4MiofsShEzUrdktRiCEbmBjTDgjJkKFSKzc7Ggai1MciIA7uDCaWWyw73/3wqM/evOfffrIt375szNPP/dP//rDv/+Xx37x82fe857r733Hu288svcv/vprj7ywFrfMrico1ruMXJi9mtLR1NTeHvdmz8/rfvfAwQPheObCpcs729u7Jyd3+mul7dVdYze+tORHIyuHr7sL0o2dneUzr5+lENx73/3f/PbP/uuHL++ajN5903UEBHt2jcdS8Vr19Zm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v7+frt9dBQAGgUoW19b63pqom1pqjaMIUXK6Y5HXv9SxGTGRmLmOBqRTJLK1MUvKlt5cBkbw4j8mpkEyuMxaWusdFWvpKYpYK3S1LJvNl+P2+FxBVW+v2Zd8wqhRpqpWVUoLam21DZdXm+jq21/turqJUarnO0Ot/YK3jXElxUbamrd/IymttCr0a503NminVx9DmlThnnMVapOW9+daV4O/HOUOGydZ92MiqkDZnfWv8f/3Xv/z64/Y8ThLm3W59rnj/81O79+7zyL3bGL4N38a4De9drsAkqCrtWBFZt218v99etuvNnxBag6omGackBJC7j62pIg1qiiuSOo/1OA5UteYBRiRVnivejkJZE21qW1OytPHbfexddpPe+yPqTHy73142V5XnDGP8ct9++34bo1XUeTxuY9wcl6b3vvnrsE2ZCVf9tjX6/VgUb7fb/fu+bUMUuii/vz9SIWWMUEmoEwssI5SixiwpFCtiRsyZBS2hWhWqwMiIVEUVALhakQJc70KC5gqChMAKBUiJ8JInClyVQGZWRWWpXdheac2uHycTTSQKRQmBkCiJ5FrzenU2E1OneGumIrfepPI+3MWGuqrY8CA/Z6243DegQMWuYz6hi1jXwywk57pmg5nTzQvIquRyQPVfyVjT5grWMHGXDu9b7yZm7sZh0lR6E6NAygC5nLxilwdRK2ZWdfFEujV3zGOpmZiIGiUpkjP63uacn/NwbfovHNL1elgQVbe+VDqgKhK11HSdoW2vNaPmaCPWKoCQFWE+liJXsS9R1dSMIgErNwcK2kw0Ca5092SKIC+Lc1WFaadrL/KCU5qg8sp8GkpAORmkrJjeboSB6Cr9YjSBlYmqLxdr5FyZGbf7i2agaowBwXyuWRdAT59zzZWZeTwxQ83lOc8Z+Y+3R9IgopI+VF3PzPfPAPSXb/fbpkFNW81sxfO5oqytJBcw9sdxKOJ1679977/cRzFft+1JfD4zKp/HCUqkWO+R/MppSDTpL6M1N1SuVV+7GkUzHc24InJ667/e9wfxfEK63Hvbmp0VnHRDN4F6Tiyt86xFpXuTENWISnCeIZrGdsYEqY45Y6388XKPYkREamv01ry35/NcQteuos3zx2001+M8xce2tXo8v+0jj/PzEWroQ658/ozaVf7y2nr31vU/355///1xkgJs5tqaOBFSyUlo296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 \ No newline at end of file diff --git a/tests/core-providers/scenarios/multi_turn_conversation.go b/tests/core-providers/scenarios/multi_turn_conversation.go new file mode 100644 index 000000000..ca013b9f6 --- /dev/null +++ b/tests/core-providers/scenarios/multi_turn_conversation.go @@ -0,0 +1,162 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunMultiTurnConversationTest executes the multi-turn conversation test scenario +func RunMultiTurnConversationTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.MultiTurnConversation { + t.Logf("Multi-turn conversation not supported for provider %s", testConfig.Provider) + return + } + + t.Run("MultiTurnConversation", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // First message - introduction + userMessage1 := CreateBasicChatMessage("Hello, my name is Alice.") + messages1 := []schemas.ChatMessage{ + userMessage1, + } + + firstRequest := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: messages1, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + + // Use retry framework for first request + retryConfig1 := GetTestRetryConfigForScenario("MultiTurnConversation", testConfig) + retryContext1 := TestRetryContext{ + ScenarioName: "MultiTurnConversation_Step1", + ExpectedBehavior: map[string]interface{}{ + "acknowledging_name": true, + "polite_response": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "step": "introduction", + }, + } + chatRetryConfig1 := ChatRetryConfig{ + MaxAttempts: retryConfig1.MaxAttempts, + BaseDelay: retryConfig1.BaseDelay, + MaxDelay: retryConfig1.MaxDelay, + Conditions: []ChatRetryCondition{}, // Add specific chat retry conditions as needed + OnRetry: retryConfig1.OnRetry, + OnFinalFail: retryConfig1.OnFinalFail, + } + + // Enhanced validation for first response + // Just check that it acknowledges Alice by name - being less strict about exact wording + expectations1 := ConversationExpectations([]string{"alice"}) + expectations1 = ModifyExpectationsForProvider(expectations1, testConfig.Provider) + expectations1.MinContentLength = 10 + + response1, bifrostErr := WithChatTestRetry(t, chatRetryConfig1, retryContext1, expectations1, "MultiTurnConversation_Step1", func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return client.ChatCompletionRequest(ctx, firstRequest) + }) + + if bifrostErr != nil { + t.Fatalf("❌ MultiTurnConversation_Step1 request failed after retries: %v", GetErrorMessage(bifrostErr)) + } + + t.Logf("βœ… First turn acknowledged: %s", GetChatContent(response1)) + + // Second message with conversation history - memory test + messages2 := []schemas.ChatMessage{ + userMessage1, + } + + // Add all choice messages from the first response + if response1 != nil { + for _, choice := range response1.Choices { + if choice.Message != nil { + messages2 = append(messages2, *choice.Message) + } + } + } + + // Add the follow-up question to test memory + messages2 = append(messages2, CreateBasicChatMessage("What's my name?")) + + secondRequest := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: messages2, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + + // Use retry framework for memory recall test + retryConfig2 := GetTestRetryConfigForScenario("MultiTurnConversation", testConfig) + retryContext2 := TestRetryContext{ + ScenarioName: "MultiTurnConversation_Step2", + ExpectedBehavior: map[string]interface{}{ + "should_remember_alice": true, + "memory_recall": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + "step": "memory_test", + "context": "name_recall", + }, + } + chatRetryConfig2 := ChatRetryConfig{ + MaxAttempts: retryConfig2.MaxAttempts, + BaseDelay: retryConfig2.BaseDelay, + MaxDelay: retryConfig2.MaxDelay, + Conditions: []ChatRetryCondition{}, + OnRetry: retryConfig2.OnRetry, + OnFinalFail: retryConfig2.OnFinalFail, + } + + // Enhanced validation for memory recall response + expectations2 := ConversationExpectations([]string{"alice"}) + expectations2 = ModifyExpectationsForProvider(expectations2, testConfig.Provider) + expectations2.ShouldContainKeywords = []string{"alice"} // Case insensitive + expectations2.MinContentLength = 5 // At least mention the name + expectations2.MaxContentLength = 200 // Don't be overly verbose + expectations2.ShouldNotContainWords = []string{"don't know", "can't remember", "forgot"} // Memory failure indicators + + response2, bifrostErr := WithChatTestRetry(t, chatRetryConfig2, retryContext2, expectations2, "MultiTurnConversation_Step2", func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return client.ChatCompletionRequest(ctx, secondRequest) + }) + + if bifrostErr != nil { + t.Fatalf("MultiTurnConversation_Step2 request failed after retries: %v", GetErrorMessage(bifrostErr)) + } + + content := GetChatContent(response2) + + // Specific memory validation + contentLower := strings.ToLower(content) + if strings.Contains(contentLower, "alice") { + t.Logf("βœ… Model successfully remembered the name: %s", content) + } else { + // This is a critical failure for multi-turn conversation + t.Fatalf("❌ Model failed to remember the name 'Alice' in multi-turn conversation. Response: %s", content) + } + + t.Logf("βœ… Multi-turn conversation completed successfully") + }) +} diff --git a/tests/core-providers/scenarios/multiple_images.go b/tests/core-providers/scenarios/multiple_images.go new file mode 100644 index 000000000..841f21d7b --- /dev/null +++ b/tests/core-providers/scenarios/multiple_images.go @@ -0,0 +1,142 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunMultipleImagesTest executes the multiple images test scenario +func RunMultipleImagesTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.MultipleImages { + t.Logf("Multiple images not supported for provider %s", testConfig.Provider) + return + } + + t.Run("MultipleImages", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Load lion base64 image for comparison + lionBase64, err := GetLionBase64Image() + if err != nil { + t.Fatalf("Failed to load lion base64 image: %v", err) + } + + messages := []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + { + Type: schemas.ChatContentBlockTypeText, + Text: bifrost.Ptr("Compare these two images - what are the similarities and differences? Both are animals, but what are the specific differences between them?"), + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: TestImageURL, // Ant image + }, + }, + { + Type: schemas.ChatContentBlockTypeImage, + ImageURLStruct: &schemas.ChatInputImage{ + URL: lionBase64, // Lion image + }, + }, + }, + }, + }, + } + + request := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.VisionModel, + Input: messages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(300), + }, + Fallbacks: testConfig.Fallbacks, + } + + // Use retry framework for multiple image processing (more complex, can be flaky) + retryConfig := GetTestRetryConfigForScenario("MultipleImages", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "MultipleImages", + ExpectedBehavior: map[string]interface{}{ + "should_compare_images": true, + "should_identify_similarities": true, + "should_identify_differences": true, + "multiple_image_processing": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.VisionModel, + "image_count": 2, + "mixed_formats": true, // URL and base64 + "expected_keywords": []string{"different", "differences", "contrast", "unlike", "comparison", "compare", "both", "two"}, // 🎯 Comparison-specific terms + }, + } + chatRetryConfig := ChatRetryConfig{ + MaxAttempts: retryConfig.MaxAttempts, + BaseDelay: retryConfig.BaseDelay, + MaxDelay: retryConfig.MaxDelay, + Conditions: []ChatRetryCondition{}, // Add specific chat retry conditions as needed + OnRetry: retryConfig.OnRetry, + OnFinalFail: retryConfig.OnFinalFail, + } + + // Enhanced validation for multiple image comparison (ant vs lion) + expectations := VisionExpectations([]string{"ant", "lion"}) // Basic expectation - should identify both as animals with differences + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.MinContentLength = 30 // Should provide comparative analysis + expectations.MaxContentLength = 1500 // Multiple images can generate verbose responses + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{ + "only see one", "cannot compare", "missing image", + "single image", "unable to view the second", + }...) // Failure to process multiple images indicators + + response, bifrostError := WithChatTestRetry(t, chatRetryConfig, retryContext, expectations, "MultipleImages", func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return client.ChatCompletionRequest(ctx, request) + }) + + // Validation now happens inside WithTestRetry - no need to check again + if bifrostError != nil { + t.Fatalf("❌ Multiple images request failed after retries: %v", GetErrorMessage(bifrostError)) + } + + content := GetChatContent(response) + + // Additional validation for ant vs lion comparison + contentLower := strings.ToLower(content) + foundAnimalRef := strings.Contains(contentLower, "ant") || strings.Contains(contentLower, "lion") || + strings.Contains(contentLower, "insect") || strings.Contains(contentLower, "cat") || + strings.Contains(contentLower, "animal") + foundComparison := strings.Contains(contentLower, "different") || strings.Contains(contentLower, "compare") || + strings.Contains(contentLower, "contrast") || strings.Contains(contentLower, "versus") + + if foundAnimalRef && foundComparison { + t.Logf("βœ… Model successfully identified animals and made comparisons: %s", content) + } else if foundAnimalRef { + t.Logf("βœ… Model identified animals but may not have made clear comparisons") + } else { + t.Logf("⚠️ Model may not have clearly identified the animals in the images") + } + + // Check for substantial response indicating both images were processed + if len(content) > 50 { + t.Logf("βœ… Generated substantial comparison response (%d chars)", len(content)) + } else { + t.Logf("⚠️ Comparison response seems brief: %s", content) + } + + t.Logf("βœ… Multiple images comparison completed: %s", content) + }) +} diff --git a/tests/core-providers/scenarios/multiple_tool_calls.go b/tests/core-providers/scenarios/multiple_tool_calls.go new file mode 100644 index 000000000..4a10e1778 --- /dev/null +++ b/tests/core-providers/scenarios/multiple_tool_calls.go @@ -0,0 +1,191 @@ +package scenarios + +import ( + "context" + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// getKeysFromMap returns the keys of a map[string]bool as a slice +func getKeysFromMap(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// RunMultipleToolCallsTest executes the multiple tool calls test scenario using dual API testing framework +func RunMultipleToolCallsTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.MultipleToolCalls { + t.Logf("Multiple tool calls not supported for provider %s", testConfig.Provider) + return + } + + t.Run("MultipleToolCalls", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + chatMessages := []schemas.ChatMessage{ + CreateBasicChatMessage("I need to know the weather in London and also calculate 15 * 23. Can you help with both in a single request?"), + } + responsesMessages := []schemas.ResponsesMessage{ + CreateBasicResponsesMessage("I need to know the weather in London and also calculate 15 * 23. Can you help with both in a single request?"), + } + + // Get tools for both APIs using the new GetSampleTool function + chatWeatherTool := GetSampleChatTool(SampleToolTypeWeather) // Chat Completions API + chatCalculatorTool := GetSampleChatTool(SampleToolTypeCalculate) // Chat Completions API + responsesWeatherTool := GetSampleResponsesTool(SampleToolTypeWeather) // Responses API + responsesCalculatorTool := GetSampleResponsesTool(SampleToolTypeCalculate) // Responses API + + // Use specialized multi-tool retry configuration + retryConfig := MultiToolRetryConfig(2, []string{"weather", "calculate"}) + retryContext := TestRetryContext{ + ScenarioName: "MultipleToolCalls", + ExpectedBehavior: map[string]interface{}{ + "expected_tool_count": 2, + "should_handle_both": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + }, + } + + // Enhanced multi-tool validation (same for both APIs) + expectedTools := []string{"weather", "calculate"} + expectations := MultipleToolExpectations(expectedTools, [][]string{{"location"}, {"expression"}}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + // Add additional validation for the specific tools + expectations.ExpectedToolCalls[0].ArgumentTypes = map[string]string{ + "location": "string", + } + expectations.ExpectedToolCalls[1].ArgumentTypes = map[string]string{ + "expression": "string", + } + expectations.ExpectedChoiceCount = 0 // to remove the check + + // Create operations for both Chat Completions and Responses API + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{*chatWeatherTool, *chatCalculatorTool}, + }, + Fallbacks: testConfig.Fallbacks, + } + chatReq.Input = chatMessages + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{*responsesWeatherTool, *responsesCalculatorTool}, + }, + Fallbacks: testConfig.Fallbacks, + } + responsesReq.Input = responsesMessages + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test - passes only if BOTH APIs succeed + result := WithDualAPITestRetry(t, + retryConfig, + retryContext, + expectations, + "MultipleToolCalls", + chatOperation, + responsesOperation) + + // Validate both APIs succeeded + if !result.BothSucceeded { + var errors []string + if result.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result.ChatCompletionsError)) + } + if result.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ MultipleToolCalls dual API test failed: %v", errors) + } + + // Verify we got the expected tools using universal tool extraction + validateChatMultipleToolCalls := func(response *schemas.BifrostChatResponse, apiName string) { + toolCalls := ExtractChatToolCalls(response) + toolsFound := make(map[string]bool) + toolCallCount := len(toolCalls) + + for _, toolCall := range toolCalls { + if toolCall.Name != "" { + toolsFound[toolCall.Name] = true + t.Logf("βœ… %s found tool call: %s with args: %s", apiName, toolCall.Name, toolCall.Arguments) + } + } + + // Validate that we got both expected tools + for _, expectedTool := range expectedTools { + if !toolsFound[expectedTool] { + t.Fatalf("%s API expected tool '%s' not found. Found tools: %v", apiName, expectedTool, getKeysFromMap(toolsFound)) + } + } + + if toolCallCount < 2 { + t.Fatalf("%s API expected at least 2 tool calls, got %d", apiName, toolCallCount) + } + + t.Logf("βœ… %s API successfully found %d tool calls: %v", apiName, toolCallCount, getKeysFromMap(toolsFound)) + } + + validateResponsesMultipleToolCalls := func(response *schemas.BifrostResponsesResponse, apiName string) { + toolCalls := ExtractResponsesToolCalls(response) + toolsFound := make(map[string]bool) + toolCallCount := len(toolCalls) + + for _, toolCall := range toolCalls { + if toolCall.Name != "" { + toolsFound[toolCall.Name] = true + t.Logf("βœ… %s found tool call: %s with args: %s", apiName, toolCall.Name, toolCall.Arguments) + } + } + + // Validate that we got both expected tools + for _, expectedTool := range expectedTools { + if !toolsFound[expectedTool] { + t.Fatalf("%s API expected tool '%s' not found. Found tools: %v", apiName, expectedTool, getKeysFromMap(toolsFound)) + } + } + + if toolCallCount < 2 { + t.Fatalf("%s API expected at least 2 tool calls, got %d", apiName, toolCallCount) + } + + t.Logf("βœ… %s API successfully found %d tool calls: %v", apiName, toolCallCount, getKeysFromMap(toolsFound)) + } + + // Validate both API responses + if result.ChatCompletionsResponse != nil { + validateChatMultipleToolCalls(result.ChatCompletionsResponse, "Chat Completions") + } + + if result.ResponsesAPIResponse != nil { + validateResponsesMultipleToolCalls(result.ResponsesAPIResponse, "Responses") + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed MultipleToolCalls test!") + }) +} diff --git a/tests/core-providers/scenarios/reasoning.go b/tests/core-providers/scenarios/reasoning.go new file mode 100644 index 000000000..5e8b7c7ff --- /dev/null +++ b/tests/core-providers/scenarios/reasoning.go @@ -0,0 +1,211 @@ +package scenarios + +import ( + "context" + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunReasoningTest executes the reasoning test scenario to test thinking capabilities via Responses API only +func RunReasoningTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.Reasoning { + t.Logf("⏭️ Reasoning not supported for provider %s", testConfig.Provider) + return + } + + // Skip if no reasoning model is configured + if testConfig.ReasoningModel == "" { + t.Logf("⏭️ No reasoning model configured for provider %s", testConfig.Provider) + return + } + + t.Run("Reasoning", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Create a complex problem that requires step-by-step reasoning + problemPrompt := "A farmer has 100 chickens and 50 cows. Each chicken lays 5 eggs per week, and each cow produces 20 liters of milk per day. If the farmer sells eggs for $0.25 each and milk for $1.50 per liter, and it costs $2 per week to feed each chicken and $15 per week to feed each cow, what is the farmer's weekly profit? Please show your step-by-step reasoning." + + responsesMessages := []schemas.ResponsesMessage{ + CreateBasicResponsesMessage(problemPrompt), + } + + // Execute Responses API test with retries + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ReasoningModel, + Input: responsesMessages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(800), + // Configure reasoning-specific parameters + Reasoning: &schemas.ResponsesParametersReasoning{ + Effort: bifrost.Ptr("high"), // High effort for complex reasoning + Summary: bifrost.Ptr("detailed"), // Detailed summary of reasoning process + }, + // Include reasoning content in response + Include: []string{"reasoning.encrypted_content"}, + }, + Fallbacks: testConfig.Fallbacks, + } + + // Use retry framework with enhanced validation for reasoning + retryConfig := GetTestRetryConfigForScenario("Reasoning", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "Reasoning", + ExpectedBehavior: map[string]interface{}{ + "should_show_reasoning": true, + "mathematical_problem": true, + "step_by_step": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ReasoningModel, + "problem_type": "mathematical", + "complexity": "high", + "expects_reasoning": true, + }, + } + responsesRetryConfig := ResponsesRetryConfig{ + MaxAttempts: retryConfig.MaxAttempts, + BaseDelay: retryConfig.BaseDelay, + MaxDelay: retryConfig.MaxDelay, + Conditions: []ResponsesRetryCondition{}, // Add specific responses retry conditions as needed + OnRetry: retryConfig.OnRetry, + OnFinalFail: retryConfig.OnFinalFail, + } + + // Enhanced validation for reasoning scenarios + expectations := GetExpectationsForScenario("Reasoning", testConfig, map[string]interface{}{ + "requires_reasoning": true, + }) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.MinContentLength = 50 // Reasoning requires substantial content + expectations.MaxContentLength = 2000 // Reasoning can be verbose + + response, responsesError := WithResponsesTestRetry(t, responsesRetryConfig, retryContext, expectations, "Reasoning", func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + return client.ResponsesRequest(ctx, responsesReq) + }) + + if responsesError != nil { + t.Fatalf("❌ Reasoning test failed after retries: %v", GetErrorMessage(responsesError)) + } + + // Log the response content + responsesContent := GetResponsesContent(response) + if responsesContent == "" { + t.Logf("βœ… Responses API reasoning result: ") + } else { + maxLen := 300 + if len(responsesContent) < maxLen { + maxLen = len(responsesContent) + } + t.Logf("βœ… Responses API reasoning result: %s", responsesContent[:maxLen]) + } + + // Additional reasoning-specific validation (complementary to the main validation) + reasoningDetected := validateResponsesAPIReasoning(t, response) + if !reasoningDetected { + t.Logf("⚠️ No explicit reasoning indicators found in response structure - may still contain valid reasoning in content") + } else { + t.Logf("🧠 Reasoning structure detected in response") + } + + t.Logf("πŸŽ‰ Responses API passed Reasoning test!") + }) +} + +// validateResponsesAPIReasoning performs additional validation specific to Responses API reasoning features +// Returns true if reasoning indicators are found +func validateResponsesAPIReasoning(t *testing.T, response *schemas.BifrostResponsesResponse) bool { + if response == nil || response.Output == nil { + return false + } + + reasoningFound := false + summaryFound := false + reasoningContentFound := false + + // Check if response contains reasoning messages or reasoning content + for _, message := range response.Output { + // Check for ResponsesMessageTypeReasoning + if message.Type != nil && *message.Type == schemas.ResponsesMessageTypeReasoning { + reasoningFound = true + t.Logf("🧠 Found ResponsesMessageTypeReasoning message in response") + + // Check for reasoning summary content + if message.ResponsesReasoning != nil && len(message.ResponsesReasoning.Summary) > 0 { + summaryFound = true + t.Logf("πŸ“ Found reasoning summary with %d content blocks", len(message.ResponsesReasoning.Summary)) + + // Log first summary block for debugging + if len(message.ResponsesReasoning.Summary) > 0 { + firstSummary := message.ResponsesReasoning.Summary[0] + if len(firstSummary.Text) > 0 { + maxLen := 200 + if len(firstSummary.Text) < maxLen { + maxLen = len(firstSummary.Text) + } + t.Logf("πŸ“‹ First reasoning summary: %s", firstSummary.Text[:maxLen]) + } else { + t.Logf("πŸ“‹ First reasoning summary: (empty)") + } + } + } + + // Check for encrypted reasoning content + if message.ResponsesReasoning != nil && message.ResponsesReasoning.EncryptedContent != nil { + t.Logf("πŸ” Found encrypted reasoning content") + } + } + + // Check for content blocks with ResponsesOutputMessageContentTypeReasoning + if message.Content != nil && message.Content.ContentBlocks != nil { + for _, block := range message.Content.ContentBlocks { + if block.Type == schemas.ResponsesOutputMessageContentTypeReasoning { + reasoningContentFound = true + t.Logf("πŸ” Found ResponsesOutputMessageContentTypeReasoning content block") + } + } + } + } + + // Check if reasoning tokens were used + if response.Usage != nil && response.Usage.OutputTokensDetails != nil && + response.Usage.OutputTokensDetails.ReasoningTokens > 0 { + t.Logf("πŸ”’ Reasoning tokens used: %d", response.Usage.OutputTokensDetails.ReasoningTokens) + reasoningFound = true // Reasoning tokens indicate reasoning was performed + } + + // Log findings + detected := reasoningFound || reasoningContentFound + if detected { + t.Logf("βœ… Responses API reasoning indicators detected") + if reasoningFound { + t.Logf(" - ResponsesMessageTypeReasoning or reasoning tokens found") + } + if reasoningContentFound { + t.Logf(" - ResponsesOutputMessageContentTypeReasoning content blocks found") + } + if summaryFound { + t.Logf(" - Reasoning summary content found") + } + } else { + t.Logf("ℹ️ No explicit reasoning indicators found (may be provider-specific)") + } + + return detected +} + +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/tests/core-providers/scenarios/response_validation.go b/tests/core-providers/scenarios/response_validation.go new file mode 100644 index 000000000..862fafa60 --- /dev/null +++ b/tests/core-providers/scenarios/response_validation.go @@ -0,0 +1,1350 @@ +package scenarios + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ============================================================================= +// RESPONSE VALIDATION FRAMEWORK +// ============================================================================= + +// ResponseExpectations defines what we expect from a response +type ResponseExpectations struct { + // Basic structure expectations + ShouldHaveContent bool // Response should have non-empty content + MinContentLength int // Minimum content length + MaxContentLength int // Maximum content length (0 = no limit) + ExpectedChoiceCount int // Expected number of choices (0 = any) + ExpectedFinishReason *string // Expected finish reason + + // Content expectations + ShouldContainKeywords []string // Content should contain ALL these keywords (AND logic) + ShouldContainAnyOf []string // Content should contain AT LEAST ONE of these keywords (OR logic) + ShouldNotContainWords []string // Content should NOT contain these words + ContentPattern *regexp.Regexp // Content should match this pattern + IsRelevantToPrompt bool // Content should be relevant to the original prompt + + // Tool calling expectations + ExpectedToolCalls []ToolCallExpectation // Expected tool calls + ShouldNotHaveFunctionCalls bool // Should not have any function calls + + // Technical expectations + ShouldHaveUsageStats bool // Should have token usage information + ShouldHaveTimestamps bool // Should have created timestamp + ShouldHaveModel bool // Should have model field + ShouldHaveLatency bool // Should have latency information in ExtraFields + + // Provider-specific expectations + ProviderSpecific map[string]interface{} // Provider-specific validation data +} + +// ToolCallExpectation defines expectations for a specific tool call +type ToolCallExpectation struct { + FunctionName string // Expected function name + RequiredArgs []string // Arguments that must be present + ForbiddenArgs []string // Arguments that should NOT be present + ArgumentTypes map[string]string // Expected types for arguments ("string", "number", "boolean", "array", "object") + ArgumentValues map[string]interface{} // Specific expected values for arguments + ValidateArgsJSON bool // Whether arguments should be valid JSON +} + +// ValidationResult contains the results of response validation +type ValidationResult struct { + Passed bool // Overall validation result + Errors []string // List of validation errors + Warnings []string // List of validation warnings + MetricsCollected map[string]interface{} // Collected metrics for analysis +} + +// ============================================================================= +// MAIN VALIDATION FUNCTIONS +// ============================================================================= + +// ValidateChatResponse performs comprehensive validation for chat completion responses +func ValidateChatResponse(t *testing.T, response *schemas.BifrostChatResponse, err *schemas.BifrostError, expectations ResponseExpectations, scenarioName string) ValidationResult { + result := ValidationResult{ + Passed: true, + Errors: make([]string, 0), + Warnings: make([]string, 0), + MetricsCollected: make(map[string]interface{}), + } + + // If there's an error when we expected success, that's a failure + if err != nil { + result.Passed = false + parsed := ParseBifrostError(err) + result.Errors = append(result.Errors, fmt.Sprintf("Got error when expecting success: %s", FormatErrorConcise(parsed))) + LogError(t, err, scenarioName) + return result + } + + // If response is nil when we expected success, that's a failure + if response == nil { + result.Passed = false + result.Errors = append(result.Errors, "Response is nil") + return result + } + + // Validate basic structure + validateChatBasicStructure(t, response, expectations, &result) + + // Validate content + validateChatContent(t, response, expectations, &result) + + // Validate tool calls + validateChatToolCalls(t, response, expectations, &result) + + // Validate technical fields + validateChatTechnicalFields(t, response, expectations, &result) + + // Collect metrics + collectChatResponseMetrics(response, &result) + + // Log results + logValidationResults(t, result, scenarioName) + + return result +} + +// ValidateTextCompletionResponse performs comprehensive validation for text completion responses +func ValidateTextCompletionResponse(t *testing.T, response *schemas.BifrostTextCompletionResponse, err *schemas.BifrostError, expectations ResponseExpectations, scenarioName string) ValidationResult { + result := ValidationResult{ + Passed: true, + Errors: make([]string, 0), + Warnings: make([]string, 0), + MetricsCollected: make(map[string]interface{}), + } + + // If there's an error when we expected success, that's a failure + if err != nil { + result.Passed = false + parsed := ParseBifrostError(err) + result.Errors = append(result.Errors, fmt.Sprintf("Got error when expecting success: %s", FormatErrorConcise(parsed))) + LogError(t, err, scenarioName) + return result + } + + // If response is nil when we expected success, that's a failure + if response == nil { + result.Passed = false + result.Errors = append(result.Errors, "Response is nil") + return result + } + + // Validate basic structure + validateTextCompletionBasicStructure(t, response, expectations, &result) + + // Validate content + validateTextCompletionContent(t, response, expectations, &result) + + // Validate technical fields + validateTextCompletionTechnicalFields(t, response, expectations, &result) + + // Collect metrics + collectTextCompletionResponseMetrics(response, &result) + + // Log results + logValidationResults(t, result, scenarioName) + + return result +} + +// ValidateResponsesResponse performs comprehensive validation for Responses API responses +func ValidateResponsesResponse(t *testing.T, response *schemas.BifrostResponsesResponse, err *schemas.BifrostError, expectations ResponseExpectations, scenarioName string) ValidationResult { + result := ValidationResult{ + Passed: true, + Errors: make([]string, 0), + Warnings: make([]string, 0), + MetricsCollected: make(map[string]interface{}), + } + + // If there's an error when we expected success, that's a failure + if err != nil { + result.Passed = false + parsed := ParseBifrostError(err) + result.Errors = append(result.Errors, fmt.Sprintf("Got error when expecting success: %s", FormatErrorConcise(parsed))) + LogError(t, err, scenarioName) + return result + } + + // If response is nil when we expected success, that's a failure + if response == nil { + result.Passed = false + result.Errors = append(result.Errors, "Response is nil") + return result + } + + // Validate basic structure + validateResponsesBasicStructure(response, expectations, &result) + + // Validate content + validateResponsesContent(t, response, expectations, &result) + + // Validate tool calls + validateResponsesToolCalls(t, response, expectations, &result) + + // Validate technical fields + validateResponsesTechnicalFields(t, response, expectations, &result) + + // Collect metrics + collectResponsesResponseMetrics(response, &result) + + // Log results + logValidationResults(t, result, scenarioName) + + return result +} + +// ValidateSpeechResponse performs comprehensive validation for speech synthesis responses +func ValidateSpeechResponse(t *testing.T, response *schemas.BifrostSpeechResponse, err *schemas.BifrostError, expectations ResponseExpectations, scenarioName string) ValidationResult { + result := ValidationResult{ + Passed: true, + Errors: make([]string, 0), + Warnings: make([]string, 0), + MetricsCollected: make(map[string]interface{}), + } + + // If there's an error when we expected success, that's a failure + if err != nil { + result.Passed = false + parsed := ParseBifrostError(err) + result.Errors = append(result.Errors, fmt.Sprintf("Got error when expecting success: %s", FormatErrorConcise(parsed))) + LogError(t, err, scenarioName) + return result + } + + // If response is nil when we expected success, that's a failure + if response == nil { + result.Passed = false + result.Errors = append(result.Errors, "Response is nil") + return result + } + + // Validate speech synthesis specific fields + validateSpeechSynthesisResponse(t, response, expectations, &result) + + // Collect metrics + collectSpeechResponseMetrics(response, &result) + + // Log results + logValidationResults(t, result, scenarioName) + + return result +} + +// ValidateTranscriptionResponse performs comprehensive validation for transcription responses +func ValidateTranscriptionResponse(t *testing.T, response *schemas.BifrostTranscriptionResponse, err *schemas.BifrostError, expectations ResponseExpectations, scenarioName string) ValidationResult { + result := ValidationResult{ + Passed: true, + Errors: make([]string, 0), + Warnings: make([]string, 0), + MetricsCollected: make(map[string]interface{}), + } + + // If there's an error when we expected success, that's a failure + if err != nil { + result.Passed = false + parsed := ParseBifrostError(err) + result.Errors = append(result.Errors, fmt.Sprintf("Got error when expecting success: %s", FormatErrorConcise(parsed))) + LogError(t, err, scenarioName) + return result + } + + // If response is nil when we expected success, that's a failure + if response == nil { + result.Passed = false + result.Errors = append(result.Errors, "Response is nil") + return result + } + + // Validate transcription specific fields + validateTranscriptionFields(t, response, expectations, &result) + + // Collect metrics + collectTranscriptionResponseMetrics(response, &result) + + // Log results + logValidationResults(t, result, scenarioName) + + return result +} + +// ValidateEmbeddingResponse performs comprehensive validation for embedding responses +func ValidateEmbeddingResponse(t *testing.T, response *schemas.BifrostEmbeddingResponse, err *schemas.BifrostError, expectations ResponseExpectations, scenarioName string) ValidationResult { + result := ValidationResult{ + Passed: true, + Errors: make([]string, 0), + Warnings: make([]string, 0), + MetricsCollected: make(map[string]interface{}), + } + + // If there's an error when we expected success, that's a failure + if err != nil { + result.Passed = false + parsed := ParseBifrostError(err) + result.Errors = append(result.Errors, fmt.Sprintf("Got error when expecting success: %s", FormatErrorConcise(parsed))) + LogError(t, err, scenarioName) + return result + } + + // If response is nil when we expected success, that's a failure + if response == nil { + result.Passed = false + result.Errors = append(result.Errors, "Response is nil") + return result + } + + // Validate embedding specific fields + validateEmbeddingFields(t, response, expectations, &result) + + // Collect metrics + collectEmbeddingResponseMetrics(response, &result) + + // Log results + logValidationResults(t, result, scenarioName) + + return result +} + +// ============================================================================= +// VALIDATION HELPER FUNCTIONS - CHAT RESPONSE +// ============================================================================= + +// validateChatBasicStructure checks the basic structure of the chat response +func validateChatBasicStructure(t *testing.T, response *schemas.BifrostChatResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check choice count + if expectations.ExpectedChoiceCount > 0 { + actualCount := 0 + if response.Choices != nil { + actualCount = len(response.Choices) + } + if actualCount != expectations.ExpectedChoiceCount { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Expected %d choices, got %d", expectations.ExpectedChoiceCount, actualCount)) + } + } + + // Check finish reasons + if expectations.ExpectedFinishReason != nil && response.Choices != nil { + for i, choice := range response.Choices { + if choice.FinishReason == nil { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Choice %d has no finish reason", i)) + } else if *choice.FinishReason != *expectations.ExpectedFinishReason { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Choice %d has finish reason '%s', expected '%s'", + i, *choice.FinishReason, *expectations.ExpectedFinishReason)) + } + } + } +} + +// validateChatContent checks the content of the chat response +func validateChatContent(t *testing.T, response *schemas.BifrostChatResponse, expectations ResponseExpectations, result *ValidationResult) { + // Skip content validation for responses that don't have text content + if !expectations.ShouldHaveContent { + return + } + + content := GetChatContent(response) + + // Check if content exists when expected + if expectations.ShouldHaveContent { + if strings.TrimSpace(content) == "" { + result.Passed = false + result.Errors = append(result.Errors, "Expected content but got empty response") + return + } + } + + // Check content length + contentLen := len(strings.TrimSpace(content)) + if expectations.MinContentLength > 0 && contentLen < expectations.MinContentLength { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content length %d is below minimum %d", contentLen, expectations.MinContentLength)) + } + + if expectations.MaxContentLength > 0 && contentLen > expectations.MaxContentLength { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Content length %d exceeds maximum %d", contentLen, expectations.MaxContentLength)) + } + + // Check required keywords (AND logic - ALL must be present) + lowerContent := strings.ToLower(content) + for _, keyword := range expectations.ShouldContainKeywords { + if !strings.Contains(lowerContent, strings.ToLower(keyword)) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content should contain keyword '%s' but doesn't. Actual content: %s", + keyword, truncateContentForError(content, 200))) + } + } + + // Check OR keywords (OR logic - AT LEAST ONE must be present) + if len(expectations.ShouldContainAnyOf) > 0 { + foundAny := false + for _, keyword := range expectations.ShouldContainAnyOf { + if strings.Contains(lowerContent, strings.ToLower(keyword)) { + foundAny = true + break + } + } + if !foundAny { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content should contain at least one of these keywords: %v, but doesn't. Actual content: %s", + expectations.ShouldContainAnyOf, truncateContentForError(content, 200))) + } + } + + // Check forbidden words + for _, word := range expectations.ShouldNotContainWords { + if strings.Contains(lowerContent, strings.ToLower(word)) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content contains forbidden word '%s'. Actual content: %s", + word, truncateContentForError(content, 200))) + } + } + + // Check content pattern + if expectations.ContentPattern != nil { + if !expectations.ContentPattern.MatchString(content) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content doesn't match expected pattern: %s. Actual content: %s", + expectations.ContentPattern.String(), truncateContentForError(content, 200))) + } + } + + // Store content for metrics + result.MetricsCollected["content_length"] = contentLen + result.MetricsCollected["content_word_count"] = len(strings.Fields(content)) +} + +// validateChatToolCalls checks tool calling aspects of chat response +func validateChatToolCalls(t *testing.T, response *schemas.BifrostChatResponse, expectations ResponseExpectations, result *ValidationResult) { + totalToolCalls := 0 + + // Count tool calls from Chat Completions API + if response.Choices != nil { + for _, choice := range response.Choices { + if choice.Message.ChatAssistantMessage != nil && choice.Message.ChatAssistantMessage.ToolCalls != nil { + totalToolCalls += len(choice.Message.ChatAssistantMessage.ToolCalls) + } + } + } + + // Check if we should have no function calls + if expectations.ShouldNotHaveFunctionCalls && totalToolCalls > 0 { + result.Passed = false + actualToolNames := extractChatToolCallNames(response) + result.Errors = append(result.Errors, + fmt.Sprintf("Expected no function calls but found %d: %v", totalToolCalls, actualToolNames)) + } + + // Validate specific tool calls + if len(expectations.ExpectedToolCalls) > 0 { + validateChatSpecificToolCalls(response, expectations.ExpectedToolCalls, result) + } + + result.MetricsCollected["tool_call_count"] = totalToolCalls +} + +// validateChatTechnicalFields checks technical aspects of the chat response +func validateChatTechnicalFields(t *testing.T, response *schemas.BifrostChatResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check usage stats + if expectations.ShouldHaveUsageStats { + if response.Usage == nil { + result.Warnings = append(result.Warnings, "Expected usage statistics but not present") + } else { + // Validate usage makes sense + if response.Usage.TotalTokens < response.Usage.PromptTokens { + result.Warnings = append(result.Warnings, "Total tokens less than prompt tokens") + } + if response.Usage.TotalTokens < response.Usage.CompletionTokens { + result.Warnings = append(result.Warnings, "Total tokens less than completion tokens") + } + } + } + + // Check timestamps + if expectations.ShouldHaveTimestamps { + if response.Created == 0 { + result.Warnings = append(result.Warnings, "Expected created timestamp but not present") + } + } + + // Check model field + if expectations.ShouldHaveModel { + if strings.TrimSpace(response.Model) == "" { + result.Warnings = append(result.Warnings, "Expected model field but not present or empty") + } + } + + // Check latency field + if expectations.ShouldHaveLatency { + if response.ExtraFields.Latency <= 0 { + result.Passed = false + result.Errors = append(result.Errors, "Expected latency information but not present or invalid") + } else { + result.MetricsCollected["latency_ms"] = response.ExtraFields.Latency + } + } +} + +// collectChatResponseMetrics collects metrics from the chat response for analysis +func collectChatResponseMetrics(response *schemas.BifrostChatResponse, result *ValidationResult) { + result.MetricsCollected["choice_count"] = len(response.Choices) + result.MetricsCollected["has_usage"] = response.Usage != nil + result.MetricsCollected["has_model"] = response.Model != "" + result.MetricsCollected["has_timestamp"] = response.Created > 0 + + if response.Usage != nil { + result.MetricsCollected["total_tokens"] = response.Usage.TotalTokens + result.MetricsCollected["prompt_tokens"] = response.Usage.PromptTokens + result.MetricsCollected["completion_tokens"] = response.Usage.CompletionTokens + } +} + +// ============================================================================= +// VALIDATION HELPER FUNCTIONS - TEXT COMPLETION RESPONSE +// ============================================================================= + +// validateTextCompletionBasicStructure checks the basic structure of the text completion response +func validateTextCompletionBasicStructure(t *testing.T, response *schemas.BifrostTextCompletionResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check choice count + if expectations.ExpectedChoiceCount > 0 { + actualCount := 0 + if response.Choices != nil { + actualCount = len(response.Choices) + } + if actualCount != expectations.ExpectedChoiceCount { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Expected %d choices, got %d", expectations.ExpectedChoiceCount, actualCount)) + } + } + + // Check finish reasons + if expectations.ExpectedFinishReason != nil && response.Choices != nil { + for i, choice := range response.Choices { + if choice.FinishReason == nil { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Choice %d has no finish reason", i)) + } else if *choice.FinishReason != *expectations.ExpectedFinishReason { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Choice %d has finish reason '%s', expected '%s'", + i, *choice.FinishReason, *expectations.ExpectedFinishReason)) + } + } + } +} + +// validateTextCompletionContent checks the content of the text completion response +func validateTextCompletionContent(t *testing.T, response *schemas.BifrostTextCompletionResponse, expectations ResponseExpectations, result *ValidationResult) { + // Skip content validation for responses that don't have text content + if !expectations.ShouldHaveContent { + return + } + + content := GetTextCompletionContent(response) + + // Check if content exists when expected + if expectations.ShouldHaveContent { + if strings.TrimSpace(content) == "" { + result.Passed = false + result.Errors = append(result.Errors, "Expected content but got empty response") + return + } + } + + // Check content length + contentLen := len(strings.TrimSpace(content)) + if expectations.MinContentLength > 0 && contentLen < expectations.MinContentLength { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content length %d is below minimum %d", contentLen, expectations.MinContentLength)) + } + + if expectations.MaxContentLength > 0 && contentLen > expectations.MaxContentLength { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Content length %d exceeds maximum %d", contentLen, expectations.MaxContentLength)) + } + + // Check required keywords (AND logic - ALL must be present) + lowerContent := strings.ToLower(content) + for _, keyword := range expectations.ShouldContainKeywords { + if !strings.Contains(lowerContent, strings.ToLower(keyword)) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content should contain keyword '%s' but doesn't. Actual content: %s", + keyword, truncateContentForError(content, 200))) + } + } + + // Check OR keywords (OR logic - AT LEAST ONE must be present) + if len(expectations.ShouldContainAnyOf) > 0 { + foundAny := false + for _, keyword := range expectations.ShouldContainAnyOf { + if strings.Contains(lowerContent, strings.ToLower(keyword)) { + foundAny = true + break + } + } + if !foundAny { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content should contain at least one of these keywords: %v, but doesn't. Actual content: %s", + expectations.ShouldContainAnyOf, truncateContentForError(content, 200))) + } + } + + // Check forbidden words + for _, word := range expectations.ShouldNotContainWords { + if strings.Contains(lowerContent, strings.ToLower(word)) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content contains forbidden word '%s'. Actual content: %s", + word, truncateContentForError(content, 200))) + } + } + + // Check content pattern + if expectations.ContentPattern != nil { + if !expectations.ContentPattern.MatchString(content) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content doesn't match expected pattern: %s. Actual content: %s", + expectations.ContentPattern.String(), truncateContentForError(content, 200))) + } + } + + // Store content for metrics + result.MetricsCollected["content_length"] = contentLen + result.MetricsCollected["content_word_count"] = len(strings.Fields(content)) +} + +// validateTextCompletionTechnicalFields checks technical aspects of the text completion response +func validateTextCompletionTechnicalFields(t *testing.T, response *schemas.BifrostTextCompletionResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check usage stats + if expectations.ShouldHaveUsageStats { + if response.Usage == nil { + result.Warnings = append(result.Warnings, "Expected usage statistics but not present") + } else { + // Validate usage makes sense + if response.Usage.TotalTokens < response.Usage.PromptTokens { + result.Warnings = append(result.Warnings, "Total tokens less than prompt tokens") + } + if response.Usage.TotalTokens < response.Usage.CompletionTokens { + result.Warnings = append(result.Warnings, "Total tokens less than completion tokens") + } + } + } + + // Check timestamps - Text completion responses don't have a Created field + if expectations.ShouldHaveTimestamps { + // Text completion responses don't have timestamps, so skip this check + result.Warnings = append(result.Warnings, "Text completion responses don't support timestamp validation") + } + + // Check model field + if expectations.ShouldHaveModel { + if strings.TrimSpace(response.Model) == "" { + result.Warnings = append(result.Warnings, "Expected model field but not present or empty") + } + } + + // Check latency field + if expectations.ShouldHaveLatency { + if response.ExtraFields.Latency <= 0 { + result.Passed = false + result.Errors = append(result.Errors, "Expected latency information but not present or invalid") + } else { + result.MetricsCollected["latency_ms"] = response.ExtraFields.Latency + } + } +} + +// collectTextCompletionResponseMetrics collects metrics from the text completion response for analysis +func collectTextCompletionResponseMetrics(response *schemas.BifrostTextCompletionResponse, result *ValidationResult) { + result.MetricsCollected["choice_count"] = len(response.Choices) + result.MetricsCollected["has_usage"] = response.Usage != nil + result.MetricsCollected["has_model"] = response.Model != "" + result.MetricsCollected["has_timestamp"] = false // Text completion responses don't have timestamps + + if response.Usage != nil { + result.MetricsCollected["total_tokens"] = response.Usage.TotalTokens + result.MetricsCollected["prompt_tokens"] = response.Usage.PromptTokens + result.MetricsCollected["completion_tokens"] = response.Usage.CompletionTokens + } +} + +// ============================================================================= +// VALIDATION HELPER FUNCTIONS - RESPONSES API +// ============================================================================= + +// validateResponsesBasicStructure checks the basic structure of the Responses API response +func validateResponsesBasicStructure(response *schemas.BifrostResponsesResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check choice count + if expectations.ExpectedChoiceCount > 0 { + actualCount := 0 + if response.Output != nil { + // For Responses API, count "logical choices" instead of raw message count + // Group related messages (text + tool calls) as one logical choice + actualCount = countLogicalChoicesInResponsesAPI(response.Output) + } + if actualCount != expectations.ExpectedChoiceCount { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Expected %d choices, got %d", expectations.ExpectedChoiceCount, actualCount)) + } + } +} + +// validateResponsesContent checks the content of the Responses API response +func validateResponsesContent(t *testing.T, response *schemas.BifrostResponsesResponse, expectations ResponseExpectations, result *ValidationResult) { + // Skip content validation for responses that don't have text content + if !expectations.ShouldHaveContent { + return + } + + content := GetResponsesContent(response) + + // Check if content exists when expected + if expectations.ShouldHaveContent { + if strings.TrimSpace(content) == "" { + result.Passed = false + result.Errors = append(result.Errors, "Expected content but got empty response") + return + } + } + + // Check content length + contentLen := len(strings.TrimSpace(content)) + if expectations.MinContentLength > 0 && contentLen < expectations.MinContentLength { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content length %d is below minimum %d", contentLen, expectations.MinContentLength)) + } + + if expectations.MaxContentLength > 0 && contentLen > expectations.MaxContentLength { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Content length %d exceeds maximum %d", contentLen, expectations.MaxContentLength)) + } + + // Check required keywords (AND logic - ALL must be present) + lowerContent := strings.ToLower(content) + for _, keyword := range expectations.ShouldContainKeywords { + if !strings.Contains(lowerContent, strings.ToLower(keyword)) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content should contain keyword '%s' but doesn't. Actual content: %s", + keyword, truncateContentForError(content, 200))) + } + } + + // Check OR keywords (OR logic - AT LEAST ONE must be present) + if len(expectations.ShouldContainAnyOf) > 0 { + foundAny := false + for _, keyword := range expectations.ShouldContainAnyOf { + if strings.Contains(lowerContent, strings.ToLower(keyword)) { + foundAny = true + break + } + } + if !foundAny { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content should contain at least one of these keywords: %v, but doesn't. Actual content: %s", + expectations.ShouldContainAnyOf, truncateContentForError(content, 200))) + } + } + + // Check forbidden words + for _, word := range expectations.ShouldNotContainWords { + if strings.Contains(lowerContent, strings.ToLower(word)) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content contains forbidden word '%s'. Actual content: %s", + word, truncateContentForError(content, 200))) + } + } + + // Check content pattern + if expectations.ContentPattern != nil { + if !expectations.ContentPattern.MatchString(content) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Content doesn't match expected pattern: %s. Actual content: %s", + expectations.ContentPattern.String(), truncateContentForError(content, 200))) + } + } + + // Store content for metrics + result.MetricsCollected["content_length"] = contentLen + result.MetricsCollected["content_word_count"] = len(strings.Fields(content)) +} + +// validateResponsesToolCalls checks tool calling aspects of Responses API response +func validateResponsesToolCalls(t *testing.T, response *schemas.BifrostResponsesResponse, expectations ResponseExpectations, result *ValidationResult) { + totalToolCalls := 0 + + // Count tool calls from Responses API + if response.Output != nil { + for _, output := range response.Output { + // Check if this message contains tool call data regardless of Type + if output.ResponsesToolMessage != nil { + totalToolCalls++ + } + } + } + + // Check if we should have no function calls + if expectations.ShouldNotHaveFunctionCalls && totalToolCalls > 0 { + result.Passed = false + actualToolNames := extractResponsesToolCallNames(response) + result.Errors = append(result.Errors, + fmt.Sprintf("Expected no function calls but found %d: %v", totalToolCalls, actualToolNames)) + } + + // Validate specific tool calls + if len(expectations.ExpectedToolCalls) > 0 { + validateResponsesSpecificToolCalls(response, expectations.ExpectedToolCalls, result) + } + + result.MetricsCollected["tool_call_count"] = totalToolCalls +} + +// validateResponsesTechnicalFields checks technical aspects of the Responses API response +func validateResponsesTechnicalFields(t *testing.T, response *schemas.BifrostResponsesResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check usage stats + if expectations.ShouldHaveUsageStats { + if response.Usage == nil { + result.Warnings = append(result.Warnings, "Expected usage statistics but not present") + } + } + + // Check timestamps + if expectations.ShouldHaveTimestamps { + if response.CreatedAt == 0 { + result.Warnings = append(result.Warnings, "Expected created timestamp but not present") + } + } + + // Check latency field + if expectations.ShouldHaveLatency { + if response.ExtraFields.Latency <= 0 { + result.Passed = false + result.Errors = append(result.Errors, "Expected latency information but not present or invalid") + } else { + result.MetricsCollected["latency_ms"] = response.ExtraFields.Latency + } + } +} + +// collectResponsesResponseMetrics collects metrics from the Responses API response for analysis +func collectResponsesResponseMetrics(response *schemas.BifrostResponsesResponse, result *ValidationResult) { + if response.Output != nil { + result.MetricsCollected["choice_count"] = len(response.Output) + } + result.MetricsCollected["has_usage"] = response.Usage != nil + result.MetricsCollected["has_timestamp"] = response.CreatedAt > 0 + + if response.Usage != nil { + // Responses API has different usage structure + result.MetricsCollected["usage_present"] = true + } +} + +// ============================================================================= +// VALIDATION HELPER FUNCTIONS - SPEECH RESPONSE +// ============================================================================= + +// validateSpeechSynthesisResponse validates speech synthesis responses +func validateSpeechSynthesisResponse(t *testing.T, response *schemas.BifrostSpeechResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check if response has speech data + if response.Audio == nil { + result.Passed = false + result.Errors = append(result.Errors, "Speech synthesis response missing Audio field") + return + } + + // Check if audio data exists + shouldHaveAudio, _ := expectations.ProviderSpecific["should_have_audio"].(bool) + if shouldHaveAudio && response.Audio == nil { + result.Passed = false + result.Errors = append(result.Errors, "Speech synthesis response missing audio data") + return + } + + // Check minimum audio bytes + if minBytes, ok := expectations.ProviderSpecific["min_audio_bytes"].(int); ok { + if response.Audio != nil { + actualSize := len(response.Audio) + if actualSize < minBytes { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Audio data too small: got %d bytes, expected at least %d", actualSize, minBytes)) + } else { + result.MetricsCollected["audio_bytes"] = actualSize + } + } + } + + // Validate audio format if specified + if expectedFormat, ok := expectations.ProviderSpecific["expected_format"].(string); ok { + // This could be extended to validate actual audio format based on file headers + result.MetricsCollected["expected_audio_format"] = expectedFormat + } + + // Check latency field + if expectations.ShouldHaveLatency { + if response.ExtraFields.Latency <= 0 { + result.Passed = false + result.Errors = append(result.Errors, "Expected latency information but not present or invalid") + } else { + result.MetricsCollected["latency_ms"] = response.ExtraFields.Latency + } + } + + result.MetricsCollected["speech_validation"] = "completed" +} + +// collectSpeechResponseMetrics collects metrics from the speech response for analysis +func collectSpeechResponseMetrics(response *schemas.BifrostSpeechResponse, result *ValidationResult) { + result.MetricsCollected["has_audio"] = response.Audio != nil + if response.Audio != nil { + result.MetricsCollected["audio_size"] = len(response.Audio) + } +} + +// ============================================================================= +// VALIDATION HELPER FUNCTIONS - TRANSCRIPTION RESPONSE +// ============================================================================= + +// validateTranscriptionFields validates transcription responses +func validateTranscriptionFields(t *testing.T, response *schemas.BifrostTranscriptionResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check if transcribed text exists + shouldHaveTranscription, _ := expectations.ProviderSpecific["should_have_transcription"].(bool) + if shouldHaveTranscription && response.Text == "" { + result.Passed = false + result.Errors = append(result.Errors, "Transcription response missing transcribed text") + return + } + + // Check minimum transcription length + if minLength, ok := expectations.ProviderSpecific["min_transcription_length"].(int); ok { + actualLength := len(response.Text) + if actualLength < minLength { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Transcribed text too short: got %d characters, expected at least %d", actualLength, minLength)) + } else { + result.MetricsCollected["transcription_length"] = actualLength + } + } + + // Check for common transcription failure indicators + transcribedText := strings.ToLower(response.Text) + for _, errorPhrase := range expectations.ShouldNotContainWords { + if strings.Contains(transcribedText, errorPhrase) { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Transcribed text contains error indicator: '%s'", errorPhrase)) + } + } + + // Validate additional transcription fields if available + if response.Language != nil { + result.MetricsCollected["detected_language"] = *response.Language + } + if response.Duration != nil { + result.MetricsCollected["audio_duration"] = *response.Duration + } + + // Check latency field + if expectations.ShouldHaveLatency { + if response.ExtraFields.Latency <= 0 { + result.Passed = false + result.Errors = append(result.Errors, "Expected latency information but not present or invalid") + } else { + result.MetricsCollected["latency_ms"] = response.ExtraFields.Latency + } + } + + result.MetricsCollected["transcription_validation"] = "completed" +} + +// collectTranscriptionResponseMetrics collects metrics from the transcription response for analysis +func collectTranscriptionResponseMetrics(response *schemas.BifrostTranscriptionResponse, result *ValidationResult) { + result.MetricsCollected["has_text"] = response.Text != "" + result.MetricsCollected["text_length"] = len(response.Text) + result.MetricsCollected["has_language"] = response.Language != nil + result.MetricsCollected["has_duration"] = response.Duration != nil +} + +// ============================================================================= +// VALIDATION HELPER FUNCTIONS - EMBEDDING RESPONSE +// ============================================================================= + +// validateEmbeddingFields validates embedding responses +func validateEmbeddingFields(t *testing.T, response *schemas.BifrostEmbeddingResponse, expectations ResponseExpectations, result *ValidationResult) { + // Check if response has embedding data + if len(response.Data) == 0 { + result.Passed = false + result.Errors = append(result.Errors, "Embedding response missing data") + return + } + + // Check embedding dimensions + if expectedDimensions, ok := expectations.ProviderSpecific["expected_dimensions"].(int); ok { + for i, embedding := range response.Data { + var actualDimensions int + if embedding.Embedding.EmbeddingArray != nil { + actualDimensions = len(embedding.Embedding.EmbeddingArray) + } else if embedding.Embedding.Embedding2DArray != nil { + if len(embedding.Embedding.Embedding2DArray) > 0 { + actualDimensions = len(embedding.Embedding.Embedding2DArray[0]) + } + } + if actualDimensions != expectedDimensions { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Embedding %d has %d dimensions, expected %d", i, actualDimensions, expectedDimensions)) + } + } + } + + // Check latency field + if expectations.ShouldHaveLatency { + if response.ExtraFields.Latency <= 0 { + result.Passed = false + result.Errors = append(result.Errors, "Expected latency information but not present or invalid") + } else { + result.MetricsCollected["latency_ms"] = response.ExtraFields.Latency + } + } + + result.MetricsCollected["embedding_validation"] = "completed" +} + +// collectEmbeddingResponseMetrics collects metrics from the embedding response for analysis +func collectEmbeddingResponseMetrics(response *schemas.BifrostEmbeddingResponse, result *ValidationResult) { + result.MetricsCollected["has_data"] = response.Data != nil + result.MetricsCollected["embedding_count"] = len(response.Data) + result.MetricsCollected["has_usage"] = response.Usage != nil + if len(response.Data) > 0 { + var dimensions int + if response.Data[0].Embedding.EmbeddingArray != nil { + dimensions = len(response.Data[0].Embedding.EmbeddingArray) + } else if len(response.Data[0].Embedding.Embedding2DArray) > 0 { + dimensions = len(response.Data[0].Embedding.Embedding2DArray[0]) + } + result.MetricsCollected["embedding_dimensions"] = dimensions + } +} + +// extractChatToolCallNames extracts tool call function names from chat response for error messages +func extractChatToolCallNames(response *schemas.BifrostChatResponse) []string { + var toolNames []string + + if response.Choices != nil { + for _, choice := range response.Choices { + if choice.Message.ChatAssistantMessage != nil && choice.Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range choice.Message.ChatAssistantMessage.ToolCalls { + if toolCall.Function.Name != nil { + toolNames = append(toolNames, *toolCall.Function.Name) + } + } + } + } + } + return toolNames +} + +// extractResponsesToolCallNames extracts tool call function names from Responses API response for error messages +func extractResponsesToolCallNames(response *schemas.BifrostResponsesResponse) []string { + var toolNames []string + + if response.Output != nil { + for _, output := range response.Output { + if output.ResponsesToolMessage != nil && output.Name != nil { + toolNames = append(toolNames, *output.Name) + } + } + } + return toolNames +} + +// validateChatSpecificToolCalls validates individual tool call expectations for chat response +func validateChatSpecificToolCalls(response *schemas.BifrostChatResponse, expectedCalls []ToolCallExpectation, result *ValidationResult) { + for _, expected := range expectedCalls { + found := false + + if response.Choices != nil { + for _, message := range response.Choices { + if message.Message.ChatAssistantMessage != nil && message.Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range message.Message.ChatAssistantMessage.ToolCalls { + if toolCall.Function.Name != nil && *toolCall.Function.Name == expected.FunctionName { + arguments := toolCall.Function.Arguments + found = true + validateSingleToolCall(arguments, expected, 0, 0, result) + break + } + } + } + } + } + + if !found { + result.Passed = false + actualToolNames := extractChatToolCallNames(response) + if len(actualToolNames) == 0 { + result.Errors = append(result.Errors, + fmt.Sprintf("Expected tool call '%s' not found (no tool calls present)", expected.FunctionName)) + } else { + result.Errors = append(result.Errors, + fmt.Sprintf("Expected tool call '%s' not found. Actual tool calls found: %v", + expected.FunctionName, actualToolNames)) + } + } + } +} + +// validateResponsesSpecificToolCalls validates individual tool call expectations for Responses API response +func validateResponsesSpecificToolCalls(response *schemas.BifrostResponsesResponse, expectedCalls []ToolCallExpectation, result *ValidationResult) { + for _, expected := range expectedCalls { + found := false + + if response.Output != nil { + for _, message := range response.Output { + if message.ResponsesToolMessage != nil && + message.ResponsesToolMessage.Name != nil && + *message.ResponsesToolMessage.Name == expected.FunctionName { + if message.ResponsesToolMessage.Arguments != nil { + arguments := *message.ResponsesToolMessage.Arguments + found = true + validateSingleToolCall(arguments, expected, 0, 0, result) + break + } + } + } + } + + if !found { + result.Passed = false + actualToolNames := extractResponsesToolCallNames(response) + if len(actualToolNames) == 0 { + result.Errors = append(result.Errors, + fmt.Sprintf("Expected tool call '%s' not found (no tool calls present)", expected.FunctionName)) + } else { + result.Errors = append(result.Errors, + fmt.Sprintf("Expected tool call '%s' not found. Actual tool calls found: %v", + expected.FunctionName, actualToolNames)) + } + } + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +// truncateContentForError safely truncates content for error messages +func truncateContentForError(content string, maxLength int) string { + content = strings.TrimSpace(content) + if len(content) <= maxLength { + return fmt.Sprintf("'%s'", content) + } + return fmt.Sprintf("'%s...' (truncated from %d chars)", content[:maxLength], len(content)) +} + +// getJSONType returns the JSON type of a value +func getJSONType(value interface{}) string { + switch value.(type) { + case string: + return "string" + case float64, int, int64: + return "number" + case bool: + return "boolean" + case []interface{}: + return "array" + case map[string]interface{}: + return "object" + case nil: + return "null" + default: + return "unknown" + } +} + +// validateSingleToolCall validates a specific tool call against expectations +func validateSingleToolCall(arguments interface{}, expected ToolCallExpectation, choiceIdx, callIdx int, result *ValidationResult) { + // Parse arguments with safe type handling + var args map[string]interface{} + + if expected.ValidateArgsJSON { + // Handle nil arguments + if arguments == nil { + args = nil + } else if argsMap, ok := arguments.(map[string]interface{}); ok { + // Already a map, use directly + args = argsMap + } else if argsMapInterface, ok := arguments.(map[interface{}]interface{}); ok { + // Convert map[interface{}]interface{} to map[string]interface{} + args = make(map[string]interface{}) + for k, v := range argsMapInterface { + if keyStr, ok := k.(string); ok { + args[keyStr] = v + } + } + } else if argsStr, ok := arguments.(string); ok { + // String type - unmarshal as JSON + if err := json.Unmarshal([]byte(argsStr), &args); err != nil { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s (choice %d, call %d) has invalid JSON arguments: %s", + expected.FunctionName, choiceIdx, callIdx, err.Error())) + return + } + } else if argsBytes, ok := arguments.([]byte); ok { + // []byte type - unmarshal as JSON + if err := json.Unmarshal(argsBytes, &args); err != nil { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s (choice %d, call %d) has invalid JSON arguments: %s", + expected.FunctionName, choiceIdx, callIdx, err.Error())) + return + } + } else { + // Unsupported type + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s (choice %d, call %d) has unsupported argument type: %T", + expected.FunctionName, choiceIdx, callIdx, arguments)) + return + } + } + + // Check required arguments + for _, reqArg := range expected.RequiredArgs { + if _, exists := args[reqArg]; !exists { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s missing required argument '%s'", expected.FunctionName, reqArg)) + } + } + + // Check forbidden arguments + for _, forbiddenArg := range expected.ForbiddenArgs { + if _, exists := args[forbiddenArg]; exists { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s has forbidden argument '%s'", expected.FunctionName, forbiddenArg)) + } + } + + // Check argument types + for argName, expectedType := range expected.ArgumentTypes { + if value, exists := args[argName]; exists { + actualType := getJSONType(value) + if actualType != expectedType { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s argument '%s' is %s, expected %s", + expected.FunctionName, argName, actualType, expectedType)) + } + } + } + + // Check specific argument values + for argName, expectedValue := range expected.ArgumentValues { + if actualValue, exists := args[argName]; exists { + if actualValue != expectedValue { + result.Passed = false + result.Errors = append(result.Errors, + fmt.Sprintf("Tool call %s argument '%s' is %v, expected %v", + expected.FunctionName, argName, actualValue, expectedValue)) + } + } + } +} + +// logValidationResults logs the validation results +func logValidationResults(t *testing.T, result ValidationResult, scenarioName string) { + if result.Passed { + t.Logf("βœ… Validation passed for %s", scenarioName) + } else { + // LogF, not ErrorF else later retries will still fail the test + t.Logf("❌ Validation failed for %s with %d errors", scenarioName, len(result.Errors)) + for _, err := range result.Errors { + t.Logf(" Error: %s", err) + } + } + + if len(result.Warnings) > 0 { + t.Logf("⚠️ %d warnings for %s", len(result.Warnings), scenarioName) + for _, warning := range result.Warnings { + t.Logf(" Warning: %s", warning) + } + } +} + +// countLogicalChoicesInResponsesAPI counts logical choices in Responses API format +// Groups related messages (text + tool calls) as one logical choice to match Chat Completions API behavior +func countLogicalChoicesInResponsesAPI(messages []schemas.ResponsesMessage) int { + if len(messages) == 0 { + return 0 + } + + // For tool call scenarios, we typically have: + // 1. Text message (ResponsesMessageTypeMessage) + // 2. Tool call message(s) (ResponsesMessageTypeFunctionCall) + // These should count as 1 logical choice + + hasTextMessage := false + hasToolCalls := false + hasSeparateMessages := false + + for _, msg := range messages { + if msg.Type != nil { + switch *msg.Type { + case schemas.ResponsesMessageTypeMessage: + hasTextMessage = true + case schemas.ResponsesMessageTypeFunctionCall: + hasToolCalls = true + case schemas.ResponsesMessageTypeReasoning, schemas.ResponsesMessageTypeRefusal: + hasSeparateMessages = true + } + } + } + + // If we have both text and tool calls, count as 1 logical choice + // This matches the Chat Completions API behavior where both are in the same choice + if hasTextMessage && hasToolCalls { + return 1 + (func() int { + if hasSeparateMessages { + return 1 // Add 1 for reasoning/refusal messages + } + return 0 + })() + } + + // If only tool calls (no text), still count as 1 logical choice + if hasToolCalls && !hasTextMessage { + return 1 + } + + // If only text message(s) or other types, count actual messages + return len(messages) +} diff --git a/tests/core-providers/scenarios/responses_stream.go b/tests/core-providers/scenarios/responses_stream.go new file mode 100644 index 000000000..ed9206465 --- /dev/null +++ b/tests/core-providers/scenarios/responses_stream.go @@ -0,0 +1,624 @@ +package scenarios + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunResponsesStreamTest executes the responses streaming test scenario +func RunResponsesStreamTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.CompletionStream { + t.Logf("Responses completion stream not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ResponsesStream", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + messages := []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: schemas.Ptr("Tell me a short story about a robot learning to paint the city which has the eiffel tower. Keep it under 200 words."), + }, + }, + } + + request := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: messages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + + // Use retry framework for stream requests + retryConfig := StreamingRetryConfig() + retryContext := TestRetryContext{ + ScenarioName: "ResponsesStream", + ExpectedBehavior: map[string]interface{}{ + "should_stream_content": true, + "should_tell_story": true, + "topic": "robot painting", + "should_have_streaming_events": true, + "should_have_sequence_numbers": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + }, + } + + // Use proper streaming retry wrapper for the stream request + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.ResponsesStreamRequest(ctx, request) + }) + + // Enhanced error handling + RequireNoError(t, err, "Responses stream request failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var fullContent strings.Builder + var responseCount int + var lastResponse *schemas.BifrostStream + + // Track streaming events for validation + eventTypes := make(map[schemas.ResponsesStreamResponseType]int) + var sequenceNumbers []int + var hasResponseCreated, hasResponseCompleted bool + var hasOutputItems, hasContentParts bool + + // Create a timeout context for the stream reading + streamCtx, cancel := context.WithTimeout(ctx, 200*time.Second) + defer cancel() + + t.Logf("πŸ“‘ Starting to read responses streaming response...") + + // Read streaming responses + for { + select { + case response, ok := <-responseChannel: + if !ok { + // Channel closed, streaming completed + t.Logf("βœ… Responses streaming completed. Total chunks received: %d", responseCount) + goto streamComplete + } + + if response == nil { + t.Fatal("Streaming response should not be nil") + } + lastResponse = DeepCopyBifrostStream(response) + + // Basic validation of streaming response structure + if response.BifrostResponsesStreamResponse != nil { + if response.BifrostResponsesStreamResponse.ExtraFields.Provider != testConfig.Provider { + t.Logf("⚠️ Warning: Provider mismatch - expected %s, got %s", testConfig.Provider, response.BifrostResponsesStreamResponse.ExtraFields.Provider) + } + + // Log latency for each chunk (can be 0 for inter-chunks) + t.Logf("πŸ“Š Chunk %d latency: %d ms", responseCount+1, response.BifrostResponsesStreamResponse.ExtraFields.Latency) + + // Process the streaming response + streamResp := response.BifrostResponsesStreamResponse + + // Track event types + eventTypes[streamResp.Type]++ + + // Track sequence numbers + sequenceNumbers = append(sequenceNumbers, streamResp.SequenceNumber) + + // Log the streaming event + t.Logf("πŸ“Š Event: %s (seq: %d)", streamResp.Type, streamResp.SequenceNumber) + + // Print chunk content for debugging + switch streamResp.Type { + case schemas.ResponsesStreamResponseTypeOutputTextDelta: + if streamResp.Delta != nil { + fullContent.WriteString(*streamResp.Delta) + t.Logf("πŸ“ Text chunk: %q", *streamResp.Delta) + } + + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + if streamResp.Item != nil { + t.Logf("πŸ“¦ Item added: type=%v, id=%v", streamResp.Item.Type, streamResp.Item.ID) + if streamResp.Item.Content != nil { + if streamResp.Item.Content.ContentStr != nil { + t.Logf("πŸ“ Item content: %q", *streamResp.Item.Content.ContentStr) + fullContent.WriteString(*streamResp.Item.Content.ContentStr) + } + if streamResp.Item.Content.ContentBlocks != nil { + for i, block := range streamResp.Item.Content.ContentBlocks { + if block.Text != nil { + t.Logf("πŸ“ Item content block[%d]: %q", i, *block.Text) + fullContent.WriteString(*block.Text) + } + } + } + } + } + + case schemas.ResponsesStreamResponseTypeContentPartAdded: + if streamResp.Part != nil { + t.Logf("🧩 Content part: type=%s", streamResp.Part.Type) + if streamResp.Part.Text != nil { + t.Logf("πŸ“ Part text: %q", *streamResp.Part.Text) + fullContent.WriteString(*streamResp.Part.Text) + } + } + } + + // Log other event details for debugging + if streamResp.Arguments != nil { + t.Logf("πŸ”§ Arguments: %q", *streamResp.Arguments) + } + if streamResp.Refusal != nil { + t.Logf("🚫 Refusal: %q", *streamResp.Refusal) + } + + // Update state tracking for event types + switch streamResp.Type { + case schemas.ResponsesStreamResponseTypeCreated: + hasResponseCreated = true + t.Logf("🎬 Response created event detected") + + case schemas.ResponsesStreamResponseTypeCompleted: + hasResponseCompleted = true + t.Logf("🏁 Response completed event detected") + + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + hasOutputItems = true + + case schemas.ResponsesStreamResponseTypeContentPartAdded: + hasContentParts = true + + case schemas.ResponsesStreamResponseTypeError: + if streamResp.Message != nil { + t.Fatalf("❌ Error in streaming: %s", *streamResp.Message) + } else { + t.Fatalf("❌ Error in streaming (no message)") + } + } + } + + responseCount++ + + // Safety check to prevent infinite loops + if responseCount > 500 { + t.Fatal("Received too many streaming chunks, something might be wrong") + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for responses streaming response") + } + } + + streamComplete: + // Validate streaming events and structure + validateResponsesStreamingStructure(t, eventTypes, sequenceNumbers, hasResponseCreated, hasResponseCompleted, hasOutputItems, hasContentParts) + + // Validate final content + finalContent := strings.TrimSpace(fullContent.String()) + + // Enhanced validation expectations for responses streaming + expectations := GetExpectationsForScenario("ResponsesStream", testConfig, map[string]interface{}{}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ShouldContainKeywords = append(expectations.ShouldContainKeywords, []string{"paris"}...) // Should include story elements + expectations.MinContentLength = 50 // Should be substantial story + expectations.MaxContentLength = 2000 // Reasonable upper bound + + // Validate streaming-specific aspects instead of using regular response validation + streamingValidationResult := validateResponsesStreamingResponse(t, eventTypes, sequenceNumbers, finalContent, lastResponse, testConfig) + + if !streamingValidationResult.Passed { + t.Logf("⚠️ Responses streaming validation warnings: %v", streamingValidationResult.Errors) + } + + t.Logf("πŸ“Š Responses streaming metrics: %d chunks, %d chars, %d event types", responseCount, len(finalContent), len(eventTypes)) + + t.Logf("βœ… Responses streaming test completed successfully") + t.Logf("πŸ“ Final assembled content (%d chars): %q", len(finalContent), finalContent) + }) + + // Test responses streaming with tool calls if supported + if testConfig.Scenarios.ToolCalls { + t.Run("ResponsesStreamWithTools", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + messages := []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: schemas.Ptr("What's the weather like in San Francisco in celsius? Please use the get_weather function."), + }, + }, + } + + // Create sample weather tool for responses API + tool := &schemas.ResponsesTool{ + Type: "function", + Name: schemas.Ptr("get_weather"), + Description: schemas.Ptr("Get the current weather in a given location"), + ResponsesToolFunction: &schemas.ResponsesToolFunction{ + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{ + "location": map[string]interface{}{ + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "unit": map[string]interface{}{ + "type": "string", + "enum": []string{"celsius", "fahrenheit"}, + }, + }, + Required: []string{"location"}, + }, + }, + } + + request := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: messages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(150), + Tools: []schemas.ResponsesTool{*tool}, + }, + Fallbacks: testConfig.Fallbacks, + } + + responseChannel, err := client.ResponsesStreamRequest(ctx, request) + RequireNoError(t, err, "Responses stream with tools failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var toolCallDetected bool + var functionCallArgsDetected bool + var responseCount int + + streamCtx, cancel := context.WithTimeout(ctx, 200*time.Second) + defer cancel() + + t.Logf("πŸ”§ Testing responses streaming with tool calls...") + + for { + select { + case response, ok := <-responseChannel: + if !ok { + goto toolStreamComplete + } + + if response == nil { + t.Fatal("Streaming response should not be nil") + } + responseCount++ + + if response.BifrostResponsesStreamResponse != nil { + streamResp := response.BifrostResponsesStreamResponse + + // Check for function call events + switch streamResp.Type { + case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta: + functionCallArgsDetected = true + if streamResp.Arguments != nil { + t.Logf("πŸ”§ Function call arguments chunk: %q", *streamResp.Arguments) + } + + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + if streamResp.Item != nil && streamResp.Item.Type != nil { + if *streamResp.Item.Type == schemas.ResponsesMessageTypeFunctionCall { + toolCallDetected = true + t.Logf("πŸ”§ Function call detected in streaming response") + + if streamResp.Item.Name != nil { + t.Logf("πŸ”§ Function name: %s", *streamResp.Item.Name) + } + } + } + + case schemas.ResponsesStreamResponseTypeOutputTextDelta: + if streamResp.Delta != nil { + t.Logf("πŸ“ Text chunk in tool call stream: %q", *streamResp.Delta) + } + } + } + + if responseCount > 100 { + goto toolStreamComplete + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for responses streaming response with tools") + } + } + + toolStreamComplete: + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + + // At least one of these should be detected for tool calling + if !toolCallDetected && !functionCallArgsDetected { + t.Fatal("Should detect tool calls or function arguments in responses streaming response") + } + + t.Logf("βœ… Responses streaming with tools test completed successfully") + }) + } + + // Test responses streaming with reasoning if supported + if testConfig.Scenarios.Reasoning && testConfig.ReasoningModel != "" { + t.Run("ResponsesStreamWithReasoning", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + problemPrompt := "Solve this step by step: If a train leaves station A at 2 PM traveling at 60 mph, and another train leaves station B at 3 PM traveling at 80 mph toward station A, and the stations are 420 miles apart, when will they meet?" + + messages := []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: schemas.Ptr(problemPrompt), + }, + }, + } + + request := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ReasoningModel, + Input: messages, + Params: &schemas.ResponsesParameters{ + MaxOutputTokens: bifrost.Ptr(400), + Reasoning: &schemas.ResponsesParametersReasoning{ + Effort: bifrost.Ptr("high"), + Summary: bifrost.Ptr("detailed"), + }, + Include: []string{"reasoning.encrypted_content"}, + }, + Fallbacks: testConfig.Fallbacks, + } + + responseChannel, err := client.ResponsesStreamRequest(ctx, request) + RequireNoError(t, err, "Responses stream with reasoning failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var reasoningDetected bool + var reasoningSummaryDetected bool + var responseCount int + + streamCtx, cancel := context.WithTimeout(ctx, 200*time.Second) + defer cancel() + + t.Logf("🧠 Testing responses streaming with reasoning...") + + for { + select { + case response, ok := <-responseChannel: + if !ok { + goto reasoningStreamComplete + } + + if response == nil { + t.Fatal("Streaming response should not be nil") + } + responseCount++ + + if response.BifrostResponsesStreamResponse != nil { + streamResp := response.BifrostResponsesStreamResponse + + // Check for reasoning-specific events + switch streamResp.Type { + case schemas.ResponsesStreamResponseTypeReasoningSummaryPartAdded: + reasoningSummaryDetected = true + t.Logf("🧠 Reasoning summary part added") + + case schemas.ResponsesStreamResponseTypeReasoningSummaryTextDelta: + reasoningSummaryDetected = true + if streamResp.Delta != nil { + t.Logf("🧠 Reasoning summary text chunk: %q", *streamResp.Delta) + } + + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + if streamResp.Item != nil && streamResp.Item.Type != nil { + if *streamResp.Item.Type == schemas.ResponsesMessageTypeReasoning { + reasoningDetected = true + t.Logf("🧠 Reasoning message detected in streaming response") + } + } + + case schemas.ResponsesStreamResponseTypeOutputTextDelta: + if streamResp.Delta != nil { + t.Logf("πŸ“ Text chunk in reasoning stream: %q", *streamResp.Delta) + } + } + } + + if responseCount > 150 { + goto reasoningStreamComplete + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for responses streaming response with reasoning") + } + } + + reasoningStreamComplete: + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + + // At least one of these should be detected for reasoning + if !reasoningDetected && !reasoningSummaryDetected { + t.Logf("⚠️ Warning: No explicit reasoning indicators found in streaming response") + } + + t.Logf("βœ… Responses streaming with reasoning test completed successfully") + }) + } +} + +// validateResponsesStreamingStructure validates the structure and events of responses streaming +func validateResponsesStreamingStructure(t *testing.T, eventTypes map[schemas.ResponsesStreamResponseType]int, sequenceNumbers []int, hasResponseCreated, hasResponseCompleted, hasOutputItems, hasContentParts bool) { + // Validate sequence numbers are increasing + for i := 1; i < len(sequenceNumbers); i++ { + if sequenceNumbers[i] < sequenceNumbers[i-1] { + t.Errorf("⚠️ Warning: Sequence numbers not in ascending order: %d -> %d", sequenceNumbers[i-1], sequenceNumbers[i]) + } + } + + // Log event type statistics + t.Logf("πŸ“Š Event type distribution:") + for eventType, count := range eventTypes { + t.Logf(" %s: %d occurrences", eventType, count) + } + + // Basic streaming flow validation + if !hasResponseCreated { + t.Logf("⚠️ Warning: No response.created event detected") + } + + if !hasResponseCompleted { + t.Logf("⚠️ Warning: No response.completed event detected") + } + + if !hasOutputItems && !hasContentParts { + t.Logf("⚠️ Warning: No output items or content parts detected") + } + + // Validate minimum expected events + expectedEvents := []schemas.ResponsesStreamResponseType{ + schemas.ResponsesStreamResponseTypeCreated, + schemas.ResponsesStreamResponseTypeOutputTextDelta, + } + + for _, expectedEvent := range expectedEvents { + if count, exists := eventTypes[expectedEvent]; !exists || count == 0 { + t.Logf("⚠️ Warning: Expected event %s not found", expectedEvent) + } + } +} + +// StreamingValidationResult represents the result of streaming validation +type StreamingValidationResult struct { + Passed bool + Errors []string +} + +// validateResponsesStreamingResponse validates streaming-specific aspects of responses API +func validateResponsesStreamingResponse(t *testing.T, eventTypes map[schemas.ResponsesStreamResponseType]int, sequenceNumbers []int, finalContent string, lastResponse *schemas.BifrostStream, testConfig config.ComprehensiveTestConfig) StreamingValidationResult { + var errors []string + + // Basic content validation + if len(finalContent) == 0 { + errors = append(errors, "Final content should not be empty") + } + + if len(finalContent) < 10 { + errors = append(errors, "Final content should be substantial (at least 10 characters)") + } + + // Streaming event validation + if len(eventTypes) == 0 { + errors = append(errors, "Should have received streaming events") + } + + // Check for required events + if _, hasCreated := eventTypes[schemas.ResponsesStreamResponseTypeCreated]; !hasCreated { + t.Logf("⚠️ Warning: No response.created event detected") + } + + if _, hasCompleted := eventTypes[schemas.ResponsesStreamResponseTypeCompleted]; !hasCompleted { + t.Logf("⚠️ Warning: No response.completed event detected") + } + + // Check for content events + hasContentEvents := false + contentEventTypes := []schemas.ResponsesStreamResponseType{ + schemas.ResponsesStreamResponseTypeOutputTextDelta, + schemas.ResponsesStreamResponseTypeOutputItemAdded, + schemas.ResponsesStreamResponseTypeContentPartAdded, + } + + for _, eventType := range contentEventTypes { + if count, exists := eventTypes[eventType]; exists && count > 0 { + hasContentEvents = true + break + } + } + + if !hasContentEvents { + errors = append(errors, "Should have received content-related streaming events") + } + + // Sequence number validation + if len(sequenceNumbers) > 1 { + for i := 1; i < len(sequenceNumbers); i++ { + if sequenceNumbers[i] < sequenceNumbers[i-1] { + errors = append(errors, fmt.Sprintf("Sequence numbers not in order: %d -> %d", sequenceNumbers[i-1], sequenceNumbers[i])) + } + } + } + + // Validate last response structure + if lastResponse == nil { + errors = append(errors, "Should have at least one streaming response") + } else { + if lastResponse.BifrostResponsesStreamResponse == nil { + errors = append(errors, "Last streaming response should have BifrostResponsesStreamResponse") + } else { + if lastResponse.BifrostResponsesStreamResponse.ExtraFields.Provider != testConfig.Provider { + errors = append(errors, fmt.Sprintf("Provider mismatch: expected %s, got %s", testConfig.Provider, lastResponse.BifrostResponsesStreamResponse.ExtraFields.Provider)) + } + } + } + + // Content quality checks (basic) + if len(finalContent) > 0 { + // Check for reasonable content for story prompt + if testConfig.Provider != schemas.SGL { // SGL might have different output patterns + lowerContent := strings.ToLower(finalContent) + hasStoryElements := strings.Contains(lowerContent, "robot") || + strings.Contains(lowerContent, "paint") || + strings.Contains(lowerContent, "story") + + if !hasStoryElements { + t.Logf("⚠️ Warning: Content doesn't seem to contain expected story elements") + } + } + } + + // Validate latency is present in the last chunk (total latency) + if lastResponse != nil && lastResponse.BifrostResponsesStreamResponse != nil { + if lastResponse.BifrostResponsesStreamResponse.ExtraFields.Latency <= 0 { + errors = append(errors, fmt.Sprintf("Last streaming chunk missing latency information (got %d ms)", lastResponse.BifrostResponsesStreamResponse.ExtraFields.Latency)) + } else { + t.Logf("βœ… Total streaming latency: %d ms", lastResponse.BifrostResponsesStreamResponse.ExtraFields.Latency) + } + } + + return StreamingValidationResult{ + Passed: len(errors) == 0, + Errors: errors, + } +} diff --git a/tests/core-providers/scenarios/simple_chat.go b/tests/core-providers/scenarios/simple_chat.go new file mode 100644 index 000000000..eb5ad8be6 --- /dev/null +++ b/tests/core-providers/scenarios/simple_chat.go @@ -0,0 +1,152 @@ +package scenarios + +import ( + "context" + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunSimpleChatTest executes the simple chat test scenario using dual API testing framework +func RunSimpleChatTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.SimpleChat { + t.Logf("Simple chat not supported for provider %s", testConfig.Provider) + return + } + + t.Run("SimpleChat", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + chatMessages := []schemas.ChatMessage{ + CreateBasicChatMessage("Hello! What's the capital of France?"), + } + responsesMessages := []schemas.ResponsesMessage{ + CreateBasicResponsesMessage("Hello! What's the capital of France?"), + } + + // Use retry framework with enhanced validation + retryConfig := GetTestRetryConfigForScenario("SimpleChat", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "SimpleChat", + ExpectedBehavior: map[string]interface{}{ + "should_mention_paris": true, + "should_be_factual": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + }, + } + + // Enhanced validation expectations (same for both APIs) + expectations := GetExpectationsForScenario("SimpleChat", testConfig, map[string]interface{}{}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ShouldContainKeywords = append(expectations.ShouldContainKeywords, "paris") // Should mention Paris as the capital + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{"berlin", "london", "madrid"}...) // Common wrong answers + + // Create Chat Completions API retry config + chatRetryConfig := ChatRetryConfig{ + MaxAttempts: retryConfig.MaxAttempts, + BaseDelay: retryConfig.BaseDelay, + MaxDelay: retryConfig.MaxDelay, + Conditions: []ChatRetryCondition{}, // Add specific chat retry conditions as needed + OnRetry: retryConfig.OnRetry, + OnFinalFail: retryConfig.OnFinalFail, + } + + // Create Responses API retry config + responsesRetryConfig := ResponsesRetryConfig{ + MaxAttempts: retryConfig.MaxAttempts, + BaseDelay: retryConfig.BaseDelay, + MaxDelay: retryConfig.MaxDelay, + Conditions: []ResponsesRetryCondition{}, // Add specific responses retry conditions as needed + OnRetry: retryConfig.OnRetry, + OnFinalFail: retryConfig.OnFinalFail, + } + + // Test Chat Completions API + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: chatMessages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.Fallbacks, + } + response, err := client.ChatCompletionRequest(ctx, chatReq) + if err != nil { + return nil, err + } + if response != nil { + return response, nil + } + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "No chat response returned", + }, + } + } + + chatResponse, chatError := WithChatTestRetry(t, chatRetryConfig, retryContext, expectations, "SimpleChat_Chat", chatOperation) + + // Test Responses API + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: responsesMessages, + Fallbacks: testConfig.Fallbacks, + } + response, err := client.ResponsesRequest(ctx, responsesReq) + if err != nil { + return nil, err + } + if response != nil { + return response, nil + } + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: "No responses response returned", + }, + } + } + + responsesResponse, responsesError := WithResponsesTestRetry(t, responsesRetryConfig, retryContext, expectations, "SimpleChat_Responses", responsesOperation) + + // Check that both APIs succeeded + if chatError != nil { + t.Errorf("❌ Chat Completions API failed: %s", GetErrorMessage(chatError)) + } + if responsesError != nil { + t.Errorf("❌ Responses API failed: %s", GetErrorMessage(responsesError)) + } + + // Log results from both APIs + if chatResponse != nil { + chatContent := GetChatContent(chatResponse) + t.Logf("βœ… Chat Completions API result: %s", chatContent) + } + + if responsesResponse != nil { + responsesContent := GetResponsesContent(responsesResponse) + t.Logf("βœ… Responses API result: %s", responsesContent) + } + + // Fail test if either API failed + if chatError != nil || responsesError != nil { + t.Fatalf("❌ SimpleChat test failed - one or both APIs failed") + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed SimpleChat test!") + }) +} diff --git a/tests/core-providers/scenarios/speech_synthesis.go b/tests/core-providers/scenarios/speech_synthesis.go new file mode 100644 index 000000000..6c1cc7b45 --- /dev/null +++ b/tests/core-providers/scenarios/speech_synthesis.go @@ -0,0 +1,294 @@ +package scenarios + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + "github.com/stretchr/testify/require" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunSpeechSynthesisTest executes the speech synthesis test scenario +func RunSpeechSynthesisTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.SpeechSynthesis { + t.Logf("Speech synthesis not supported for provider %s", testConfig.Provider) + return + } + + t.Run("SpeechSynthesis", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Test with shared text constants for round-trip validation with transcription + testCases := []struct { + name string + text string + voiceType string + format string + expectMinBytes int + saveForSST bool // Whether to save this audio for SST round-trip testing + }{ + { + name: "BasicText_Primary_MP3", + text: TTSTestTextBasic, + voiceType: "primary", + format: "mp3", + expectMinBytes: 1000, + saveForSST: true, + }, + { + name: "MediumText_Secondary_MP3", + text: TTSTestTextMedium, + voiceType: "secondary", + format: "mp3", + expectMinBytes: 2000, + saveForSST: true, + }, + { + name: "TechnicalText_Tertiary_MP3", + text: TTSTestTextTechnical, + voiceType: "tertiary", + format: "mp3", + expectMinBytes: 500, + saveForSST: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + voice := GetProviderVoice(testConfig.Provider, tc.voiceType) + request := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, // Use configured model + Input: &schemas.SpeechInput{ + Input: tc.text, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: tc.format, + }, + Fallbacks: testConfig.SpeechSynthesisFallbacks, + } + + // Enhanced validation for speech synthesis + expectations := SpeechExpectations(tc.expectMinBytes) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + requestCtx := context.Background() + + speechResponse, bifrostErr := client.SpeechRequest(requestCtx, request) + if bifrostErr != nil { + t.Fatalf("❌ SpeechSynthesis_"+tc.name+" request failed: %v", GetErrorMessage(bifrostErr)) + } + + // Validate using the new validation framework + result := ValidateSpeechResponse(t, speechResponse, bifrostErr, expectations, "SpeechSynthesis_"+tc.name) + if !result.Passed { + t.Fatalf("❌ Speech synthesis validation failed: %v", result.Errors) + } + + // Additional speech-specific validations (complementary to main validation) + validateSpeechSynthesisSpecific(t, speechResponse, tc.expectMinBytes, testConfig.SpeechSynthesisModel) + + // Save audio file for SST round-trip testing if requested + if tc.saveForSST { + tempDir := os.TempDir() + audioFileName := filepath.Join(tempDir, "tts_"+tc.name+"."+tc.format) + + err := os.WriteFile(audioFileName, speechResponse.Audio, 0644) + require.NoError(t, err, "Failed to save audio file for SST testing") + + // Register cleanup to remove temp file + t.Cleanup(func() { + os.Remove(audioFileName) + }) + + t.Logf("πŸ’Ύ Audio saved for SST testing: %s (text: '%s')", audioFileName, tc.text) + } + + t.Logf("βœ… Speech synthesis successful: %d bytes of %s audio generated for voice '%s'", + len(speechResponse.Audio), tc.format, voice) + }) + } + }) +} + +// RunSpeechSynthesisAdvancedTest executes advanced speech synthesis test scenarios +func RunSpeechSynthesisAdvancedTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.SpeechSynthesis { + t.Logf("Speech synthesis not supported for provider %s", testConfig.Provider) + return + } + + t.Run("SpeechSynthesisAdvanced", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + t.Run("LongText_HDModel", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Test with longer text and HD model + longText := ` + This is a comprehensive test of the text-to-speech functionality using a longer piece of text. + The system should be able to handle multiple sentences, proper punctuation, and maintain + consistent voice quality throughout the entire speech generation process. This test ensures + that the speech synthesis can handle realistic use cases with substantial content. + ` + + voice := GetProviderVoice(testConfig.Provider, "tertiary") + request := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: longText, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: "mp3", + Instructions: "Speak slowly and clearly with natural intonation.", + }, + Fallbacks: testConfig.SpeechSynthesisFallbacks, + } + + retryConfig := GetTestRetryConfigForScenario("SpeechSynthesisHD", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "SpeechSynthesis_HD_LongText", + ExpectedBehavior: map[string]interface{}{ + "generate_hd_audio": true, + "handle_long_text": true, + "min_audio_bytes": 5000, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.SpeechSynthesisModel, + "text_length": len(longText), + }, + } + + expectations := SpeechExpectations(5000) // HD should produce substantial audio + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + requestCtx := context.Background() + + response, bifrostErr := WithTestRetry(t, retryConfig, retryContext, expectations, "SpeechSynthesis_HD", func() (*schemas.BifrostResponse, *schemas.BifrostError) { + c, err := client.SpeechRequest(requestCtx, request) + if err != nil { + return nil, err + } + return &schemas.BifrostResponse{SpeechResponse: c}, nil + }) + if bifrostErr != nil { + t.Fatalf("❌ SpeechSynthesis_HD request failed after retries: %v", GetErrorMessage(bifrostErr)) + } + + if response.SpeechResponse == nil || response.SpeechResponse.Audio == nil { + t.Fatal("HD speech synthesis response missing audio data") + } + + audioSize := len(response.SpeechResponse.Audio) + if audioSize < 5000 { + t.Fatalf("HD audio data too small: got %d bytes, expected at least 5000", audioSize) + } + + if response.SpeechResponse.ExtraFields.ModelRequested != testConfig.SpeechSynthesisModel { + t.Logf("⚠️ Expected HD model, got: %s", response.SpeechResponse.ExtraFields.ModelRequested) + } + + t.Logf("βœ… HD speech synthesis successful: %d bytes generated", len(response.SpeechResponse.Audio)) + }) + + t.Run("AllVoiceOptions", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Test provider-specific voice options + voiceTypes := []string{"primary", "secondary", "tertiary"} + testText := TTSTestTextBasic // Use shared constant + + for _, voiceType := range voiceTypes { + t.Run("VoiceType_"+voiceType, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + voice := GetProviderVoice(testConfig.Provider, voiceType) + request := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: testText, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: "mp3", + }, + Fallbacks: testConfig.SpeechSynthesisFallbacks, + } + + expectations := SpeechExpectations(500) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + requestCtx := context.Background() + + speechResponse, bifrostErr := client.SpeechRequest(requestCtx, request) + if bifrostErr != nil { + t.Fatalf("❌ SpeechSynthesis_Voice_"+voiceType+" request failed: %v", GetErrorMessage(bifrostErr)) + } + + if speechResponse.Audio == nil { + t.Fatalf("Voice %s (%s) missing audio data", voice, voiceType) + } + + audioSize := len(speechResponse.Audio) + if audioSize < 500 { + t.Fatalf("Audio too small for voice %s: got %d bytes, expected at least 500", voice, audioSize) + } + t.Logf("βœ… Voice %s (%s): %d bytes generated", voice, voiceType, len(speechResponse.Audio)) + }) + } + }) + }) +} + +// validateSpeechSynthesisSpecific performs speech-specific validation +// This is complementary to the main validation framework and focuses on speech synthesis concerns +func validateSpeechSynthesisSpecific(t *testing.T, response *schemas.BifrostSpeechResponse, expectMinBytes int, expectedModel string) { + if response == nil { + t.Fatal("Invalid speech synthesis response structure") + } + + if response.Audio == nil { + t.Fatal("Speech synthesis response missing audio data") + } + + audioSize := len(response.Audio) + if audioSize < expectMinBytes { + t.Fatalf("Audio data too small: got %d bytes, expected at least %d", audioSize, expectMinBytes) + } + + if expectedModel != "" && response.ExtraFields.ModelRequested != expectedModel { + t.Logf("⚠️ Expected model, got: %s", response.ExtraFields.ModelRequested) + } + + t.Logf("βœ… Audio validation passed: %d bytes generated", audioSize) +} diff --git a/tests/core-providers/scenarios/speech_synthesis_stream.go b/tests/core-providers/scenarios/speech_synthesis_stream.go new file mode 100644 index 000000000..de4ff0087 --- /dev/null +++ b/tests/core-providers/scenarios/speech_synthesis_stream.go @@ -0,0 +1,450 @@ +package scenarios + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunSpeechSynthesisStreamTest executes the streaming speech synthesis test scenario +func RunSpeechSynthesisStreamTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.SpeechSynthesisStream { + t.Logf("Speech synthesis streaming not supported for provider %s", testConfig.Provider) + return + } + + t.Run("SpeechSynthesisStream", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Test streaming with different text lengths + testCases := []struct { + name string + text string + voice string + format string + expectMinChunks int + expectMinBytes int + skip bool + }{ + { + name: "ShortText_Streaming", + text: "This is a short text for streaming speech synthesis test.", + voice: GetProviderVoice(testConfig.Provider, "primary"), + format: "mp3", + expectMinChunks: 1, + expectMinBytes: 1000, + skip: false, + }, + { + name: "LongText_Streaming", + text: `This is a longer text to test streaming speech synthesis functionality. + The streaming should provide audio chunks as they are generated, allowing for + real-time playback while the rest of the audio is still being processed. + This enables better user experience with reduced latency.`, + voice: GetProviderVoice(testConfig.Provider, "secondary"), + format: "mp3", + expectMinChunks: 2, + expectMinBytes: 3000, + skip: testConfig.Provider == schemas.Gemini, + }, + { + name: "MediumText_Echo_WAV", + text: "Testing streaming with WAV format. This should produce multiple audio chunks in WAV format for streaming playback.", + voice: GetProviderVoice(testConfig.Provider, "tertiary"), + format: "wav", + expectMinChunks: 1, + expectMinBytes: 2000, + skip: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + if tc.skip { + t.Skipf("Skipping %s test", tc.name) + return + } + + voice := tc.voice + request := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: tc.text, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: tc.format, + }, + Fallbacks: testConfig.SpeechSynthesisFallbacks, + } + + // Use retry framework for streaming speech synthesis + retryConfig := GetTestRetryConfigForScenario("SpeechSynthesisStream", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "SpeechSynthesisStream_" + tc.name, + ExpectedBehavior: map[string]interface{}{ + "generate_streaming_audio": true, + "voice_type": tc.voice, + "format": tc.format, + "min_chunks": tc.expectMinChunks, + "min_total_bytes": tc.expectMinBytes, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.SpeechSynthesisModel, + "text_length": len(tc.text), + "voice": tc.voice, + "format": tc.format, + }, + } + + requestCtx := context.Background() + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.SpeechStreamRequest(requestCtx, request) + }) + + // Enhanced validation for streaming speech synthesis + if err != nil { + RequireNoError(t, err, "Speech synthesis stream initiation failed") + } + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var totalBytes int + var chunkCount int + var lastResponse *schemas.BifrostStream + var streamErrors []string + var lastTokenLatency int64 + + // Read streaming chunks with enhanced validation + for response := range responseChannel { + if response == nil { + streamErrors = append(streamErrors, "Received nil stream response") + continue + } + + // Check for errors in stream + if response.BifrostError != nil { + streamErrors = append(streamErrors, FormatErrorConcise(ParseBifrostError(response.BifrostError))) + continue + } + + if response.BifrostSpeechStreamResponse != nil { + lastTokenLatency = response.BifrostSpeechStreamResponse.ExtraFields.Latency + } + + if response.BifrostSpeechStreamResponse == nil { + streamErrors = append(streamErrors, "Stream response missing speech stream payload") + continue + } + + if response.BifrostSpeechStreamResponse.Audio == nil { + streamErrors = append(streamErrors, "Stream response missing audio data") + continue + } + + // Log latency for each chunk (can be 0 for inter-chunks) + t.Logf("πŸ“Š Speech chunk %d latency: %d ms", chunkCount+1, response.BifrostSpeechStreamResponse.ExtraFields.Latency) + + // Collect audio chunks + if response.BifrostSpeechStreamResponse.Audio != nil { + chunkSize := len(response.BifrostSpeechStreamResponse.Audio) + if chunkSize == 0 { + t.Logf("⚠️ Skipping zero-length audio chunk") + continue + } + totalBytes += chunkSize + chunkCount++ + t.Logf("βœ… Received audio chunk %d: %d bytes", chunkCount, chunkSize) + + // Validate chunk structure + if response.BifrostSpeechStreamResponse.Type != "" && (response.BifrostSpeechStreamResponse.Type != schemas.SpeechStreamResponseTypeDelta && response.BifrostSpeechStreamResponse.Type != schemas.SpeechStreamResponseTypeDone) { + t.Logf("⚠️ Unexpected object type in stream: %s", response.BifrostSpeechStreamResponse.Type) + } + if response.BifrostSpeechStreamResponse.ExtraFields.ModelRequested != "" && response.BifrostSpeechStreamResponse.ExtraFields.ModelRequested != testConfig.SpeechSynthesisModel { + t.Logf("⚠️ Unexpected model in stream: %s", response.BifrostSpeechStreamResponse.ExtraFields.ModelRequested) + } + } + + lastResponse = DeepCopyBifrostStream(response) + } + + // Enhanced validation of streaming results + if len(streamErrors) > 0 { + t.Logf("⚠️ Stream errors encountered: %v", streamErrors) + } + + if chunkCount < tc.expectMinChunks { + t.Fatalf("Insufficient chunks received: got %d, expected at least %d", chunkCount, tc.expectMinChunks) + } + + if totalBytes < tc.expectMinBytes { + t.Fatalf("Insufficient audio data: got %d bytes, expected at least %d", totalBytes, tc.expectMinBytes) + } + + if lastResponse == nil { + t.Fatal("Should have received at least one response") + } + + // Additional streaming-specific validations + if chunkCount == 0 { + t.Fatal("No audio chunks received from stream") + } + + averageChunkSize := totalBytes / chunkCount + if averageChunkSize < 100 { + t.Logf("⚠️ Average chunk size seems small: %d bytes", averageChunkSize) + } + + if lastTokenLatency == 0 { + t.Errorf("❌ Last token latency is 0") + } + + t.Logf("βœ… Streaming speech synthesis successful: %d chunks, %d total bytes for voice '%s' in %s format", + chunkCount, totalBytes, tc.voice, tc.format) + }) + } + }) +} + +// RunSpeechSynthesisStreamAdvancedTest executes advanced streaming speech synthesis test scenarios +func RunSpeechSynthesisStreamAdvancedTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.SpeechSynthesisStream { + t.Logf("Speech synthesis streaming not supported for provider %s", testConfig.Provider) + return + } + + t.Run("SpeechSynthesisStreamAdvanced", func(t *testing.T) { + t.Run("LongText_HDModel_Streaming", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + if testConfig.Provider == schemas.Gemini { + t.Skipf("Skipping %s test", "LongText_HDModel_Streaming") + return + } + + // Test streaming with HD model and very long text + finalText := "" + for i := 1; i <= 20; i++ { + finalText += strings.Replace("This is sentence number %d in a very long text for testing streaming speech synthesis with the HD model. ", "%d", string(rune('0'+i%10)), -1) + } + + voice := GetProviderVoice(testConfig.Provider, "tertiary") + request := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: finalText, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: "mp3", + Instructions: "Speak at a natural pace with clear pronunciation.", + }, + Fallbacks: testConfig.SpeechSynthesisFallbacks, + } + + retryConfig := GetTestRetryConfigForScenario("SpeechSynthesisStreamHD", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "SpeechSynthesisStreamHD_LongText", + ExpectedBehavior: map[string]interface{}{ + "generate_hd_streaming_audio": true, + "handle_long_text": true, + "min_chunks": 3, + "min_total_bytes": 10000, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.SpeechSynthesisModel, + "text_length": len(finalText), + "voice": voice, + }, + } + + requestCtx := context.Background() + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.SpeechStreamRequest(requestCtx, request) + }) + + RequireNoError(t, err, "HD streaming speech synthesis failed") + + var totalBytes int + var chunkCount int + var streamErrors []string + var lastTokenLatency int64 + + for response := range responseChannel { + if response == nil { + streamErrors = append(streamErrors, "Received nil HD stream response") + continue + } + + if response.BifrostError != nil { + streamErrors = append(streamErrors, FormatErrorConcise(ParseBifrostError(response.BifrostError))) + continue + } + + if response.BifrostSpeechStreamResponse != nil { + lastTokenLatency = response.BifrostSpeechStreamResponse.ExtraFields.Latency + } + + if response.BifrostSpeechStreamResponse != nil && response.BifrostSpeechStreamResponse.Audio != nil { + chunkSize := len(response.BifrostSpeechStreamResponse.Audio) + if chunkSize == 0 { + t.Logf("⚠️ Skipping zero-length HD audio chunk") + continue + } + totalBytes += chunkSize + chunkCount++ + t.Logf("βœ… HD chunk %d: %d bytes", chunkCount, chunkSize) + } + + if response.BifrostSpeechStreamResponse != nil && response.BifrostSpeechStreamResponse.ExtraFields.ModelRequested != "" && response.BifrostSpeechStreamResponse.ExtraFields.ModelRequested != testConfig.SpeechSynthesisModel { + t.Logf("⚠️ Unexpected HD model: %s", response.BifrostSpeechStreamResponse.ExtraFields.ModelRequested) + } + } + + if len(streamErrors) > 0 { + t.Logf("⚠️ HD stream errors: %v", streamErrors) + } + + if chunkCount <= 3 { + t.Fatalf("HD model should produce more chunks for long text: got %d, expected > 3", chunkCount) + } + + if totalBytes <= 10000 { + t.Fatalf("HD model should produce substantial audio data: got %d bytes, expected > 10000", totalBytes) + } + + if lastTokenLatency == 0 { + t.Errorf("❌ Last token latency is 0") + } + + t.Logf("βœ… HD streaming successful: %d chunks, %d total bytes", chunkCount, totalBytes) + }) + + t.Run("MultipleVoices_Streaming", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + voices := []string{} + + // Test streaming with all available voices + openaiVoices := []string{"alloy", "echo", "fable", "onyx", "nova", "shimmer"} + geminiVoices := []string{"achernar", "achird", "erinome"} + testText := "Testing streaming speech synthesis with different voice options." + + if testConfig.Provider == schemas.OpenAI { + voices = openaiVoices + } else if testConfig.Provider == schemas.Gemini { + voices = geminiVoices + } + + for _, voice := range voices { + t.Run("StreamingVoice_"+voice, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + voiceCopy := voice + request := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: testText, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voiceCopy, + }, + ResponseFormat: "mp3", + }, + Fallbacks: testConfig.SpeechSynthesisFallbacks, + } + + retryConfig := GetTestRetryConfigForScenario("SpeechSynthesisStreamVoice", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "SpeechSynthesisStream_Voice_" + voice, + ExpectedBehavior: map[string]interface{}{ + "generate_streaming_audio": true, + "voice_type": voice, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "voice": voice, + }, + } + + requestCtx := context.Background() + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.SpeechStreamRequest(requestCtx, request) + }) + + RequireNoError(t, err, fmt.Sprintf("Streaming failed for voice %s", voice)) + + var receivedData bool + var streamErrors []string + var lastTokenLatency int64 + + for response := range responseChannel { + if response == nil { + streamErrors = append(streamErrors, fmt.Sprintf("Received nil stream response for voice %s", voice)) + continue + } + + if response.BifrostError != nil { + streamErrors = append(streamErrors, fmt.Sprintf("Error in stream for voice %s: %s", voice, FormatErrorConcise(ParseBifrostError(response.BifrostError)))) + continue + } + + if response.BifrostSpeechStreamResponse != nil { + lastTokenLatency = response.BifrostSpeechStreamResponse.ExtraFields.Latency + } + + if response.BifrostSpeechStreamResponse != nil && response.BifrostSpeechStreamResponse.Audio != nil && len(response.BifrostSpeechStreamResponse.Audio) > 0 { + receivedData = true + t.Logf("βœ… Received data for voice %s: %d bytes", voice, len(response.BifrostSpeechStreamResponse.Audio)) + } + } + + if len(streamErrors) > 0 { + t.Errorf("❌ Stream errors for voice %s: %v", voice, streamErrors) + } + + if !receivedData { + t.Errorf("❌ Should receive audio data for voice %s", voice) + } + if lastTokenLatency == 0 { + t.Errorf("❌ Last token latency is 0") + } + t.Logf("βœ… Streaming successful for voice: %s", voice) + }) + } + }) + }) +} diff --git a/tests/core-providers/scenarios/test_retry_conditions.go b/tests/core-providers/scenarios/test_retry_conditions.go new file mode 100644 index 000000000..f03805a28 --- /dev/null +++ b/tests/core-providers/scenarios/test_retry_conditions.go @@ -0,0 +1,844 @@ +package scenarios + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ============================================================================= +// BASIC RESPONSE CONDITIONS +// ============================================================================= + +// EmptyResponseCondition checks for empty or missing response content +type EmptyResponseCondition struct{} + +func (c *EmptyResponseCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + // If there's an error, let the HTTP retry logic handle it + if err != nil { + return false, "" + } + + // No response at all + if response == nil { + return true, "response is nil" + } + + // Check if chat completions response exists + if response.TextCompletionResponse == nil && response.ChatResponse == nil && response.ResponsesResponse == nil { + return true, "response has no chat completions or responses data" + } + + // Check if all choices are empty (no content AND no tool calls) + hasContent := false + + // Check for textual content using the already robust GetResultContent function + content := GetResultContent(response) + if strings.TrimSpace(content) != "" { + hasContent = true + } + + // If no textual content, check for tool calls using the universal ExtractToolCalls function + if !hasContent { + toolCalls := ExtractToolCalls(response) + if len(toolCalls) > 0 { + // Validate that at least one tool call has a function name + for _, toolCall := range toolCalls { + if strings.TrimSpace(toolCall.Name) != "" { + hasContent = true + break + } + } + } + + if len(toolCalls) == 0 { + return true, "no tool calls found in response" + } + } + + if !hasContent { + return true, "all choices have empty content and no tool calls" + } + + return false, "" +} + +func (c *EmptyResponseCondition) GetConditionName() string { + return "EmptyResponse" +} + +// ============================================================================= +// TOOL CALLING CONDITIONS +// ============================================================================= + +// MissingToolCallCondition checks if expected tool call is missing +type MissingToolCallCondition struct { + ExpectedToolName string // Name of the tool that should have been called +} + +func (c *MissingToolCallCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + expectedTool := c.ExpectedToolName + if expectedTool == "" { + // Try to get from context + if tool, ok := context.ExpectedBehavior["expected_tool_name"].(string); ok { + expectedTool = tool + } else { + return false, "" + } + } + + // Extract tool calls from both API formats + toolCalls := ExtractToolCalls(response) + + // Check if any tool call has the expected name + for _, toolCall := range toolCalls { + if toolCall.Name == expectedTool { + return false, "" // Found the expected tool call + } + } + + return true, fmt.Sprintf("expected tool call '%s' not found in response", expectedTool) +} + +func (c *MissingToolCallCondition) GetConditionName() string { + return "MissingToolCall" +} + +// MalformedToolArgsCondition checks for malformed tool call arguments +type MalformedToolArgsCondition struct{} + +func (c *MalformedToolArgsCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + // Extract tool calls from both API formats + toolCalls := ExtractToolCalls(response) + + // Check all tool calls for malformed arguments + for i, toolCall := range toolCalls { + if toolCall.Arguments == "" { + continue // Skip empty arguments for now + } + + // Try to parse arguments as JSON + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Arguments), &args); err != nil { + return true, fmt.Sprintf("tool call %d has malformed JSON arguments: %s", i, err.Error()) + } + + // Check for empty arguments only when arguments are explicitly required + // Some tools (like get_current_time) legitimately take no arguments + requiresArgs := false + if context.ExpectedBehavior != nil { + // Check if this test expects arguments (default: false, allowing tools with no args) + if expectArgs, ok := context.ExpectedBehavior["requires_arguments"].(bool); ok { + requiresArgs = expectArgs + } + } + + if requiresArgs && len(args) == 0 && toolCall.Name != "" { + return true, fmt.Sprintf("tool call %d (%s) has empty arguments but arguments are required", i, toolCall.Name) + } + } + + return false, "" +} + +func (c *MalformedToolArgsCondition) GetConditionName() string { + return "MalformedToolArgs" +} + +// WrongToolCalledCondition checks if the wrong tool was called +type WrongToolCalledCondition struct { + ExpectedToolName string + ForbiddenTools []string // Tools that should not be called +} + +func (c *WrongToolCalledCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + expectedTool := c.ExpectedToolName + if expectedTool == "" { + if tool, ok := context.ExpectedBehavior["expected_tool_name"].(string); ok { + expectedTool = tool + } + } + + // Extract tool calls from both API formats + toolCalls := ExtractToolCalls(response) + + // Check all tool calls + for i, toolCall := range toolCalls { + toolName := toolCall.Name + if toolName == "" { + continue + } + + // Check if forbidden tool was called + for _, forbidden := range c.ForbiddenTools { + if toolName == forbidden { + return true, fmt.Sprintf("tool call %d called forbidden tool '%s'", i, toolName) + } + } + + // If we have an expected tool and this isn't it + if expectedTool != "" && toolName != expectedTool { + return true, fmt.Sprintf("tool call %d called '%s' instead of expected '%s'", i, toolName, expectedTool) + } + } + + return false, "" +} + +func (c *WrongToolCalledCondition) GetConditionName() string { + return "WrongToolCalled" +} + +// ============================================================================= +// MULTIPLE TOOL CALL CONDITIONS +// ============================================================================= + +// PartialToolCallCondition checks if we got fewer tool calls than expected +type PartialToolCallCondition struct { + ExpectedCount int // Expected number of tool calls +} + +func (c *PartialToolCallCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + expectedCount := c.ExpectedCount + if expectedCount == 0 { + if count, ok := context.ExpectedBehavior["expected_tool_count"].(int); ok { + expectedCount = count + } else { + return false, "" + } + } + + // Extract tool calls from both API formats and count them + toolCalls := ExtractToolCalls(response) + actualCount := len(toolCalls) + + if actualCount < expectedCount { + return true, fmt.Sprintf("got %d tool calls, expected %d", actualCount, expectedCount) + } + + return false, "" +} + +func (c *PartialToolCallCondition) GetConditionName() string { + return "PartialToolCall" +} + +// WrongToolSequenceCondition checks if tools were called in wrong order +type WrongToolSequenceCondition struct { + ExpectedTools []string // Expected sequence of tool names +} + +func (c *WrongToolSequenceCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + expectedTools := c.ExpectedTools + if len(expectedTools) == 0 { + if tools, ok := context.ExpectedBehavior["expected_tool_sequence"].([]string); ok { + expectedTools = tools + } else { + return false, "" + } + } + + // Extract tool calls from both API formats + toolCalls := ExtractToolCalls(response) + + // If we don't have enough tool calls + if len(toolCalls) < len(expectedTools) { + return true, fmt.Sprintf("got %d tool calls, expected at least %d", len(toolCalls), len(expectedTools)) + } + + // Check sequence + for j, expectedTool := range expectedTools { + if j >= len(toolCalls) { + break + } + + actualTool := toolCalls[j].Name + if actualTool != expectedTool { + if actualTool == "" { + actualTool = "nil" + } + return true, fmt.Sprintf("position %d: got '%s', expected '%s'", j, actualTool, expectedTool) + } + } + + return false, "" +} + +func (c *WrongToolSequenceCondition) GetConditionName() string { + return "WrongToolSequence" +} + +// ============================================================================= +// IMAGE PROCESSING CONDITIONS +// ============================================================================= + +// ImageNotProcessedCondition checks if image content was actually processed +type ImageNotProcessedCondition struct{} + +func (c *ImageNotProcessedCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + // Get response content + content := strings.ToLower(GetResultContent(response)) + + // Check for generic responses that don't indicate image processing + genericPhrases := []string{ + "i can't see", + "i cannot see", + "unable to see", + "can't view", + "cannot view", + "no image", + "not able to see", + "i don't see", + "i cannot process", + } + + for _, phrase := range genericPhrases { + if strings.Contains(content, phrase) { + return true, fmt.Sprintf("response suggests image was not processed: contains '%s'", phrase) + } + } + + // If content is suspiciously short for image analysis + if len(strings.TrimSpace(content)) < 20 { + return true, "response too short for meaningful image analysis" + } + + return false, "" +} + +func (c *ImageNotProcessedCondition) GetConditionName() string { + return "ImageNotProcessed" +} + +// GenericResponseCondition checks for generic/template responses +type GenericResponseCondition struct{} + +func (c *GenericResponseCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.TextCompletionResponse == nil && response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + content := strings.ToLower(GetResultContent(response)) + + // Generic phrases that suggest the model didn't engage with the specific request + genericPhrases := []string{ + "as an ai", + "as a language model", + "i'm an ai", + "i am an ai", + "i'm a language model", + "i am a language model", + "i can help you with", + "how can i assist you", + "what would you like to know", + "is there anything else", + } + + // Check if response starts with generic phrases (more concerning) + for _, phrase := range genericPhrases { + if strings.HasPrefix(content, phrase) { + return true, fmt.Sprintf("response starts with generic phrase: '%s'", phrase) + } + } + + // Check for overly generic responses (short and generic) + if len(strings.TrimSpace(content)) < 30 { + for _, phrase := range genericPhrases { + if strings.Contains(content, phrase) { + return true, fmt.Sprintf("short response contains generic phrase: '%s'", phrase) + } + } + } + + return false, "" +} + +func (c *GenericResponseCondition) GetConditionName() string { + return "GenericResponse" +} + +// ============================================================================= +// CONTENT VALIDATION CONDITIONS +// ============================================================================= + +// ContentValidationCondition checks if response fails basic content validation +// This is crucial for vision tests where the AI might give different descriptions +type ContentValidationCondition struct{} + +func (c *ContentValidationCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.TextCompletionResponse == nil && response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + content := strings.ToLower(GetResultContent(response)) + + // Skip if response is too short or generic (other conditions will handle these) + if len(content) < 10 { + return false, "" + } + + // Only check content validation for vision-related scenarios + scenarioName := strings.ToLower(context.ScenarioName) + if !strings.Contains(scenarioName, "image") && !strings.Contains(scenarioName, "vision") { + return false, "" + } + + // Check if this looks like a valid vision response but might be missing keywords + // Look for vision-related indicators that suggest the AI processed the image + visionIndicators := []string{ + "see", "shows", "depicts", "contains", "features", "displays", + "appears", "looks", "visible", "image", "picture", "photo", + "color", "shape", "object", "animal", "person", "building", + "in the", "there is", "there are", "this is", "i can see", + } + + hasVisionContent := false + for _, indicator := range visionIndicators { + if strings.Contains(content, indicator) { + hasVisionContent = true + break + } + } + + // If it looks like a valid vision response, check if we should retry based on missing expected keywords + if hasVisionContent { + // Check if this test has expected keywords from the TestRetryContext + if testMetadata, exists := context.TestMetadata["expected_keywords"]; exists { + if expectedKeywords, ok := testMetadata.([]string); ok && len(expectedKeywords) > 0 { + // Check if ANY of the expected keywords are present + foundExpectedKeyword := false + for _, keyword := range expectedKeywords { + if strings.Contains(content, strings.ToLower(keyword)) { + foundExpectedKeyword = true + break + } + } + + // If valid vision response but missing ALL expected keywords, retry + // Allow longer responses for complex vision tasks (comparisons, detailed descriptions) + if !foundExpectedKeyword && len(content) > 20 && len(content) < 2000 { + return true, fmt.Sprintf("valid vision response but missing expected keywords %v, might include them on retry", expectedKeywords) + } + } + } + + // Fallback: Check expected behavior fields for dynamic validation + if expectedAnimal, ok := context.ExpectedBehavior["should_identify_animal"].(string); ok && expectedAnimal != "" { + // Parse expected animal from behavior context (e.g., "lion or animal") + expectedTerms := strings.Split(strings.ToLower(expectedAnimal), " or ") + foundExpected := false + for _, term := range expectedTerms { + term = strings.TrimSpace(term) + if term != "" && strings.Contains(content, term) { + foundExpected = true + break + } + } + if !foundExpected && len(content) > 20 && len(content) < 1500 { + return true, fmt.Sprintf("valid vision response but missing expected animal terms '%s', might get more specific on retry", expectedAnimal) + } + } + + if expectedObject, ok := context.ExpectedBehavior["should_identify_object"].(string); ok && expectedObject != "" { + // Parse expected object from behavior context (e.g., "ant or insect") + expectedTerms := strings.Split(strings.ToLower(expectedObject), " or ") + foundExpected := false + for _, term := range expectedTerms { + term = strings.TrimSpace(term) + if term != "" && strings.Contains(content, term) { + foundExpected = true + break + } + } + if !foundExpected && len(content) > 15 && len(content) < 1500 { + return true, fmt.Sprintf("valid vision response but missing expected object terms '%s', might get more specific on retry", expectedObject) + } + } + } + + return false, "" +} + +func (c *ContentValidationCondition) GetConditionName() string { + return "ContentValidation" +} + +// ============================================================================= +// STREAMING CONDITIONS +// ============================================================================= + +// StreamErrorCondition checks for streaming-specific errors that should trigger retries +type StreamErrorCondition struct{} + +func (c *StreamErrorCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + // Only retry on actual stream errors, not when stream is successful but response is nil + if err == nil { + return false, "" + } + + // Check for specific streaming errors that indicate retry-worthy conditions + // Check both the Message field and nested Error field + var errorMsg string + if strings.TrimSpace(err.Error.Message) != "" { + errorMsg = strings.ToLower(err.Error.Message) + } else if err.Error.Error != nil { + errorMsg = strings.ToLower(err.Error.Error.Error()) + } else { + return false, "" + } + + // Retry on connection/timeout issues during streaming + if strings.Contains(errorMsg, "connection reset") || + strings.Contains(errorMsg, "connection refused") || + strings.Contains(errorMsg, "timeout") || + strings.Contains(errorMsg, "stream closed") || + strings.Contains(errorMsg, "stream interrupted") { + return true, fmt.Sprintf("stream connection error: %s", errorMsg) + } + + // Retry on temporary streaming API errors + if strings.Contains(errorMsg, "rate limit") || + strings.Contains(errorMsg, "quota exceeded") || + strings.Contains(errorMsg, "service unavailable") || + strings.Contains(errorMsg, "server overloaded") { + return true, fmt.Sprintf("temporary API error: %s", errorMsg) + } + + // Don't retry on authentication, invalid request, or other permanent errors + return false, "" +} + +func (c *StreamErrorCondition) GetConditionName() string { + return "StreamError" +} + +// IncompleteStreamCondition checks for incomplete streaming responses +type IncompleteStreamCondition struct{} + +func (c *IncompleteStreamCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check both Chat Completions and Responses API formats + if response.TextCompletionResponse == nil && response.ChatResponse == nil && response.ResponsesResponse == nil { + return false, "" + } + + // For Chat Completions API, check finish reasons in choices + if response.ChatResponse != nil { + for i, choice := range response.ChatResponse.Choices { + if choice.FinishReason == nil { + return true, fmt.Sprintf("choice %d has no finish reason (stream may be incomplete)", i) + } + + // Check for incomplete finish reasons + finishReason := string(*choice.FinishReason) + if finishReason == "length" { + // This might be okay depending on context, but could indicate truncation + singleChoiceResponse := &schemas.BifrostResponse{ + ChatResponse: &schemas.BifrostChatResponse{ + Choices: []schemas.BifrostResponseChoice{choice}, + }, + } + choiceContent := GetResultContent(singleChoiceResponse) + if len(choiceContent) < 10 { + return true, fmt.Sprintf("choice %d finished due to length but content is very short", i) + } + } + } + } + + if response.TextCompletionResponse != nil { + for i, choice := range response.TextCompletionResponse.Choices { + if choice.FinishReason == nil { + return true, fmt.Sprintf("choice %d has no finish reason (stream may be incomplete)", i) + } + + finishReason := string(*choice.FinishReason) + if finishReason == "length" { + // This might be okay depending on context, but could indicate truncation + singleChoiceResponse := &schemas.BifrostResponse{ + TextCompletionResponse: &schemas.BifrostTextCompletionResponse{ + Choices: []schemas.BifrostResponseChoice{choice}, + }, + } + choiceContent := GetResultContent(singleChoiceResponse) + if len(choiceContent) < 10 { + return true, fmt.Sprintf("choice %d finished due to length but content is very short", i) + } + } + } + + } + + // For Responses API, check completion status in output messages + if response.ResponsesResponse != nil { + for i, output := range response.ResponsesResponse.Output { + if output.Status == nil { + return true, fmt.Sprintf("output %d has no status (stream may be incomplete)", i) + } + + status := *output.Status + if status == "incomplete" || status == "in_progress" { + return true, fmt.Sprintf("output %d has incomplete status: %s", i, status) + } + } + } + + return false, "" +} + +func (c *IncompleteStreamCondition) GetConditionName() string { + return "IncompleteStream" +} + +// ============================================================================= +// SPEECH SYNTHESIS CONDITIONS +// ============================================================================= + +// EmptySpeechCondition checks for missing or invalid audio data in speech synthesis responses +type EmptySpeechCondition struct{} + +func (c *EmptySpeechCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + // If there's an error, let other conditions handle it + if err != nil { + return false, "" + } + + // No response at all + if response == nil { + return true, "response is nil" + } + + // Check if speech response exists + if response.SpeechResponse == nil { + return true, "response has no speech data" + } + + // Check if audio data exists and is not empty + if response.SpeechResponse.Audio == nil { + return true, "response has no audio data" + } + + // Check for unreasonably small audio files (likely errors) + if len(response.SpeechResponse.Audio) < 100 { + return true, fmt.Sprintf("audio data too small (%d bytes), likely an error", len(response.SpeechResponse.Audio)) + } + + return false, "" +} + +func (c *EmptySpeechCondition) GetConditionName() string { + return "EmptySpeech" +} + +// ============================================================================= +// TRANSCRIPTION CONDITIONS +// ============================================================================= + +// EmptyTranscriptionCondition checks for missing or invalid transcription text +type EmptyTranscriptionCondition struct{} + +func (c *EmptyTranscriptionCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + // If there's an error, let other conditions handle it + if err != nil { + return false, "" + } + + // No response at all + if response == nil { + return true, "response is nil" + } + + // Check if transcription response exists + if response.TranscriptionResponse == nil { + return true, "response has no transcription data" + } + + // Check if text exists and is not empty + if response.TranscriptionResponse.Text == "" || strings.TrimSpace(response.TranscriptionResponse.Text) == "" { + return true, "response has no transcription text" + } + + // Check for unreasonably short transcriptions (likely errors) + text := strings.TrimSpace(response.TranscriptionResponse.Text) + if len(text) < 3 { + return true, fmt.Sprintf("transcription text too short (%d chars): '%s'", len(text), text) + } + + return false, "" +} + +func (c *EmptyTranscriptionCondition) GetConditionName() string { + return "EmptyTranscription" +} + +// ============================================================================= +// EMBEDDING CONDITIONS +// ============================================================================= + +// EmptyEmbeddingCondition checks for missing or empty embeddings +type EmptyEmbeddingCondition struct{} + +func (c *EmptyEmbeddingCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil { + return false, "" + } + + // Check if we have embedding data + if response.EmbeddingResponse == nil || len(response.EmbeddingResponse.Data) == 0 { + return true, "response has no embedding data" + } + + // Check each embedding + for i, data := range response.EmbeddingResponse.Data { + vec, extractErr := getEmbeddingVector(data) + if extractErr != nil { + return true, fmt.Sprintf("embedding %d: failed to extract vector: %s", i, extractErr.Error()) + } + + if len(vec) == 0 { + return true, fmt.Sprintf("embedding %d: vector is empty", i) + } + + // Check for all-zero vectors (sometimes indicates an error) + allZero := true + for _, val := range vec { + if val != 0.0 { + allZero = false + break + } + } + + if allZero { + return true, fmt.Sprintf("embedding %d: vector is all zeros", i) + } + } + + return false, "" +} + +func (c *EmptyEmbeddingCondition) GetConditionName() string { + return "EmptyEmbedding" +} + +// InvalidEmbeddingDimensionCondition checks for inconsistent embedding dimensions +type InvalidEmbeddingDimensionCondition struct { + ExpectedDimension int // Expected vector dimension (0 means any) +} + +func (c *InvalidEmbeddingDimensionCondition) ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) { + if err != nil || response == nil || response.EmbeddingResponse == nil || len(response.EmbeddingResponse.Data) == 0 { + return false, "" + } + + expectedDim := c.ExpectedDimension + if expectedDim == 0 { + if dim, ok := context.ExpectedBehavior["expected_dimension"].(int); ok { + expectedDim = dim + } + } + + var firstDimension int + + // Check each embedding + for i, data := range response.EmbeddingResponse.Data { + vec, extractErr := getEmbeddingVector(data) + if extractErr != nil { + return false, "" // Let EmptyEmbeddingCondition handle this + } + + dimension := len(vec) + + // Set expected dimension from first embedding if not specified + if i == 0 { + firstDimension = dimension + if expectedDim > 0 && dimension != expectedDim { + return true, fmt.Sprintf("embedding %d: got dimension %d, expected %d", i, dimension, expectedDim) + } + } else { + // Check consistency with first embedding + if dimension != firstDimension { + return true, fmt.Sprintf("embedding %d: dimension %d differs from first embedding dimension %d", i, dimension, firstDimension) + } + } + + // Check for unreasonably small dimensions (likely an error) + if dimension < 50 { + return true, fmt.Sprintf("embedding %d: dimension %d seems too small", i, dimension) + } + } + + return false, "" +} + +func (c *InvalidEmbeddingDimensionCondition) GetConditionName() string { + return "InvalidEmbeddingDimension" +} diff --git a/tests/core-providers/scenarios/test_retry_framework.go b/tests/core-providers/scenarios/test_retry_framework.go new file mode 100644 index 000000000..d839ac01e --- /dev/null +++ b/tests/core-providers/scenarios/test_retry_framework.go @@ -0,0 +1,979 @@ +package scenarios + +import ( + "fmt" + "math" + "reflect" + "strings" + "testing" + "time" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +// DeepCopyBifrostStream creates a deep copy of a BifrostStream object to avoid pooling issues +func DeepCopyBifrostStream(original *schemas.BifrostStream) *schemas.BifrostStream { + if original == nil { + return nil + } + + // Use reflection to create a deep copy + return deepCopyReflect(original).(*schemas.BifrostStream) +} + +// deepCopyReflect performs a deep copy using reflection +func deepCopyReflect(original interface{}) interface{} { + if original == nil { + return nil + } + + originalValue := reflect.ValueOf(original) + return deepCopyValue(originalValue).Interface() +} + +// deepCopyValue recursively copies a reflect.Value +func deepCopyValue(original reflect.Value) reflect.Value { + switch original.Kind() { + case reflect.Ptr: + if original.IsNil() { + return reflect.Zero(original.Type()) + } + // Create a new pointer and recursively copy the value it points to + newPtr := reflect.New(original.Type().Elem()) + newPtr.Elem().Set(deepCopyValue(original.Elem())) + return newPtr + + case reflect.Struct: + // Create a new struct and copy each field + newStruct := reflect.New(original.Type()).Elem() + for i := 0; i < original.NumField(); i++ { + field := original.Field(i) + destField := newStruct.Field(i) + if destField.CanSet() { + destField.Set(deepCopyValue(field)) + } + } + return newStruct + + case reflect.Slice: + if original.IsNil() { + return reflect.Zero(original.Type()) + } + // Create a new slice and copy each element + newSlice := reflect.MakeSlice(original.Type(), original.Len(), original.Cap()) + for i := 0; i < original.Len(); i++ { + newSlice.Index(i).Set(deepCopyValue(original.Index(i))) + } + return newSlice + + case reflect.Map: + if original.IsNil() { + return reflect.Zero(original.Type()) + } + // Create a new map and copy each key-value pair + newMap := reflect.MakeMap(original.Type()) + for _, key := range original.MapKeys() { + newMap.SetMapIndex(deepCopyValue(key), deepCopyValue(original.MapIndex(key))) + } + return newMap + + case reflect.Interface: + if original.IsNil() { + return reflect.Zero(original.Type()) + } + // Copy the concrete value inside the interface + return deepCopyValue(original.Elem()) + + default: + // For basic types (int, string, bool, etc.), just return the value + return original + } +} + +// TestRetryCondition defines an interface for checking if a test operation should be retried +// This focuses specifically on LLM behavior inconsistencies, not HTTP errors (handled by Bifrost core) +type TestRetryCondition interface { + ShouldRetry(response *schemas.BifrostResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// ChatRetryCondition defines an interface for checking if a chat test operation should be retried +type ChatRetryCondition interface { + ShouldRetry(response *schemas.BifrostChatResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// TextCompletionRetryCondition defines an interface for checking if a text completion test operation should be retried +type TextCompletionRetryCondition interface { + ShouldRetry(response *schemas.BifrostTextCompletionResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// ResponsesRetryCondition defines an interface for checking if a Responses API test operation should be retried +type ResponsesRetryCondition interface { + ShouldRetry(response *schemas.BifrostResponsesResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// SpeechRetryCondition defines an interface for checking if a speech test operation should be retried +type SpeechRetryCondition interface { + ShouldRetry(response *schemas.BifrostSpeechResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// TranscriptionRetryCondition defines an interface for checking if a transcription test operation should be retried +type TranscriptionRetryCondition interface { + ShouldRetry(response *schemas.BifrostTranscriptionResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// EmbeddingRetryCondition defines an interface for checking if an embedding test operation should be retried +type EmbeddingRetryCondition interface { + ShouldRetry(response *schemas.BifrostEmbeddingResponse, err *schemas.BifrostError, context TestRetryContext) (bool, string) + GetConditionName() string +} + +// TestRetryContext provides context information for retry decisions +type TestRetryContext struct { + ScenarioName string // Name of the test scenario + AttemptNumber int // Current attempt number (1-based) + ExpectedBehavior map[string]interface{} // What we expected to happen + TestMetadata map[string]interface{} // Additional context for retry decisions +} + +// TestRetryConfig configures retry behavior for test scenarios (DEPRECATED: Use specific retry configs) +type TestRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []TestRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// ChatRetryConfig configures retry behavior for chat test scenarios +type ChatRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []ChatRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// TextCompletionRetryConfig configures retry behavior for text completion test scenarios +type TextCompletionRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []TextCompletionRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// ResponsesRetryConfig configures retry behavior for Responses API test scenarios +type ResponsesRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []ResponsesRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// SpeechRetryConfig configures retry behavior for speech test scenarios +type SpeechRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []SpeechRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// TranscriptionRetryConfig configures retry behavior for transcription test scenarios +type TranscriptionRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []TranscriptionRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// EmbeddingRetryConfig configures retry behavior for embedding test scenarios +type EmbeddingRetryConfig struct { + MaxAttempts int // Maximum retry attempts (including initial attempt) + BaseDelay time.Duration // Base delay between retries + MaxDelay time.Duration // Maximum delay between retries + Conditions []EmbeddingRetryCondition // Conditions that trigger retries + OnRetry func(attempt int, reason string, t *testing.T) // Called before each retry + OnFinalFail func(attempts int, finalErr error, t *testing.T) // Called on final failure +} + +// DefaultTestRetryConfig returns a sensible default retry configuration for LLM tests +func DefaultTestRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyResponseCondition{}, + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying test (attempt %d): %s", attempt, reason) + }, + OnFinalFail: func(attempts int, finalErr error, t *testing.T) { + t.Logf("❌ Test failed after %d attempts: %v", attempts, finalErr) + }, + } +} + +// WithTestRetry wraps a test operation with retry logic for LLM behavior inconsistencies +// This is separate from HTTP retries (handled by Bifrost core) and focuses on: +// - Tool calling inconsistencies +// - Response format variations +// - Content quality issues +// - Semantic inconsistencies +// - VALIDATION FAILURES (most important retry case) +func WithTestRetry( + t *testing.T, + config TestRetryConfig, + context TestRetryContext, + expectations ResponseExpectations, + scenarioName string, + operation func() (*schemas.BifrostResponse, *schemas.BifrostError), +) (*schemas.BifrostResponse, *schemas.BifrostError) { + + var lastResponse *schemas.BifrostResponse + var lastError *schemas.BifrostError + + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + context.AttemptNumber = attempt + + // Execute the operation + response, err := operation() + lastResponse = response + lastError = err + + // If we have a response, validate it FIRST + if response != nil { + // Note: ValidateResponse is deprecated, this should be updated to use specific validation functions + t.Logf("⚠️ Warning: Using deprecated ValidateResponse function") + // For now, skip validation in the deprecated function + validationResult := ValidationResult{Passed: true} + + // If validation passes, we're done! + if validationResult.Passed { + return response, err + } + + // Validation failed - check if we should retry based on validation failure + if attempt < config.MaxAttempts { + shouldRetry, retryReason := checkRetryConditions(response, err, context, config.Conditions) + + if shouldRetry { + // Log retry attempt due to validation failure + if config.OnRetry != nil { + validationErrors := strings.Join(validationResult.Errors, "; ") + config.OnRetry(attempt, fmt.Sprintf("%s (Validation: %s)", retryReason, validationErrors), t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + continue + } + } + + // All retries failed validation - create a BifrostError to force test failure + validationErrors := strings.Join(validationResult.Errors, "; ") + + if config.OnFinalFail != nil { + finalErr := fmt.Errorf("validation failed after %d attempts: %s", attempt, validationErrors) + config.OnFinalFail(attempt, finalErr, t) + } + + // Return nil response + BifrostError so calling test fails + testFailureError := &schemas.BifrostError{ + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("Test validation failed after %d attempts - %s", attempt, validationErrors), + Type: bifrost.Ptr("validation_failure"), + Code: bifrost.Ptr("TEST_VALIDATION_FAILED"), + }, + } + + return nil, testFailureError + } + + // No response - check basic retry conditions (connection errors, etc.) + shouldRetry, retryReason := checkRetryConditions(response, err, context, config.Conditions) + + if !shouldRetry || attempt == config.MaxAttempts { + if shouldRetry && attempt == config.MaxAttempts { + // Final attempt failed + if config.OnFinalFail != nil { + finalErr := fmt.Errorf("retry condition met on final attempt: %s", retryReason) + config.OnFinalFail(attempt, finalErr, t) + } + } + break + } + + // Log retry attempt + if config.OnRetry != nil { + config.OnRetry(attempt, retryReason, t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + } + + // Final fallback: reached here if we had connection/HTTP errors (not validation failures) + // lastError should contain the actual HTTP/connection error in this case + return lastResponse, lastError +} + +// WithChatTestRetry wraps a chat test operation with retry logic for LLM behavior inconsistencies +func WithChatTestRetry( + t *testing.T, + config ChatRetryConfig, + context TestRetryContext, + expectations ResponseExpectations, + scenarioName string, + operation func() (*schemas.BifrostChatResponse, *schemas.BifrostError), +) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + + var lastResponse *schemas.BifrostChatResponse + var lastError *schemas.BifrostError + + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + context.AttemptNumber = attempt + + // Execute the operation + response, err := operation() + lastResponse = response + lastError = err + + // If we have a response, validate it FIRST + if response != nil { + validationResult := ValidateChatResponse(t, response, err, expectations, scenarioName) + + // If validation passes, we're done! + if validationResult.Passed { + return response, err + } + + // Validation failed - check if we should retry based on validation failure + if attempt < config.MaxAttempts { + shouldRetry, retryReason := checkChatRetryConditions(response, err, context, config.Conditions) + + if shouldRetry { + // Log retry attempt due to validation failure + if config.OnRetry != nil { + validationErrors := strings.Join(validationResult.Errors, "; ") + config.OnRetry(attempt, fmt.Sprintf("%s (Validation: %s)", retryReason, validationErrors), t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + continue + } + } + + // All retries failed validation - create a BifrostError to force test failure + validationErrors := strings.Join(validationResult.Errors, "; ") + + if config.OnFinalFail != nil { + finalErr := fmt.Errorf("validation failed after %d attempts: %s", attempt, validationErrors) + config.OnFinalFail(attempt, finalErr, t) + } + + // Return nil response + BifrostError so calling test fails + statusCode := 400 + testFailureError := &schemas.BifrostError{ + IsBifrostError: true, + StatusCode: &statusCode, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("Validation failed after %d attempts: %s", attempt, validationErrors), + }, + } + return nil, testFailureError + } + + // If we have an error without a response, check if we should retry + if err != nil && attempt < config.MaxAttempts { + shouldRetry, retryReason := checkChatRetryConditions(response, err, context, config.Conditions) + + if shouldRetry { + if config.OnRetry != nil { + config.OnRetry(attempt, retryReason, t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + continue + } + } + + // If we get here, either we got a final error or no more retries + break + } + + // Final failure callback + if config.OnFinalFail != nil && lastError != nil { + errorMsg := "unknown error" + if lastError.Error != nil { + errorMsg = lastError.Error.Message + } + config.OnFinalFail(config.MaxAttempts, fmt.Errorf("final error: %s", errorMsg), t) + } + + return lastResponse, lastError +} + +// WithResponsesTestRetry wraps a Responses API test operation with retry logic for LLM behavior inconsistencies +func WithResponsesTestRetry( + t *testing.T, + config ResponsesRetryConfig, + context TestRetryContext, + expectations ResponseExpectations, + scenarioName string, + operation func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError), +) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + + var lastResponse *schemas.BifrostResponsesResponse + var lastError *schemas.BifrostError + + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + context.AttemptNumber = attempt + + // Execute the operation + response, err := operation() + lastResponse = response + lastError = err + + // If we have a response, validate it FIRST + if response != nil { + validationResult := ValidateResponsesResponse(t, response, err, expectations, scenarioName) + + // If validation passes, we're done! + if validationResult.Passed { + return response, err + } + + // Validation failed - check if we should retry based on validation failure + if attempt < config.MaxAttempts { + shouldRetry, retryReason := checkResponsesRetryConditions(response, err, context, config.Conditions) + + if shouldRetry { + // Log retry attempt due to validation failure + if config.OnRetry != nil { + validationErrors := strings.Join(validationResult.Errors, "; ") + config.OnRetry(attempt, fmt.Sprintf("%s (Validation: %s)", retryReason, validationErrors), t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + continue + } + } + + // All retries failed validation - create a BifrostError to force test failure + validationErrors := strings.Join(validationResult.Errors, "; ") + + if config.OnFinalFail != nil { + finalErr := fmt.Errorf("validation failed after %d attempts: %s", attempt, validationErrors) + config.OnFinalFail(attempt, finalErr, t) + } + + // Return nil response + BifrostError so calling test fails + statusCode := 400 + testFailureError := &schemas.BifrostError{ + IsBifrostError: true, + StatusCode: &statusCode, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("Validation failed after %d attempts: %s", attempt, validationErrors), + }, + } + return nil, testFailureError + } + + // If we have an error without a response, check if we should retry + if err != nil && attempt < config.MaxAttempts { + shouldRetry, retryReason := checkResponsesRetryConditions(response, err, context, config.Conditions) + + if shouldRetry { + if config.OnRetry != nil { + config.OnRetry(attempt, retryReason, t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + continue + } + } + + // If we get here, either we got a final error or no more retries + break + } + + // Final failure callback + if config.OnFinalFail != nil && lastError != nil { + errorMsg := "unknown error" + if lastError.Error != nil { + errorMsg = lastError.Error.Message + } + config.OnFinalFail(config.MaxAttempts, fmt.Errorf("final error: %s", errorMsg), t) + } + + return lastResponse, lastError +} + +// WithStreamRetry wraps a streaming operation with retry logic for LLM behavioral inconsistencies +func WithStreamRetry( + t *testing.T, + config TestRetryConfig, + context TestRetryContext, + operation func() (chan *schemas.BifrostStream, *schemas.BifrostError), +) (chan *schemas.BifrostStream, *schemas.BifrostError) { + var lastChannel chan *schemas.BifrostStream + var lastError *schemas.BifrostError + + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + if attempt > 1 { + t.Logf("πŸ”„ Retry attempt %d/%d for %s", attempt, config.MaxAttempts, context.ScenarioName) + } + + lastChannel, lastError = operation() + + // If successful (no error), return immediately + if lastError == nil { + if attempt > 1 { + t.Logf("βœ… Stream retry succeeded on attempt %d for %s", attempt, context.ScenarioName) + } + return lastChannel, nil + } + + // Check if we should retry based on conditions + shouldRetry, reason := checkStreamRetryConditions(lastChannel, lastError, context, config.Conditions) + + if !shouldRetry || attempt == config.MaxAttempts { + if attempt > 1 { + t.Logf("❌ Stream retry failed after %d attempts for %s", attempt, context.ScenarioName) + } + return lastChannel, lastError + } + + t.Logf("πŸ”„ Stream retry %d/%d triggered for %s: %s", attempt, config.MaxAttempts, context.ScenarioName, reason) + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + } + + return lastChannel, lastError +} + +// checkStreamRetryConditions evaluates retry conditions for streaming operations +func checkStreamRetryConditions( + channel chan *schemas.BifrostStream, + err *schemas.BifrostError, + context TestRetryContext, + conditions []TestRetryCondition, +) (bool, string) { + // For streaming, we mainly check the error conditions since the channel is either nil or valid + // We can't easily check the contents of the stream without consuming it + for _, condition := range conditions { + // Pass nil response since streaming doesn't have a single response + if shouldRetry, reason := condition.ShouldRetry(nil, err, context); shouldRetry { + return true, fmt.Sprintf("%s: %s", condition.GetConditionName(), reason) + } + } + return false, "" +} + +// checkRetryConditions evaluates all retry conditions and returns whether to retry +func checkRetryConditions( + response *schemas.BifrostResponse, + err *schemas.BifrostError, + context TestRetryContext, + conditions []TestRetryCondition, +) (bool, string) { + for _, condition := range conditions { + if shouldRetry, reason := condition.ShouldRetry(response, err, context); shouldRetry { + return true, fmt.Sprintf("%s: %s", condition.GetConditionName(), reason) + } + } + return false, "" +} + +// calculateRetryDelay calculates the delay for the next retry attempt using exponential backoff +func calculateRetryDelay(attempt int, baseDelay, maxDelay time.Duration) time.Duration { + // Exponential backoff: baseDelay * 2^attempt + delay := time.Duration(float64(baseDelay) * math.Pow(2, float64(attempt))) + + // Cap at maximum delay + if delay > maxDelay { + delay = maxDelay + } + + return delay +} + +// Convenience functions for common retry configurations + +// ToolCallRetryConfig creates a retry config optimized for tool calling tests +func ToolCallRetryConfig(expectedToolName string) TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 5, // Tool calling can be very inconsistent + BaseDelay: 750 * time.Millisecond, + MaxDelay: 8 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyResponseCondition{}, + &MissingToolCallCondition{ExpectedToolName: expectedToolName}, + &MalformedToolArgsCondition{}, + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying tool call test (attempt %d): %s", attempt, reason) + }, + } +} + +// MultiToolRetryConfig creates a retry config for multiple tool call tests +func MultiToolRetryConfig(expectedToolCount int, expectedTools []string) TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 4, + BaseDelay: 1 * time.Second, + MaxDelay: 10 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyResponseCondition{}, + &PartialToolCallCondition{ExpectedCount: expectedToolCount}, + &MalformedToolArgsCondition{}, + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying multi-tool test (attempt %d): %s", attempt, reason) + }, + } +} + +// ImageProcessingRetryConfig creates a retry config for image processing tests +func ImageProcessingRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 4, + BaseDelay: 1 * time.Second, + MaxDelay: 8 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyResponseCondition{}, + &ImageNotProcessedCondition{}, + &GenericResponseCondition{}, + &ContentValidationCondition{}, // 🎯 KEY ADDITION: Retry when valid response lacks expected keywords + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying image processing test (attempt %d): %s", attempt, reason) + }, + } +} + +// StreamingRetryConfig creates a retry config for streaming tests +func StreamingRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + // Only use stream-specific conditions, not EmptyResponseCondition + // EmptyResponseCondition doesn't work with streaming since response is nil + Conditions: []TestRetryCondition{ + &StreamErrorCondition{}, // Only retry on actual stream errors + &IncompleteStreamCondition{}, // Check for incomplete streams + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying streaming test (attempt %d): %s", attempt, reason) + }, + } +} + +// ConversationRetryConfig creates a retry config for conversation-based tests +func ConversationRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyResponseCondition{}, + &GenericResponseCondition{}, // Catch generic AI responses + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying conversation test (attempt %d): %s", attempt, reason) + }, + } +} + +// DefaultSpeechRetryConfig creates a retry config for speech synthesis tests +func DefaultSpeechRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + Conditions: []TestRetryCondition{ + &EmptySpeechCondition{}, // Check for missing audio data + &GenericResponseCondition{}, // Catch generic error responses + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying speech synthesis test (attempt %d): %s", attempt, reason) + }, + } +} + +// SpeechStreamRetryConfig creates a retry config for streaming speech synthesis tests +func SpeechStreamRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + Conditions: []TestRetryCondition{ + &StreamErrorCondition{}, // Stream-specific errors + &EmptySpeechCondition{}, // Check for missing audio data + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying streaming speech synthesis test (attempt %d): %s", attempt, reason) + }, + } +} + +// DefaultTranscriptionRetryConfig creates a retry config for transcription tests +func DefaultTranscriptionRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyTranscriptionCondition{}, // Check for missing transcription text + &GenericResponseCondition{}, // Catch generic error responses + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying transcription test (attempt %d): %s", attempt, reason) + }, + } +} + +// ReasoningRetryConfig creates a retry config for reasoning tests +func ReasoningRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 5, + BaseDelay: 750 * time.Millisecond, + MaxDelay: 8 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyResponseCondition{}, + }, + } +} + +// DefaultEmbeddingRetryConfig creates a retry config for embedding tests +func DefaultEmbeddingRetryConfig() TestRetryConfig { + return TestRetryConfig{ + MaxAttempts: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + Conditions: []TestRetryCondition{ + &EmptyEmbeddingCondition{}, + &InvalidEmbeddingDimensionCondition{}, + }, + OnRetry: func(attempt int, reason string, t *testing.T) { + t.Logf("πŸ”„ Retrying embedding test (attempt %d): %s", attempt, reason) + }, + } +} + +// DualAPITestResult represents the result of testing both Chat Completions and Responses APIs +type DualAPITestResult struct { + ChatCompletionsResponse *schemas.BifrostChatResponse + ChatCompletionsError *schemas.BifrostError + ResponsesAPIResponse *schemas.BifrostResponsesResponse + ResponsesAPIError *schemas.BifrostError + BothSucceeded bool +} + +// WithDualAPITestRetry wraps a test operation with retry logic for both Chat Completions and Responses API +// The test passes only when BOTH APIs succeed according to expectations +func WithDualAPITestRetry( + t *testing.T, + config TestRetryConfig, + context TestRetryContext, + expectations ResponseExpectations, + scenarioName string, + chatOperation func() (*schemas.BifrostChatResponse, *schemas.BifrostError), + responsesOperation func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError), +) DualAPITestResult { + + var lastResult DualAPITestResult + + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + context.AttemptNumber = attempt + + // Execute both operations + chatResponse, chatErr := chatOperation() + responsesResponse, responsesErr := responsesOperation() + + lastResult = DualAPITestResult{ + ChatCompletionsResponse: chatResponse, + ChatCompletionsError: chatErr, + ResponsesAPIResponse: responsesResponse, + ResponsesAPIError: responsesErr, + BothSucceeded: false, + } + + // Validate Chat Completions API response + var chatValidationPassed bool + var chatValidationErrors []string + if chatResponse != nil { + chatValidationResult := ValidateChatResponse(t, chatResponse, chatErr, expectations, scenarioName+" (Chat Completions)") + chatValidationPassed = chatValidationResult.Passed + chatValidationErrors = chatValidationResult.Errors + } + + // Validate Responses API response + var responsesValidationPassed bool + var responsesValidationErrors []string + if responsesResponse != nil { + responsesValidationResult := ValidateResponsesResponse(t, responsesResponse, responsesErr, expectations, scenarioName+" (Responses API)") + responsesValidationPassed = responsesValidationResult.Passed + responsesValidationErrors = responsesValidationResult.Errors + } + + // Check if both APIs succeeded + bothPassed := chatValidationPassed && responsesValidationPassed + lastResult.BothSucceeded = bothPassed + + if bothPassed { + t.Logf("βœ… Both APIs passed validation on attempt %d for %s", attempt, scenarioName) + return lastResult + } + + // If not on final attempt, check if we should retry + if attempt < config.MaxAttempts { + // For dual API retry, we use basic retry conditions + // Since we can't use checkRetryConditions with different response types, + // we'll use a simple retry strategy based on validation failures + shouldRetry := !chatValidationPassed || !responsesValidationPassed + var retryReason string + if !chatValidationPassed { + retryReason = "Chat API validation failed" + } + if !responsesValidationPassed { + if retryReason != "" { + retryReason += " and Responses API validation failed" + } else { + retryReason = "Responses API validation failed" + } + } + + if shouldRetry { + // Log retry attempt + if config.OnRetry != nil { + var reasons []string + if !chatValidationPassed { + reasons = append(reasons, fmt.Sprintf("Chat Completions Validation: %s", strings.Join(chatValidationErrors, "; "))) + } + if !responsesValidationPassed { + reasons = append(reasons, fmt.Sprintf("Responses API Validation: %s", strings.Join(responsesValidationErrors, "; "))) + } + config.OnRetry(attempt, strings.Join(reasons, " | "), t) + } + + // Calculate delay with exponential backoff + delay := calculateRetryDelay(attempt-1, config.BaseDelay, config.MaxDelay) + time.Sleep(delay) + continue + } + } + + // Final attempt failed - log details + if config.OnFinalFail != nil { + var errors []string + if !chatValidationPassed { + errors = append(errors, fmt.Sprintf("Chat Completions failed: %s", strings.Join(chatValidationErrors, "; "))) + } + if !responsesValidationPassed { + errors = append(errors, fmt.Sprintf("Responses API failed: %s", strings.Join(responsesValidationErrors, "; "))) + } + finalErr := fmt.Errorf("dual API test failed after %d attempts: %s", attempt, strings.Join(errors, " AND ")) + config.OnFinalFail(attempt, finalErr, t) + } + + break + } + + // Ensure BothSucceeded reflects the final validation state + // This fixes a bug where successful retries weren't properly reflected in the result + if lastResult.ChatCompletionsResponse != nil && lastResult.ResponsesAPIResponse != nil { + chatValidationResult := ValidateChatResponse(t, lastResult.ChatCompletionsResponse, lastResult.ChatCompletionsError, expectations, scenarioName+" (Chat Completions)") + responsesValidationResult := ValidateResponsesResponse(t, lastResult.ResponsesAPIResponse, lastResult.ResponsesAPIError, expectations, scenarioName+" (Responses API)") + lastResult.BothSucceeded = chatValidationResult.Passed && responsesValidationResult.Passed + } + + return lastResult +} + +// GetTestRetryConfigForScenario returns an appropriate retry config for a scenario +func GetTestRetryConfigForScenario(scenarioName string, testConfig config.ComprehensiveTestConfig) TestRetryConfig { + switch scenarioName { + case "ToolCalls", "SingleToolCall": + return ToolCallRetryConfig("") // Will be set by specific test + case "MultipleToolCalls": + return MultiToolRetryConfig(2, []string{}) // Will be customized by specific test + case "End2EndToolCalling", "AutomaticFunctionCalling": + return ToolCallRetryConfig("") // Tool-calling focused + case "ImageURL", "ImageBase64", "MultipleImages": + return ImageProcessingRetryConfig() + case "CompleteEnd2End_Vision": // 🎯 Vision step of end-to-end test + return ImageProcessingRetryConfig() + case "CompleteEnd2End_Chat": // πŸ’¬ Chat step of end-to-end test + return ConversationRetryConfig() + case "ChatCompletionStream": + return StreamingRetryConfig() + case "Embedding": + return DefaultEmbeddingRetryConfig() + case "SpeechSynthesis", "SpeechSynthesisHD", "SpeechSynthesis_Voice": // πŸ”Š Speech synthesis tests + return DefaultSpeechRetryConfig() + case "SpeechSynthesisStream", "SpeechSynthesisStreamHD", "SpeechSynthesisStreamVoice": // πŸ”Š Streaming speech tests + return SpeechStreamRetryConfig() + case "Transcription", "TranscriptionStream": // πŸŽ™οΈ Transcription tests + return DefaultTranscriptionRetryConfig() + case "Reasoning": + return ReasoningRetryConfig() + default: + // For basic scenarios like SimpleChat, TextCompletion + return DefaultTestRetryConfig() + } +} + +// checkChatRetryConditions checks if any chat retry conditions are met +func checkChatRetryConditions(response *schemas.BifrostChatResponse, err *schemas.BifrostError, context TestRetryContext, conditions []ChatRetryCondition) (bool, string) { + for _, condition := range conditions { + if shouldRetry, reason := condition.ShouldRetry(response, err, context); shouldRetry { + return true, fmt.Sprintf("%s: %s", condition.GetConditionName(), reason) + } + } + + return false, "" +} + +// checkResponsesRetryConditions checks if any Responses API retry conditions are met +func checkResponsesRetryConditions(response *schemas.BifrostResponsesResponse, err *schemas.BifrostError, context TestRetryContext, conditions []ResponsesRetryCondition) (bool, string) { + for _, condition := range conditions { + if shouldRetry, reason := condition.ShouldRetry(response, err, context); shouldRetry { + return true, fmt.Sprintf("%s: %s", condition.GetConditionName(), reason) + } + } + + return false, "" +} diff --git a/tests/core-providers/scenarios/text_completion.go b/tests/core-providers/scenarios/text_completion.go new file mode 100644 index 000000000..02dc7200b --- /dev/null +++ b/tests/core-providers/scenarios/text_completion.go @@ -0,0 +1,72 @@ +package scenarios + +import ( + "context" + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunTextCompletionTest tests text completion functionality +func RunTextCompletionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.TextCompletion || testConfig.TextModel == "" { + t.Logf("⏭️ Text completion not supported for provider %s", testConfig.Provider) + return + } + + t.Run("TextCompletion", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + prompt := "In fruits, A is for apple and B is for" + request := &schemas.BifrostTextCompletionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TextModel, + Input: &schemas.TextCompletionInput{ + PromptStr: &prompt, + }, + Fallbacks: testConfig.TextCompletionFallbacks, + } + + // Use retry framework with enhanced validation + retryConfig := GetTestRetryConfigForScenario("TextCompletion", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "TextCompletion", + ExpectedBehavior: map[string]interface{}{ + "should_continue_prompt": true, + "should_be_coherent": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.TextModel, + "prompt": prompt, + }, + } + + // Enhanced validation expectations + expectations := GetExpectationsForScenario("TextCompletion", testConfig, map[string]interface{}{}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ShouldContainKeywords = []string{"banana"} // Should continue the AI theme + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{"error", "failed", "invalid"}...) // Should not contain error terms + + response, bifrostErr := WithTestRetry(t, retryConfig, retryContext, expectations, "TextCompletion", func() (*schemas.BifrostResponse, *schemas.BifrostError) { + c, err := client.TextCompletionRequest(ctx, request) + if err != nil { + return nil, err + } + return &schemas.BifrostResponse{TextCompletionResponse: c}, nil + }) + + if bifrostErr != nil { + t.Fatalf("❌ TextCompletion request failed after retries: %v", GetErrorMessage(bifrostErr)) + } + + content := GetResultContent(response) + t.Logf("βœ… Text completion result: %s", content) + }) +} diff --git a/tests/core-providers/scenarios/text_completion_stream.go b/tests/core-providers/scenarios/text_completion_stream.go new file mode 100644 index 000000000..0d808e52e --- /dev/null +++ b/tests/core-providers/scenarios/text_completion_stream.go @@ -0,0 +1,455 @@ +package scenarios + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunTextCompletionStreamTest executes the text completion streaming test scenario +func RunTextCompletionStreamTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.TextCompletionStream { + t.Logf("Text completion stream not supported for provider %s", testConfig.Provider) + return + } + + t.Run("TextCompletionStream", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Create a text completion prompt + prompt := "Write a short story about a robot learning to paint. Keep it under 150 words." + + input := &schemas.TextCompletionInput{ + PromptStr: &prompt, + } + + // Use TextModel if available, otherwise fall back to ChatModel + model := testConfig.TextModel + if model == "" { + model = testConfig.ChatModel + } + + request := &schemas.BifrostTextCompletionRequest{ + Provider: testConfig.Provider, + Model: model, + Input: input, + Params: &schemas.TextCompletionParameters{ + MaxTokens: bifrost.Ptr(150), + }, + Fallbacks: testConfig.TextCompletionFallbacks, + } + + // Use retry framework for stream requests + retryConfig := StreamingRetryConfig() + retryContext := TestRetryContext{ + ScenarioName: "TextCompletionStream", + ExpectedBehavior: map[string]interface{}{ + "should_stream_content": true, + "should_tell_story": true, + "topic": "robot painting", + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": model, + }, + } + + // Use proper streaming retry wrapper for the stream request + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.TextCompletionStreamRequest(ctx, request) + }) + + // Enhanced error handling + RequireNoError(t, err, "Text completion stream request failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var fullContent strings.Builder + var responseCount int + var lastResponse *schemas.BifrostStream + + // Create a timeout context for the stream reading + streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + t.Logf("πŸ“‘ Starting to read text completion streaming response...") + + // Read streaming responses + for { + select { + case response, ok := <-responseChannel: + if !ok { + // Channel closed, streaming completed + t.Logf("βœ… Text completion streaming completed. Total chunks received: %d", responseCount) + goto streamComplete + } + + if response == nil { + t.Fatal("Streaming response should not be nil") + } + lastResponse = DeepCopyBifrostStream(response) + + // Basic validation of streaming response structure + if response.BifrostTextCompletionResponse != nil { + if response.BifrostTextCompletionResponse.ExtraFields.Provider != testConfig.Provider { + t.Logf("⚠️ Warning: Provider mismatch - expected %s, got %s", testConfig.Provider, response.BifrostTextCompletionResponse.ExtraFields.Provider) + } + if response.BifrostTextCompletionResponse.ID == "" { + t.Logf("⚠️ Warning: Response ID is empty") + } + + // Log latency for each chunk (can be 0 for inter-chunks) + t.Logf("πŸ“Š Chunk %d latency: %d ms", responseCount+1, response.BifrostTextCompletionResponse.ExtraFields.Latency) + + // Validate text completion response structure + if response.BifrostTextCompletionResponse.Choices == nil { + t.Logf("⚠️ Warning: Choices should not be nil in text completion streaming") + } + + // Process each choice in the response (similar to chat completion) + for _, choice := range response.BifrostTextCompletionResponse.Choices { + // For text completion, we expect either streaming deltas or text completion choices + if choice.TextCompletionResponseChoice != nil { + // Handle direct text completion response choice (converted by providers) + if choice.TextCompletionResponseChoice.Text != nil { + fullContent.WriteString(*choice.TextCompletionResponseChoice.Text) + t.Logf("✍️ Text completion: %s", *choice.TextCompletionResponseChoice.Text) + } + + // Check finish reason if present + if choice.FinishReason != nil { + t.Logf("🏁 Finish reason: %s", *choice.FinishReason) + } + } else { + t.Logf("⚠️ Warning: Choice %d has no text completion or stream response content", choice.Index) + } + } + } + + responseCount++ + + // Safety check to prevent infinite loops in case of issues + if responseCount > 500 { + t.Fatal("Received too many streaming chunks, something might be wrong") + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for text completion streaming response") + } + } + + streamComplete: + // Validate final streaming response + finalContent := strings.TrimSpace(fullContent.String()) + + // Create a consolidated response for validation + consolidatedResponse := createConsolidatedTextCompletionResponse(finalContent, lastResponse, testConfig.Provider) + + // Enhanced validation expectations for text completion streaming + expectations := GetExpectationsForScenario("TextCompletionStream", testConfig, map[string]interface{}{}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + expectations.ShouldContainKeywords = append(expectations.ShouldContainKeywords, []string{"robot"}...) // Should include story elements + expectations.MinContentLength = 30 // Should be substantial content + expectations.MaxContentLength = 2000 // Reasonable upper bound + + // Validate the consolidated text completion streaming response + validationResult := ValidateTextCompletionResponse(t, consolidatedResponse, nil, expectations, "TextCompletionStream") + + // Basic streaming validation + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + + if finalContent == "" { + t.Fatal("Final content should not be empty") + } + + if len(finalContent) < 5 { + t.Fatal("Final content should be substantial") + } + + // Validate latency is present in the last chunk (total latency) + if lastResponse != nil && lastResponse.BifrostTextCompletionResponse != nil { + if lastResponse.BifrostTextCompletionResponse.ExtraFields.Latency <= 0 { + t.Errorf("❌ Last streaming chunk missing latency information (got %d ms)", lastResponse.BifrostTextCompletionResponse.ExtraFields.Latency) + } else { + t.Logf("βœ… Total streaming latency: %d ms", lastResponse.BifrostTextCompletionResponse.ExtraFields.Latency) + } + } + + if !validationResult.Passed { + t.Errorf("❌ Text completion streaming validation failed: %v", validationResult.Errors) + } + + t.Logf("πŸ“Š Text completion streaming metrics: %d chunks, %d chars", responseCount, len(finalContent)) + + t.Logf("βœ… Text completion streaming test completed successfully") + t.Logf("πŸ“ Final content (%d chars): %s", len(finalContent), finalContent) + }) + + // Test text completion streaming with different prompts + t.Run("TextCompletionStreamVariedPrompts", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Use TextModel if available, otherwise fall back to ChatModel + model := testConfig.TextModel + if model == "" { + model = testConfig.ChatModel + } + testPrompts := []struct { + name string + prompt string + expect string + }{ + { + name: "SimpleCompletion", + prompt: "The quick brown fox", + expect: "completion", + }, + { + name: "Question", + prompt: "What is artificial intelligence? AI is", + expect: "definition", + }, + { + name: "CodeCompletion", + prompt: "def fibonacci(n):\n if n <= 1:", + expect: "code", + }, + } + + for _, testCase := range testPrompts { + t.Run(testCase.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + input := &schemas.TextCompletionInput{ + PromptStr: &testCase.prompt, + } + + request := &schemas.BifrostTextCompletionRequest{ + Provider: testConfig.Provider, + Model: model, + Input: input, + Params: &schemas.TextCompletionParameters{ + MaxTokens: bifrost.Ptr(50), + Temperature: bifrost.Ptr(0.7), + }, + Fallbacks: testConfig.TextCompletionFallbacks, + } + + responseChannel, err := client.TextCompletionStreamRequest(ctx, request) + RequireNoError(t, err, "Text completion stream with varied prompts failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var responseCount int + var content strings.Builder + + streamCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + t.Logf("Testing text completion streaming with prompt: %s", testCase.name) + + for { + select { + case response, ok := <-responseChannel: + if !ok { + goto variedPromptComplete + } + + if response == nil { + t.Fatal("Streaming response should not be nil") + } + responseCount++ + + // Extract content from choices + if response.BifrostTextCompletionResponse != nil { + for _, choice := range response.BifrostTextCompletionResponse.Choices { + if choice.TextCompletionResponseChoice != nil { + delta := choice.TextCompletionResponseChoice.Text + if delta != nil { + content.WriteString(*delta) + } + } + } + } + + if responseCount > 100 { + goto variedPromptComplete + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for text completion streaming response") + } + } + + variedPromptComplete: + finalContent := strings.TrimSpace(content.String()) + + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + + if finalContent == "" { + t.Logf("⚠️ Warning: No content generated for prompt: %s", testCase.prompt) + } else { + t.Logf("βœ… Generated content for %s: %s", testCase.name, finalContent) + } + }) + } + }) + + // Test text completion streaming with different parameters + t.Run("TextCompletionStreamParameters", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Use TextModel if available, otherwise fall back to ChatModel + model := testConfig.TextModel + if model == "" { + model = testConfig.ChatModel + } + + prompt := "Once upon a time in a distant galaxy" + + parameterTests := []struct { + name string + temperature *float64 + maxTokens *int + topP *float64 + }{ + { + name: "HighCreativity", + temperature: bifrost.Ptr(0.9), + maxTokens: bifrost.Ptr(100), + topP: bifrost.Ptr(0.9), + }, + { + name: "LowCreativity", + temperature: bifrost.Ptr(0.1), + maxTokens: bifrost.Ptr(50), + topP: bifrost.Ptr(0.5), + }, + { + name: "Balanced", + temperature: bifrost.Ptr(0.5), + maxTokens: bifrost.Ptr(75), + topP: bifrost.Ptr(0.8), + }, + } + + for _, paramTest := range parameterTests { + t.Run(paramTest.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + input := &schemas.TextCompletionInput{ + PromptStr: &prompt, + } + + request := &schemas.BifrostTextCompletionRequest{ + Provider: testConfig.Provider, + Model: model, + Input: input, + Params: &schemas.TextCompletionParameters{ + MaxTokens: paramTest.maxTokens, + Temperature: paramTest.temperature, + TopP: paramTest.topP, + }, + Fallbacks: testConfig.TextCompletionFallbacks, + } + + responseChannel, err := client.TextCompletionStreamRequest(ctx, request) + RequireNoError(t, err, "Text completion stream with parameters failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + var responseCount int + streamCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + t.Logf("πŸ”§ Testing text completion streaming with parameters: %s", paramTest.name) + + for { + select { + case response, ok := <-responseChannel: + if !ok { + goto parameterTestComplete + } + + if response != nil { + responseCount++ + } + + if responseCount > 150 { + goto parameterTestComplete + } + + case <-streamCtx.Done(): + t.Fatal("Timeout waiting for text completion streaming response") + } + } + + parameterTestComplete: + if responseCount == 0 { + t.Fatal("Should receive at least one streaming response") + } + + t.Logf("βœ… Parameter test %s completed with %d chunks", paramTest.name, responseCount) + }) + } + }) +} + +// createConsolidatedTextCompletionResponse creates a consolidated response for validation +func createConsolidatedTextCompletionResponse(finalContent string, lastResponse *schemas.BifrostStream, provider schemas.ModelProvider) *schemas.BifrostTextCompletionResponse { + consolidatedResponse := &schemas.BifrostTextCompletionResponse{ + Object: "text_completion", + Choices: []schemas.BifrostResponseChoice{ + { + Index: 0, + TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{ + Text: &finalContent, + }, + }, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + Provider: provider, + RequestType: schemas.TextCompletionRequest, + }, + } + + // Copy usage and other metadata from last response if available + if lastResponse != nil && lastResponse.BifrostTextCompletionResponse != nil { + consolidatedResponse.Usage = lastResponse.BifrostTextCompletionResponse.Usage + consolidatedResponse.Model = lastResponse.BifrostTextCompletionResponse.Model + consolidatedResponse.ID = lastResponse.BifrostTextCompletionResponse.ID + + // Copy finish reason from last choice if available + if len(lastResponse.BifrostTextCompletionResponse.Choices) > 0 && lastResponse.BifrostTextCompletionResponse.Choices[0].FinishReason != nil { + consolidatedResponse.Choices[0].FinishReason = lastResponse.BifrostTextCompletionResponse.Choices[0].FinishReason + } + + consolidatedResponse.ExtraFields = lastResponse.BifrostTextCompletionResponse.ExtraFields + } + + return consolidatedResponse +} diff --git a/tests/core-providers/scenarios/tool_calls.go b/tests/core-providers/scenarios/tool_calls.go new file mode 100644 index 000000000..d873f8907 --- /dev/null +++ b/tests/core-providers/scenarios/tool_calls.go @@ -0,0 +1,158 @@ +package scenarios + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/require" +) + +// RunToolCallsTest executes the tool calls test scenario using dual API testing framework +func RunToolCallsTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.ToolCalls { + t.Logf("Tool calls not supported for provider %s", testConfig.Provider) + return + } + + t.Run("ToolCalls", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + chatMessages := []schemas.ChatMessage{ + CreateBasicChatMessage("What's the weather like in New York? answer in celsius"), + } + responsesMessages := []schemas.ResponsesMessage{ + CreateBasicResponsesMessage("What's the weather like in New York? answer in celsius"), + } + + // Get tools for both APIs using the new GetSampleTool function + chatTool := GetSampleChatTool(SampleToolTypeWeather) // Chat Completions API + responsesTool := GetSampleResponsesTool(SampleToolTypeWeather) // Responses API + + // Use specialized tool call retry configuration + retryConfig := ToolCallRetryConfig(string(SampleToolTypeWeather)) + retryContext := TestRetryContext{ + ScenarioName: "ToolCalls", + ExpectedBehavior: map[string]interface{}{ + "expected_tool_name": string(SampleToolTypeWeather), + "required_location": "new york", + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.ChatModel, + }, + } + + // Enhanced tool call validation (same for both APIs) + expectations := ToolCallExpectations(string(SampleToolTypeWeather), []string{"location"}) + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + // Add additional tool-specific validations + expectations.ExpectedToolCalls[0].ArgumentTypes = map[string]string{ + "location": "string", + } + + // Create operations for both Chat Completions and Responses API + chatOperation := func() (*schemas.BifrostChatResponse, *schemas.BifrostError) { + chatReq := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: chatMessages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + Tools: []schemas.ChatTool{*chatTool}, + }, + Fallbacks: testConfig.Fallbacks, + } + return client.ChatCompletionRequest(ctx, chatReq) + } + + responsesOperation := func() (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + responsesReq := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: responsesMessages, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{*responsesTool}, + }, + } + return client.ResponsesRequest(ctx, responsesReq) + } + + // Execute dual API test - passes only if BOTH APIs succeed + result := WithDualAPITestRetry(t, + retryConfig, + retryContext, + expectations, + "ToolCalls", + chatOperation, + responsesOperation) + + // Validate both APIs succeeded + if !result.BothSucceeded { + var errors []string + if result.ChatCompletionsError != nil { + errors = append(errors, "Chat Completions: "+GetErrorMessage(result.ChatCompletionsError)) + } + if result.ResponsesAPIError != nil { + errors = append(errors, "Responses API: "+GetErrorMessage(result.ResponsesAPIError)) + } + if len(errors) == 0 { + errors = append(errors, "One or both APIs failed validation (see logs above)") + } + t.Fatalf("❌ ToolCalls dual API test failed: %v", errors) + } + + // Verify location argument mentions New York using universal tool extraction + validateLocationInChatToolCalls := func(response *schemas.BifrostChatResponse, apiName string) { + toolCalls := ExtractChatToolCalls(response) + validateLocationInToolCalls(t, toolCalls, apiName) + } + + validateLocationInResponsesToolCalls := func(response *schemas.BifrostResponsesResponse, apiName string) { + toolCalls := ExtractResponsesToolCalls(response) + validateLocationInToolCalls(t, toolCalls, apiName) + } + + // Validate both API responses + if result.ChatCompletionsResponse != nil { + validateLocationInChatToolCalls(result.ChatCompletionsResponse, "Chat Completions") + } + + if result.ResponsesAPIResponse != nil { + validateLocationInResponsesToolCalls(result.ResponsesAPIResponse, "Responses") + } + + t.Logf("πŸŽ‰ Both Chat Completions and Responses APIs passed ToolCalls test!") + }) +} + +func validateLocationInToolCalls(t *testing.T, toolCalls []ToolCallInfo, apiName string) { + locationFound := false + + for _, toolCall := range toolCalls { + if toolCall.Name == string(SampleToolTypeWeather) { + var args map[string]interface{} + if json.Unmarshal([]byte(toolCall.Arguments), &args) == nil { + if location, exists := args["location"].(string); exists { + lowerLocation := strings.ToLower(location) + if strings.Contains(lowerLocation, "new york") || strings.Contains(lowerLocation, "nyc") { + locationFound = true + t.Logf("βœ… %s tool call has correct location: %s", apiName, location) + break + } + } + } + } + } + + require.True(t, locationFound, "%s API tool call should specify New York as the location", apiName) +} diff --git a/tests/core-providers/scenarios/tool_calls_streaming.go b/tests/core-providers/scenarios/tool_calls_streaming.go new file mode 100644 index 000000000..0b8dfe0c7 --- /dev/null +++ b/tests/core-providers/scenarios/tool_calls_streaming.go @@ -0,0 +1,787 @@ +package scenarios + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/require" +) + +// StreamingToolCallAccumulator accumulates tool call fragments from streaming responses +type StreamingToolCallAccumulator struct { + // For Chat Completions: map of tool call index -> accumulated tool call + ChatToolCalls map[int]*schemas.ChatAssistantMessageToolCall + // For Responses API: map of call ID or item ID -> accumulated tool call info + ResponsesToolCalls map[string]*ResponsesToolCallInfo + // Map itemID to the key used in ResponsesToolCalls for quick lookup + ItemIDToKey map[string]string +} + +// ResponsesToolCallInfo accumulates tool call information from Responses API streaming +type ResponsesToolCallInfo struct { + ID string + Name string + Arguments string +} + +// NewStreamingToolCallAccumulator creates a new accumulator +func NewStreamingToolCallAccumulator() *StreamingToolCallAccumulator { + return &StreamingToolCallAccumulator{ + ChatToolCalls: make(map[int]*schemas.ChatAssistantMessageToolCall), + ResponsesToolCalls: make(map[string]*ResponsesToolCallInfo), + ItemIDToKey: make(map[string]string), + } +} + +// AccumulateChatToolCall accumulates a tool call from a Chat Completions streaming chunk +func (acc *StreamingToolCallAccumulator) AccumulateChatToolCall(choiceIndex int, toolCall schemas.ChatAssistantMessageToolCall) { + // Prefer ID as key if available, otherwise use index + key := -1 + var found bool + if toolCall.ID != nil && *toolCall.ID != "" { + // Try to find existing tool call by ID first + for k, existing := range acc.ChatToolCalls { + if existing.ID != nil && *existing.ID == *toolCall.ID { + key = k + found = true + break + } + } + // If not found by ID, use index + if !found { + key = int(toolCall.Index) + } + } else { + // Use the tool call index as the key + key = int(toolCall.Index) + } + + existing, exists := acc.ChatToolCalls[key] + if !exists { + // First chunk for this tool call - initialize + acc.ChatToolCalls[key] = &schemas.ChatAssistantMessageToolCall{ + Index: toolCall.Index, + Type: toolCall.Type, + ID: toolCall.ID, + Function: schemas.ChatAssistantMessageToolCallFunction{}, + } + existing = acc.ChatToolCalls[key] + } + + // Accumulate name if present + if toolCall.Function.Name != nil && *toolCall.Function.Name != "" { + existing.Function.Name = toolCall.Function.Name + } + + // Accumulate ID if present (may come in later chunks) + if toolCall.ID != nil && *toolCall.ID != "" { + existing.ID = toolCall.ID + } + + // Accumulate arguments (they come incrementally) + if toolCall.Function.Arguments != "" { + existing.Function.Arguments += toolCall.Function.Arguments + } +} + +// AccumulateResponsesToolCall accumulates a tool call from a Responses API streaming chunk +func (acc *StreamingToolCallAccumulator) AccumulateResponsesToolCall(callID *string, name *string, arguments *string, itemID *string) { + // First, try to find existing tool call by itemID (most reliable for matching) + key := "default" + if itemID != nil && *itemID != "" { + itemIDStr := *itemID + // Check if we have a mapping for this itemID + if mappedKey, exists := acc.ItemIDToKey[itemIDStr]; exists { + key = mappedKey + } else { + // Try to find by itemID in keys (with or without prefix) + for k := range acc.ResponsesToolCalls { + if k == itemIDStr || k == "item:"+itemIDStr { + key = k + acc.ItemIDToKey[itemIDStr] = key + break + } + } + // If not found, use itemID as key + if key == "default" { + key = "item:" + itemIDStr + acc.ItemIDToKey[itemIDStr] = key + } + } + } else if callID != nil && *callID != "" { + // Use callID as key if no itemID + key = *callID + } else if name != nil && *name != "" { + // Try to find existing tool call by name if we don't have callID or itemID yet + for k, existing := range acc.ResponsesToolCalls { + if existing.Name == *name && existing.ID == "" { + key = k + break + } + } + // If not found, use name as temporary key + if key == "default" { + key = "name:" + *name + } + } + + existing, exists := acc.ResponsesToolCalls[key] + if !exists { + existing = &ResponsesToolCallInfo{} + acc.ResponsesToolCalls[key] = existing + } + + // Update fields if present + if callID != nil && *callID != "" { + existing.ID = *callID + // If we were using a temporary key, migrate to callID-based key + if key != *callID { + acc.ResponsesToolCalls[*callID] = existing + // Update itemID mapping if we have one + if itemID != nil && *itemID != "" { + acc.ItemIDToKey[*itemID] = *callID + } + if key != "default" && key != *callID { + delete(acc.ResponsesToolCalls, key) + } + } + } + if name != nil && *name != "" { + existing.Name = *name + } + if arguments != nil && *arguments != "" { + // If we're getting complete arguments (from done event), replace instead of append + // Check if this looks like complete JSON (starts with { and ends with }) + argsStr := *arguments + if len(argsStr) > 0 && argsStr[0] == '{' && argsStr[len(argsStr)-1] == '}' && existing.Arguments != "" { + // This looks like complete arguments, but only replace if we already have partial args + // Otherwise, this might be the first chunk which happens to be complete + existing.Arguments = argsStr + } else { + // Incremental chunk, append + existing.Arguments += argsStr + } + } + + // Update itemID mapping if we have itemID but haven't mapped it yet + if itemID != nil && *itemID != "" { + if _, exists := acc.ItemIDToKey[*itemID]; !exists { + acc.ItemIDToKey[*itemID] = key + } + } +} + +// GetFinalChatToolCalls returns the final accumulated tool calls for Chat Completions +func (acc *StreamingToolCallAccumulator) GetFinalChatToolCalls() []ToolCallInfo { + var result []ToolCallInfo + for _, toolCall := range acc.ChatToolCalls { + info := ToolCallInfo{} + if toolCall.ID != nil { + info.ID = *toolCall.ID + } + if toolCall.Function.Name != nil { + info.Name = *toolCall.Function.Name + } + info.Arguments = toolCall.Function.Arguments + result = append(result, info) + } + return result +} + +// GetFinalResponsesToolCalls returns the final accumulated tool calls for Responses API +func (acc *StreamingToolCallAccumulator) GetFinalResponsesToolCalls() []ToolCallInfo { + var result []ToolCallInfo + for _, toolCall := range acc.ResponsesToolCalls { + result = append(result, ToolCallInfo{ + ID: toolCall.ID, + Name: toolCall.Name, + Arguments: toolCall.Arguments, + }) + } + return result +} + +// RunToolCallsStreamingTest executes the tool calls streaming test scenario +func RunToolCallsStreamingTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.ToolCallsStreaming { + t.Logf("Tool calls streaming not supported for provider %s", testConfig.Provider) + return + } + + // Test Chat Completions streaming with tool calls + t.Run("ToolCallsStreamingChatCompletions", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + chatMessages := []schemas.ChatMessage{ + CreateBasicChatMessage("What's the weather like in New York? answer in celsius"), + } + + chatTool := GetSampleChatTool(SampleToolTypeWeather) + + maxAttempts := 3 + var lastErrorMsg string + + for attempt := 1; attempt <= maxAttempts; attempt++ { + if attempt > 1 { + t.Logf("πŸ”„ Retry attempt %d/%d for Chat Completions streaming with tool calls...", attempt, maxAttempts) + } + + request := &schemas.BifrostChatRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: chatMessages, + Params: &schemas.ChatParameters{ + MaxCompletionTokens: bifrost.Ptr(150), + Tools: []schemas.ChatTool{*chatTool}, + }, + Fallbacks: testConfig.Fallbacks, + } + + responseChannel, err := client.ChatCompletionStreamRequest(ctx, request) + if err != nil { + if attempt == maxAttempts { + RequireNoError(t, err, "Chat completion stream with tools failed") + } + lastErrorMsg = GetErrorMessage(err) + continue + } + if responseChannel == nil { + if attempt == maxAttempts { + t.Fatal("Response channel should not be nil") + } + lastErrorMsg = "response channel is nil" + continue + } + + accumulator := NewStreamingToolCallAccumulator() + var responseCount int + streamError := false + + if attempt == 1 { + t.Logf("πŸ”§ Testing Chat Completions streaming with tool calls...") + } + + for response := range responseChannel { + if response == nil || response.BifrostChatResponse == nil { + if attempt == maxAttempts { + t.Fatal("Streaming response should not be nil") + } + lastErrorMsg = "streaming response is nil" + streamError = true + break + } + responseCount++ + + // Process tool calls from this chunk + if response.BifrostChatResponse.Choices != nil { + for _, choice := range response.BifrostChatResponse.Choices { + if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil { + delta := choice.ChatStreamResponseChoice.Delta + + // Check for tool calls in delta + if len(delta.ToolCalls) > 0 { + for _, toolCall := range delta.ToolCalls { + // Debug logging: what fields are present in this chunk + chunkType := "ChatCompletions.Delta.ToolCalls" + hasID := toolCall.ID != nil && *toolCall.ID != "" + hasName := toolCall.Function.Name != nil && *toolCall.Function.Name != "" + hasArgs := toolCall.Function.Arguments != "" + + t.Logf("πŸ“Š [%s] Chunk fields: ID=%v (field: toolCall.ID), Name=%v (field: toolCall.Function.Name), Args=%v (field: toolCall.Function.Arguments, len=%d)", + chunkType, hasID, hasName, hasArgs, len(toolCall.Function.Arguments)) + + if hasID { + t.Logf(" βœ… ID found in %s: %s", chunkType, *toolCall.ID) + } + if hasName { + t.Logf(" βœ… Name found in %s: %s", chunkType, *toolCall.Function.Name) + } + if hasArgs { + t.Logf(" βœ… Arguments found in %s: %s", chunkType, toolCall.Function.Arguments) + } + + accumulator.AccumulateChatToolCall(choice.Index, toolCall) + t.Logf("πŸ”§ Accumulated tool call chunk: index=%d, id=%v, name=%v, args_len=%d", + choice.Index, + toolCall.ID, + toolCall.Function.Name, + len(toolCall.Function.Arguments)) + } + } + } + } + } + + if responseCount > 500 { + break + } + } + + if streamError { + continue + } + + if responseCount == 0 { + if attempt == maxAttempts { + t.Fatal("Should receive at least one streaming response") + } + lastErrorMsg = "no streaming responses received" + continue + } + + // Validate final tool calls + finalToolCalls := accumulator.GetFinalChatToolCalls() + + // Check if validation passes + validationPassed := true + if len(finalToolCalls) == 0 { + validationPassed = false + lastErrorMsg = "no tool calls found in streaming response" + } else { + for i, toolCall := range finalToolCalls { + if toolCall.ID == "" || toolCall.Name == "" || toolCall.Arguments == "" { + validationPassed = false + lastErrorMsg = fmt.Sprintf("tool call %d missing required fields: ID=%v, Name=%v, Arguments=%v", + i, toolCall.ID != "", toolCall.Name != "", toolCall.Arguments != "") + break + } + } + } + + if validationPassed { + validateStreamingToolCalls(t, finalToolCalls, "Chat Completions") + t.Logf("βœ… Chat Completions streaming with tools test completed successfully") + return + } + + // Validation failed, retry if we have attempts left + if attempt < maxAttempts { + t.Logf("⚠️ Validation failed on attempt %d: %s", attempt, lastErrorMsg) + continue + } + } + + // All retries failed + if lastErrorMsg != "" { + t.Fatalf("❌ Chat Completions streaming with tools test failed after %d attempts: %s", maxAttempts, lastErrorMsg) + } + t.Fatalf("❌ Chat Completions streaming with tools test failed after %d attempts", maxAttempts) + }) + + // Test Responses API streaming with tool calls + t.Run("ToolCallsStreamingResponses", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + responsesMessages := []schemas.ResponsesMessage{ + CreateBasicResponsesMessage("What's the weather like in New York? answer in celsius"), + } + + responsesTool := GetSampleResponsesTool(SampleToolTypeWeather) + + maxAttempts := 3 + var lastErrorMsg string + + for attempt := 1; attempt <= maxAttempts; attempt++ { + if attempt > 1 { + t.Logf("πŸ”„ Retry attempt %d/%d for Responses API streaming with tool calls...", attempt, maxAttempts) + } + + request := &schemas.BifrostResponsesRequest{ + Provider: testConfig.Provider, + Model: testConfig.ChatModel, + Input: responsesMessages, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{*responsesTool}, + }, + Fallbacks: testConfig.Fallbacks, + } + + responseChannel, err := client.ResponsesStreamRequest(ctx, request) + if err != nil { + if attempt == maxAttempts { + RequireNoError(t, err, "Responses stream with tools failed") + } + lastErrorMsg = GetErrorMessage(err) + continue + } + if responseChannel == nil { + if attempt == maxAttempts { + t.Fatal("Response channel should not be nil") + } + lastErrorMsg = "response channel is nil" + continue + } + + accumulator := NewStreamingToolCallAccumulator() + var responseCount int + streamError := false + + if attempt == 1 { + t.Logf("πŸ”§ Testing Responses API streaming with tool calls...") + } + + for response := range responseChannel { + if response == nil { + if attempt == maxAttempts { + t.Fatal("Streaming response should not be nil") + } + lastErrorMsg = "streaming response is nil" + streamError = true + break + } + responseCount++ + + if response.BifrostResponsesStreamResponse != nil { + streamResp := response.BifrostResponsesStreamResponse + + // Check for function call events + switch streamResp.Type { + case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta: + // Arguments are being streamed - check both Delta and Arguments fields + // Delta is used by most providers (Anthropic, Cohere, Bedrock, OpenAI) + // Arguments is used by some providers (OpenAI-compatible via mux) + chunkType := string(streamResp.Type) + var arguments *string + argsField := "" + if streamResp.Delta != nil { + arguments = streamResp.Delta + argsField = "streamResp.Delta" + } else if streamResp.Arguments != nil { + arguments = streamResp.Arguments + argsField = "streamResp.Arguments" + } + + if arguments != nil { + // Try to get call ID, name, and item ID + var callID *string + var name *string + var itemID *string + callIDField := "" + nameField := "" + itemIDField := "" + + // Item ID is often in the delta event itself (for OpenAI) + if streamResp.ItemID != nil { + itemID = streamResp.ItemID + itemIDField = "streamResp.ItemID" + } + + // Try to get call ID and name from item if available + if streamResp.Item != nil && streamResp.Item.ResponsesToolMessage != nil { + if streamResp.Item.ResponsesToolMessage.CallID != nil { + callID = streamResp.Item.ResponsesToolMessage.CallID + callIDField = "streamResp.Item.ResponsesToolMessage.CallID" + } + if streamResp.Item.ResponsesToolMessage.Name != nil { + name = streamResp.Item.ResponsesToolMessage.Name + nameField = "streamResp.Item.ResponsesToolMessage.Name" + } + } + + // Also check if item has an ID + if streamResp.Item != nil && streamResp.Item.ID != nil { + itemID = streamResp.Item.ID + itemIDField = "streamResp.Item.ID" + } + + // Debug logging: what fields are present in this chunk + hasID := callID != nil && *callID != "" + hasName := name != nil && *name != "" + hasArgs := *arguments != "" + hasItemID := itemID != nil && *itemID != "" + + t.Logf("πŸ“Š [%s] Chunk fields: ID=%v (%s), Name=%v (%s), Args=%v (%s, len=%d), ItemID=%v (%s)", + chunkType, hasID, callIDField, hasName, nameField, hasArgs, argsField, len(*arguments), hasItemID, itemIDField) + + if hasID { + t.Logf(" βœ… ID found in %s: %s", chunkType, *callID) + } + if hasName { + t.Logf(" βœ… Name found in %s: %s", chunkType, *name) + } + if hasArgs { + t.Logf(" βœ… Arguments found in %s: %s", chunkType, *arguments) + } + if hasItemID { + t.Logf(" βœ… ItemID found in %s: %s", chunkType, *itemID) + } + + accumulator.AccumulateResponsesToolCall(callID, name, arguments, itemID) + callIDStr := "" + if callID != nil { + callIDStr = *callID + } + nameStr := "" + if name != nil { + nameStr = *name + } + itemIDStr := "" + if itemID != nil { + itemIDStr = *itemID + } + t.Logf("πŸ”§ Accumulated function call arguments chunk: callID=%s, name=%s, itemID=%s, args_len=%d", + callIDStr, nameStr, itemIDStr, len(*arguments)) + } + + case schemas.ResponsesStreamResponseTypeOutputItemAdded: + // A new function call item was added + if streamResp.Item != nil && streamResp.Item.Type != nil { + if *streamResp.Item.Type == schemas.ResponsesMessageTypeFunctionCall { + chunkType := string(streamResp.Type) + var callID *string + var name *string + var itemID *string + callIDField := "" + nameField := "" + itemIDField := "" + + if streamResp.Item.ResponsesToolMessage != nil { + if streamResp.Item.ResponsesToolMessage.CallID != nil { + callID = streamResp.Item.ResponsesToolMessage.CallID + callIDField = "streamResp.Item.ResponsesToolMessage.CallID" + } + if streamResp.Item.ResponsesToolMessage.Name != nil { + name = streamResp.Item.ResponsesToolMessage.Name + nameField = "streamResp.Item.ResponsesToolMessage.Name" + } + if streamResp.Item.ResponsesToolMessage.Arguments != nil { + argsField := "streamResp.Item.ResponsesToolMessage.Arguments" + t.Logf("πŸ“Š [%s] Arguments also found in item: %s (len=%d)", chunkType, argsField, len(*streamResp.Item.ResponsesToolMessage.Arguments)) + } + } + + if streamResp.Item.ID != nil { + itemID = streamResp.Item.ID + itemIDField = "streamResp.Item.ID" + } + + // Debug logging: what fields are present in this chunk + hasID := callID != nil && *callID != "" + hasName := name != nil && *name != "" + hasItemID := itemID != nil && *itemID != "" + + t.Logf("πŸ“Š [%s] Chunk fields: ID=%v (%s), Name=%v (%s), ItemID=%v (%s)", + chunkType, hasID, callIDField, hasName, nameField, hasItemID, itemIDField) + + if hasID { + t.Logf(" βœ… ID found in %s: %s", chunkType, *callID) + } + if hasName { + t.Logf(" βœ… Name found in %s: %s", chunkType, *name) + } + if hasItemID { + t.Logf(" βœ… ItemID found in %s: %s", chunkType, *itemID) + } + + // Initialize or update the tool call + accumulator.AccumulateResponsesToolCall(callID, name, nil, itemID) + callIDStr := "" + if callID != nil { + callIDStr = *callID + } + nameStr := "" + if name != nil { + nameStr = *name + } + itemIDStr := "" + if itemID != nil { + itemIDStr = *itemID + } + t.Logf("πŸ”§ Function call item added: callID=%s, name=%s, itemID=%s", + callIDStr, nameStr, itemIDStr) + } + } + + case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDone: + // Function call arguments are complete - use the complete arguments + if streamResp.Arguments != nil { + chunkType := string(streamResp.Type) + var callID *string + var name *string + var itemID *string + callIDField := "" + nameField := "" + itemIDField := "" + argsField := "streamResp.Arguments" + + if streamResp.ItemID != nil { + itemID = streamResp.ItemID + itemIDField = "streamResp.ItemID" + } + + if streamResp.Item != nil && streamResp.Item.ResponsesToolMessage != nil { + if streamResp.Item.ResponsesToolMessage.CallID != nil { + callID = streamResp.Item.ResponsesToolMessage.CallID + callIDField = "streamResp.Item.ResponsesToolMessage.CallID" + } + if streamResp.Item.ResponsesToolMessage.Name != nil { + name = streamResp.Item.ResponsesToolMessage.Name + nameField = "streamResp.Item.ResponsesToolMessage.Name" + } + } + + if streamResp.Item != nil && streamResp.Item.ID != nil { + itemID = streamResp.Item.ID + itemIDField = "streamResp.Item.ID" + } + + // Debug logging: what fields are present in this chunk + hasID := callID != nil && *callID != "" + hasName := name != nil && *name != "" + hasArgs := streamResp.Arguments != nil && *streamResp.Arguments != "" + hasItemID := itemID != nil && *itemID != "" + + t.Logf("πŸ“Š [%s] Chunk fields: ID=%v (%s), Name=%v (%s), Args=%v (%s, len=%d), ItemID=%v (%s)", + chunkType, hasID, callIDField, hasName, nameField, hasArgs, argsField, len(*streamResp.Arguments), hasItemID, itemIDField) + + if hasID { + t.Logf(" βœ… ID found in %s: %s", chunkType, *callID) + } + if hasName { + t.Logf(" βœ… Name found in %s: %s", chunkType, *name) + } + if hasArgs { + t.Logf(" βœ… Complete Arguments found in %s: %s", chunkType, *streamResp.Arguments) + } + if hasItemID { + t.Logf(" βœ… ItemID found in %s: %s", chunkType, *itemID) + } + + // Use the complete arguments from the done event + accumulator.AccumulateResponsesToolCall(callID, name, streamResp.Arguments, itemID) + callIDStr := "" + if callID != nil { + callIDStr = *callID + } + nameStr := "" + if name != nil { + nameStr = *name + } + itemIDStr := "" + if itemID != nil { + itemIDStr = *itemID + } + t.Logf("πŸ”§ Function call arguments done: callID=%s, name=%s, itemID=%s, complete_args=%s", + callIDStr, nameStr, itemIDStr, *streamResp.Arguments) + } + } + } + + if responseCount > 500 { + break + } + } + + if streamError { + continue + } + + if responseCount == 0 { + if attempt == maxAttempts { + t.Fatal("Should receive at least one streaming response") + } + lastErrorMsg = "no streaming responses received" + continue + } + + // Validate final tool calls + finalToolCalls := accumulator.GetFinalResponsesToolCalls() + + // Check if validation passes + validationPassed := true + if len(finalToolCalls) == 0 { + validationPassed = false + lastErrorMsg = "no tool calls found in streaming response" + } else { + for i, toolCall := range finalToolCalls { + if toolCall.ID == "" || toolCall.Name == "" || toolCall.Arguments == "" { + validationPassed = false + lastErrorMsg = fmt.Sprintf("tool call %d missing required fields: ID=%v, Name=%v, Arguments=%v", + i, toolCall.ID != "", toolCall.Name != "", toolCall.Arguments != "") + break + } + } + } + + if validationPassed { + validateStreamingToolCalls(t, finalToolCalls, "Responses API") + t.Logf("βœ… Responses API streaming with tools test completed successfully") + return + } + + // Validation failed, retry if we have attempts left + if attempt < maxAttempts { + t.Logf("⚠️ Validation failed on attempt %d: %s", attempt, lastErrorMsg) + continue + } + } + + // All retries failed + if lastErrorMsg != "" { + t.Fatalf("❌ Responses API streaming with tools test failed after %d attempts: %s", maxAttempts, lastErrorMsg) + } + t.Fatalf("❌ Responses API streaming with tools test failed after %d attempts", maxAttempts) + }) +} + +// validateStreamingToolCalls validates that all tool calls have ID, name, and arguments +func validateStreamingToolCalls(t *testing.T, toolCalls []ToolCallInfo, apiName string) { + if len(toolCalls) == 0 { + t.Fatalf("❌ %s: No tool calls found in streaming response", apiName) + } + + t.Logf("πŸ“Š %s: Found %d tool call(s) in streaming response", apiName, len(toolCalls)) + + for i, toolCall := range toolCalls { + // Validate ID + if toolCall.ID == "" { + t.Errorf("❌ %s: Tool call %d missing ID", apiName, i) + } else { + t.Logf("βœ… %s: Tool call %d has ID: %s", apiName, i, toolCall.ID) + } + + // Validate name + if toolCall.Name == "" { + t.Errorf("❌ %s: Tool call %d missing name", apiName, i) + } else { + t.Logf("βœ… %s: Tool call %d has name: %s", apiName, i, toolCall.Name) + } + + // Validate arguments + if toolCall.Arguments == "" { + t.Errorf("❌ %s: Tool call %d missing arguments", apiName, i) + } else { + // Try to parse arguments as JSON to ensure they're valid + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Arguments), &args); err != nil { + t.Logf("⚠️ %s: Tool call %d arguments are not valid JSON: %v", apiName, i, err) + // Don't fail on this - some providers might send partial JSON during streaming + // But we should at least have some content + if strings.TrimSpace(toolCall.Arguments) == "" { + t.Errorf("❌ %s: Tool call %d has empty arguments", apiName, i) + } + } else { + t.Logf("βœ… %s: Tool call %d has valid JSON arguments: %s", apiName, i, toolCall.Arguments) + } + } + + // All three must be present for the test to pass + require.NotEmpty(t, toolCall.ID, "%s: Tool call %d must have an ID", apiName, i) + require.NotEmpty(t, toolCall.Name, "%s: Tool call %d must have a name", apiName, i) + require.NotEmpty(t, toolCall.Arguments, "%s: Tool call %d must have arguments", apiName, i) + } + + t.Logf("βœ… %s: All tool calls have ID, name, and arguments present", apiName) +} diff --git a/tests/core-providers/scenarios/transcription.go b/tests/core-providers/scenarios/transcription.go new file mode 100644 index 000000000..f89024016 --- /dev/null +++ b/tests/core-providers/scenarios/transcription.go @@ -0,0 +1,361 @@ +package scenarios + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunTranscriptionTest executes the transcription test scenario +func RunTranscriptionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.Transcription { + t.Logf("Transcription not supported for provider %s", testConfig.Provider) + return + } + + t.Run("Transcription", func(t *testing.T) { + // First generate TTS audio for round-trip validation + roundTripCases := []struct { + name string + text string + voiceType string + format string + responseFormat *string + }{ + { + name: "RoundTrip_Basic_MP3", + text: TTSTestTextBasic, + voiceType: "primary", + format: "mp3", + responseFormat: bifrost.Ptr("json"), + }, + { + name: "RoundTrip_Medium_MP3", + text: TTSTestTextMedium, + voiceType: "secondary", + format: "mp3", + responseFormat: bifrost.Ptr("json"), + }, + { + name: "RoundTrip_Technical_MP3", + text: TTSTestTextTechnical, + voiceType: "tertiary", + format: "mp3", + responseFormat: bifrost.Ptr("json"), + }, + } + + for _, tc := range roundTripCases { + t.Run(tc.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Step 1: Generate TTS audio + voice := GetProviderVoice(testConfig.Provider, tc.voiceType) + ttsRequest := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: tc.text, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: tc.format, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + ttsResponse, err := client.SpeechRequest(ctx, ttsRequest) + RequireNoError(t, err, "TTS generation failed for round-trip test") + if ttsResponse == nil || len(ttsResponse.Audio) == 0 { + t.Fatal("TTS returned invalid or empty audio for round-trip test") + } + + // Save temp audio file + tempDir := os.TempDir() + audioFileName := filepath.Join(tempDir, "roundtrip_"+tc.name+"."+tc.format) + writeErr := os.WriteFile(audioFileName, ttsResponse.Audio, 0644) + require.NoError(t, writeErr, "Failed to save temp audio file") + + // Register cleanup + t.Cleanup(func() { + os.Remove(audioFileName) + }) + + t.Logf("Generated TTS audio for round-trip: %s (%d bytes)", audioFileName, len(ttsResponse.Audio)) + + // Step 2: Transcribe the generated audio + transcriptionRequest := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: ttsResponse.Audio, + }, + Params: &schemas.TranscriptionParameters{ + Language: bifrost.Ptr("en"), + Format: bifrost.Ptr("mp3"), + ResponseFormat: tc.responseFormat, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + // Enhanced validation for transcription + expectations := TranscriptionExpectations(10) // Expect at least some content + expectations = ModifyExpectationsForProvider(expectations, testConfig.Provider) + + transcriptionResponse, bifrostErr := client.TranscriptionRequest(ctx, transcriptionRequest) + if bifrostErr != nil { + t.Fatalf("❌ Transcription_RoundTrip_"+tc.name+" request failed: %v", GetErrorMessage(bifrostErr)) + } + + // Validate using the new validation framework + result := ValidateTranscriptionResponse(t, transcriptionResponse, bifrostErr, expectations, "Transcription_RoundTrip_"+tc.name) + if !result.Passed { + t.Fatalf("❌ Transcription validation failed: %v", result.Errors) + } + + // Validate round-trip transcription (complementary to main validation) + validateTranscriptionRoundTrip(t, transcriptionResponse, tc.text, tc.name, testConfig) + }) + } + + // Additional test cases using the utility function for edge cases + t.Run("AdditionalAudioTests", func(t *testing.T) { + // Test with custom generated audio for specific scenarios + customCases := []struct { + name string + text string + language *string + responseFormat *string + }{ + { + name: "Numbers_And_Punctuation", + text: "Testing numbers 1, 2, 3 and punctuation marks! Question?", + language: bifrost.Ptr("en"), + responseFormat: bifrost.Ptr("json"), + }, + { + name: "Technical_Terms", + text: "API gateway processes HTTP requests with JSON payloads", + language: bifrost.Ptr("en"), + responseFormat: bifrost.Ptr("json"), + }, + } + + for _, tc := range customCases { + t.Run(tc.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Use the utility function to generate audio + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, tc.text, "primary", "mp3") + + // Test transcription + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: tc.language, + Format: bifrost.Ptr("mp3"), + ResponseFormat: tc.responseFormat, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + response, err := client.TranscriptionRequest(ctx, request) + require.Nilf(t, err, "Custom transcription failed: %v", err) + require.NotNil(t, response, "Custom transcription returned nil response") + assert.NotEmpty(t, response.Text) + + t.Logf("βœ… Custom transcription successful: '%s' β†’ '%s'", tc.text, response.Text) + }) + } + }) + }) +} + +// RunTranscriptionAdvancedTest executes advanced transcription test scenarios +func RunTranscriptionAdvancedTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.Transcription { + t.Logf("Transcription not supported for provider %s", testConfig.Provider) + return + } + + t.Run("TranscriptionAdvanced", func(t *testing.T) { + t.Run("AllResponseFormats", func(t *testing.T) { + // Generate audio first for all format tests + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, TTSTestTextBasic, "primary", "mp3") + + // Test supported response formats (excluding text to avoid JSON parsing issues) + formats := []string{"json"} + + for _, format := range formats { + t.Run("Format_"+format, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + formatCopy := format + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Format: bifrost.Ptr("mp3"), + ResponseFormat: &formatCopy, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + response, err := client.TranscriptionRequest(ctx, request) + require.Nilf(t, err, "Transcription failed for format %s: %v", format, err) + require.NotNil(t, response, "Transcription returned nil response for format %s", format) + + // All formats should return some text + assert.NotEmpty(t, response.Text) + + t.Logf("βœ… Format %s successful: '%s'", format, response.Text) + }) + } + }) + + t.Run("WithCustomParameters", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Generate audio for custom parameters test + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, TTSTestTextMedium, "secondary", "mp3") + + // Test with custom parameters and temperature + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: bifrost.Ptr("en"), + Format: bifrost.Ptr("mp3"), + Prompt: bifrost.Ptr("This audio contains technical terminology and proper nouns."), + ResponseFormat: bifrost.Ptr("json"), // Use json instead of verbose_json for whisper-1 + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + response, err := client.TranscriptionRequest(ctx, request) + require.Nilf(t, err, "Advanced transcription failed: %v", err) + require.NotNil(t, response, "Advanced transcription returned nil response") + assert.NotEmpty(t, response.Text) + + t.Logf("βœ… Advanced transcription successful: '%s'", response.Text) + }) + + t.Run("MultipleLanguages", func(t *testing.T) { + // Generate audio for language tests + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, TTSTestTextBasic, "primary", "mp3") + + // Test with different language hints (only English for now since our TTS is English) + languages := []string{"en"} + + for _, lang := range languages { + t.Run("Language_"+lang, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + langCopy := lang + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Format: bifrost.Ptr("mp3"), + Language: &langCopy, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + response, err := client.TranscriptionRequest(ctx, request) + require.Nilf(t, err, "Transcription failed for language %s: %v", lang, err) + require.NotNil(t, response, "Transcription returned nil response for language %s", lang) + assert.NotEmpty(t, response.Text) + t.Logf("βœ… Language %s transcription successful: '%s'", lang, response.Text) + }) + } + }) + }) +} + +// validateTranscriptionRoundTrip performs round-trip validation for transcription responses +// This is complementary to the main validation framework and focuses on transcription accuracy +func validateTranscriptionRoundTrip(t *testing.T, response *schemas.BifrostTranscriptionResponse, originalText string, testName string, testConfig config.ComprehensiveTestConfig) { + if response == nil || response.Text == "" { + t.Fatal("Transcription response missing transcribed text") + } + + transcribedText := response.Text + + // Normalize for comparison (lowercase, remove punctuation) + originalWords := strings.Fields(strings.ToLower(originalText)) + transcribedWords := strings.Fields(strings.ToLower(transcribedText)) + + // Check that at least 50% of original words are found in transcription + foundWords := 0 + for _, originalWord := range originalWords { + // Remove punctuation for comparison + cleanOriginal := strings.Trim(originalWord, ".,!?;:") + if len(cleanOriginal) < 3 { // Skip very short words + continue + } + + for _, transcribedWord := range transcribedWords { + cleanTranscribed := strings.Trim(transcribedWord, ".,!?;:") + if strings.Contains(cleanTranscribed, cleanOriginal) || strings.Contains(cleanOriginal, cleanTranscribed) { + foundWords++ + break + } + } + } + + // Expect at least 50% word match for successful round-trip + minExpectedWords := len(originalWords) / 2 + if foundWords < minExpectedWords { + t.Logf("⚠️ Round-trip validation concern:") + t.Logf(" Original: '%s'", originalText) + t.Logf(" Transcribed: '%s'", transcribedText) + t.Logf(" Found %d/%d words (%.1f%%), expected β‰₯ %d (50%%)", + foundWords, len(originalWords), float64(foundWords)/float64(len(originalWords))*100, minExpectedWords) + // Note: Not failing test as this can be provider/model dependent + } else { + t.Logf("βœ… Round-trip validation passed: found %d/%d words (%.1f%%)", + foundWords, len(originalWords), float64(foundWords)/float64(len(originalWords))*100) + } + + // Check provider field + if response.ExtraFields.Provider != testConfig.Provider { + t.Logf("⚠️ Provider mismatch: expected %s, got %s", testConfig.Provider, response.ExtraFields.Provider) + } + + t.Logf("Round-trip test '%s' completed successfully", testName) +} diff --git a/tests/core-providers/scenarios/transcription_stream.go b/tests/core-providers/scenarios/transcription_stream.go new file mode 100644 index 000000000..f1e5fd747 --- /dev/null +++ b/tests/core-providers/scenarios/transcription_stream.go @@ -0,0 +1,574 @@ +package scenarios + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// RunTranscriptionStreamTest executes the streaming transcription test scenario +func RunTranscriptionStreamTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.TranscriptionStream { + t.Logf("Transcription streaming not supported for provider %s", testConfig.Provider) + return + } + + t.Run("TranscriptionStream", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Generate TTS audio for streaming round-trip validation + streamRoundTripCases := []struct { + name string + text string + voiceType string + format string + responseFormat *string + }{ + { + name: "StreamRoundTrip_Basic_MP3", + text: TTSTestTextBasic, + voiceType: "primary", + format: "mp3", + responseFormat: nil, // Default JSON streaming + }, + { + name: "StreamRoundTrip_Medium_MP3", + text: TTSTestTextMedium, + voiceType: "secondary", + format: "mp3", + responseFormat: bifrost.Ptr("json"), + }, + { + name: "StreamRoundTrip_Technical_MP3", + text: TTSTestTextTechnical, + voiceType: "tertiary", + format: "mp3", + responseFormat: bifrost.Ptr("json"), + }, + } + + for _, tc := range streamRoundTripCases { + t.Run(tc.name, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Step 1: Generate TTS audio + voice := GetProviderVoice(testConfig.Provider, tc.voiceType) + ttsRequest := &schemas.BifrostSpeechRequest{ + Provider: testConfig.Provider, + Model: testConfig.SpeechSynthesisModel, + Input: &schemas.SpeechInput{ + Input: tc.text, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: tc.format, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + ttsResponse, err := client.SpeechRequest(ctx, ttsRequest) + RequireNoError(t, err, "TTS generation failed for stream round-trip test") + if ttsResponse == nil || len(ttsResponse.Audio) == 0 { + t.Fatal("TTS returned invalid or empty audio for stream round-trip test") + } + + // Save temp audio file + tempDir := os.TempDir() + audioFileName := filepath.Join(tempDir, "stream_roundtrip_"+tc.name+"."+tc.format) + writeErr := os.WriteFile(audioFileName, ttsResponse.Audio, 0644) + if writeErr != nil { + t.Fatalf("Failed to save temp audio file: %v", writeErr) + } + + // Register cleanup + t.Cleanup(func() { + os.Remove(audioFileName) + }) + + t.Logf("Generated TTS audio for stream round-trip: %s (%d bytes)", audioFileName, len(ttsResponse.Audio)) + + // Step 2: Test streaming transcription + streamRequest := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: ttsResponse.Audio, + }, + Params: &schemas.TranscriptionParameters{ + Language: bifrost.Ptr("en"), + Format: bifrost.Ptr(tc.format), + ResponseFormat: tc.responseFormat, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + // Use retry framework for streaming transcription + retryConfig := GetTestRetryConfigForScenario("TranscriptionStream", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "TranscriptionStream_" + tc.name, + ExpectedBehavior: map[string]interface{}{ + "transcribe_streaming_audio": true, + "round_trip_test": true, + "original_text": tc.text, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.TranscriptionModel, + "audio_format": tc.format, + "voice_type": tc.voiceType, + }, + } + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.TranscriptionStreamRequest(ctx, streamRequest) + }) + + RequireNoError(t, err, "Transcription stream initiation failed") + if responseChannel == nil { + t.Fatal("Response channel should not be nil") + } + + streamCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + fullTranscriptionText := "" + lastResponse := &schemas.BifrostStream{} + streamErrors := []string{} + lastTokenLatency := int64(0) + + // Read streaming chunks with enhanced validation + for { + select { + case response, ok := <-responseChannel: + if !ok { + // Channel closed, streaming complete + goto streamComplete + } + + if response == nil { + streamErrors = append(streamErrors, "Received nil stream response") + continue + } + + // Check for errors in stream + if response.BifrostError != nil { + streamErrors = append(streamErrors, FormatErrorConcise(ParseBifrostError(response.BifrostError))) + continue + } + + if response.BifrostTranscriptionStreamResponse == nil { + streamErrors = append(streamErrors, "Stream response missing transcription stream payload") + continue + } + + if response.BifrostTranscriptionStreamResponse != nil { + lastTokenLatency = response.BifrostTranscriptionStreamResponse.ExtraFields.Latency + } + + if response.BifrostTranscriptionStreamResponse.Text == "" && response.BifrostTranscriptionStreamResponse.Delta == nil { + streamErrors = append(streamErrors, "Stream response missing transcription data") + continue + } + + chunkIndex := response.BifrostTranscriptionStreamResponse.ExtraFields.ChunkIndex + + // Log latency for each chunk (can be 0 for inter-chunks) + t.Logf("πŸ“Š Transcription chunk %d latency: %d ms", chunkIndex, response.BifrostTranscriptionStreamResponse.ExtraFields.Latency) + + // Collect transcription chunks + transcribeData := response.BifrostTranscriptionStreamResponse + if transcribeData.Text != "" { + t.Logf("βœ… Received transcription text chunk %d with latency %d ms: '%s'", chunkIndex, response.BifrostTranscriptionStreamResponse.ExtraFields.Latency, transcribeData.Text) + } + + // Handle delta vs complete text chunks + if transcribeData.Delta != nil { + // This is a delta chunk + deltaText := *transcribeData.Delta + fullTranscriptionText += deltaText + t.Logf("βœ… Received transcription delta chunk %d with latency %d ms: '%s'", chunkIndex, response.BifrostTranscriptionStreamResponse.ExtraFields.Latency, deltaText) + } + + // Validate chunk structure + if response.BifrostTranscriptionStreamResponse.Type != schemas.TranscriptionStreamResponseTypeDelta { + t.Logf("⚠️ Unexpected object type in stream: %s", response.BifrostTranscriptionStreamResponse.Type) + } + if response.BifrostTranscriptionStreamResponse.ExtraFields.ModelRequested != "" && response.BifrostTranscriptionStreamResponse.ExtraFields.ModelRequested != testConfig.TranscriptionModel { + t.Logf("⚠️ Unexpected model in stream: %s", response.BifrostTranscriptionStreamResponse.ExtraFields.ModelRequested) + } + + lastResponse = DeepCopyBifrostStream(response) + + case <-streamCtx.Done(): + streamErrors = append(streamErrors, "Stream reading timed out") + goto streamComplete + } + } + + streamComplete: + // Enhanced validation of streaming results + if len(streamErrors) > 0 { + t.Logf("⚠️ Stream errors encountered: %v", streamErrors) + } + + if lastResponse == nil { + t.Fatal("Should have received at least one response") + } + + if fullTranscriptionText == "" { + t.Fatal("Transcribed text should not be empty") + } + + if lastTokenLatency == 0 { + t.Errorf("❌ Last token latency is 0") + } + + // Normalize for comparison (lowercase, remove punctuation) + originalWords := strings.Fields(strings.ToLower(tc.text)) + transcribedWords := strings.Fields(strings.ToLower(fullTranscriptionText)) + + // Check that at least 50% of original words are found in transcription + foundWords := 0 + for _, originalWord := range originalWords { + // Remove punctuation for comparison + cleanOriginal := strings.Trim(originalWord, ".,!?;:") + if len(cleanOriginal) < 3 { // Skip very short words + continue + } + + for _, transcribedWord := range transcribedWords { + cleanTranscribed := strings.Trim(transcribedWord, ".,!?;:") + if strings.Contains(cleanTranscribed, cleanOriginal) || strings.Contains(cleanOriginal, cleanTranscribed) { + foundWords++ + break + } + } + } + + // Enhanced round-trip validation with better error reporting + minExpectedWords := len(originalWords) / 2 + if foundWords < minExpectedWords { + t.Logf("❌ Stream round-trip validation failed:") + t.Logf(" Original: '%s'", tc.text) + t.Logf(" Transcribed: '%s'", fullTranscriptionText) + t.Logf(" Found %d/%d words (expected at least %d)", foundWords, len(originalWords), minExpectedWords) + + // Log word-by-word comparison for debugging + t.Logf(" Word comparison:") + for i, word := range originalWords { + if i < 5 { // Show first 5 words + cleanWord := strings.Trim(word, ".,!?;:") + if len(cleanWord) >= 3 { + found := false + for _, transcribed := range transcribedWords { + if strings.Contains(strings.ToLower(transcribed), cleanWord) { + found = true + break + } + } + status := "❌" + if found { + status = "βœ…" + } + t.Logf(" %s '%s'", status, cleanWord) + } + } + } + t.Fatalf("Round-trip accuracy too low: got %d/%d words, need at least %d", foundWords, len(originalWords), minExpectedWords) + } + }) + } + }) +} + +// RunTranscriptionStreamAdvancedTest executes advanced streaming transcription test scenarios +func RunTranscriptionStreamAdvancedTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if !testConfig.Scenarios.TranscriptionStream { + t.Logf("Transcription streaming not supported for provider %s", testConfig.Provider) + return + } + + t.Run("TranscriptionStreamAdvanced", func(t *testing.T) { + t.Run("JSONStreaming", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Generate audio for streaming test + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, TTSTestTextBasic, "primary", "mp3") + + // Test streaming with JSON format + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: bifrost.Ptr("en"), + Format: bifrost.Ptr("mp3"), + ResponseFormat: bifrost.Ptr("json"), + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + retryConfig := GetTestRetryConfigForScenario("TranscriptionStreamJSON", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "TranscriptionStream_JSON", + ExpectedBehavior: map[string]interface{}{ + "transcribe_streaming_audio": true, + "json_format": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.TranscriptionModel, + "format": "json", + }, + } + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.TranscriptionStreamRequest(ctx, request) + }) + + RequireNoError(t, err, "JSON streaming failed") + + var receivedResponse bool + var streamErrors []string + + for response := range responseChannel { + if response == nil { + streamErrors = append(streamErrors, "Received nil JSON stream response") + continue + } + + if response.BifrostError != nil { + streamErrors = append(streamErrors, FormatErrorConcise(ParseBifrostError(response.BifrostError))) + continue + } + + if response.BifrostTranscriptionStreamResponse != nil { + receivedResponse = true + + // Check for JSON streaming specific fields + transcribeData := response.BifrostTranscriptionStreamResponse + if transcribeData.Type != "" { + t.Logf("βœ… Stream type: %v", transcribeData.Type) + if transcribeData.Delta != nil { + t.Logf("βœ… Delta: %s", *transcribeData.Delta) + } + } + + if transcribeData.Text != "" { + t.Logf("βœ… Received transcription text: %s", transcribeData.Text) + } + } + } + + if len(streamErrors) > 0 { + t.Logf("⚠️ JSON stream errors: %v", streamErrors) + } + + if !receivedResponse { + t.Fatal("Should receive at least one response") + } + t.Logf("βœ… Verbose JSON streaming successful") + }) + + t.Run("MultipleLanguages_Streaming", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Generate audio for language streaming tests + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, TTSTestTextBasic, "primary", "mp3") + // Test streaming with different language hints (only English for now) + languages := []string{"en"} + + for _, lang := range languages { + t.Run("StreamLang_"+lang, func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + langCopy := lang + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: &langCopy, + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + retryConfig := GetTestRetryConfigForScenario("TranscriptionStreamLang", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "TranscriptionStream_Lang_" + lang, + ExpectedBehavior: map[string]interface{}{ + "transcribe_streaming_audio": true, + "language": lang, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "language": lang, + }, + } + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.TranscriptionStreamRequest(ctx, request) + }) + + RequireNoError(t, err, fmt.Sprintf("Streaming failed for language %s", lang)) + + var receivedData bool + var streamErrors []string + var lastTokenLatency int64 + + for response := range responseChannel { + if response == nil { + streamErrors = append(streamErrors, fmt.Sprintf("Received nil stream response for language %s", lang)) + continue + } + + if response.BifrostError != nil { + streamErrors = append(streamErrors, fmt.Sprintf("Error in stream for language %s: %s", lang, FormatErrorConcise(ParseBifrostError(response.BifrostError)))) + continue + } + + if response.BifrostTranscriptionStreamResponse != nil { + receivedData = true + t.Logf("βœ… Received transcription data for language %s", lang) + if response.BifrostTranscriptionStreamResponse != nil { + lastTokenLatency = response.BifrostTranscriptionStreamResponse.ExtraFields.Latency + } + } + } + + if len(streamErrors) > 0 { + t.Logf("⚠️ Stream errors for language %s: %v", lang, streamErrors) + } + + if !receivedData { + t.Fatalf("Should receive transcription data for language %s", lang) + } + + if lastTokenLatency == 0 { + t.Errorf("❌ Last token latency is 0") + } + + t.Logf("βœ… Streaming successful for language: %s", lang) + }) + } + }) + + t.Run("WithCustomPrompt_Streaming", func(t *testing.T) { + if os.Getenv("SKIP_PARALLEL_TESTS") != "true" { + t.Parallel() + } + + // Generate audio for custom prompt streaming test + audioData, _ := GenerateTTSAudioForTest(ctx, t, client, testConfig.Provider, testConfig.SpeechSynthesisModel, TTSTestTextTechnical, "tertiary", "mp3") + + // Test streaming with custom prompt for context + request := &schemas.BifrostTranscriptionRequest{ + Provider: testConfig.Provider, + Model: testConfig.TranscriptionModel, + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: bifrost.Ptr("en"), + Prompt: bifrost.Ptr("This audio contains technical terms, proper nouns, and streaming-related vocabulary."), + }, + Fallbacks: testConfig.TranscriptionFallbacks, + } + + retryConfig := GetTestRetryConfigForScenario("TranscriptionStreamPrompt", testConfig) + retryContext := TestRetryContext{ + ScenarioName: "TranscriptionStream_CustomPrompt", + ExpectedBehavior: map[string]interface{}{ + "transcribe_streaming_audio": true, + "custom_prompt": true, + "technical_content": true, + }, + TestMetadata: map[string]interface{}{ + "provider": testConfig.Provider, + "model": testConfig.TranscriptionModel, + "has_prompt": true, + }, + } + + responseChannel, err := WithStreamRetry(t, retryConfig, retryContext, func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return client.TranscriptionStreamRequest(ctx, request) + }) + + RequireNoError(t, err, "Custom prompt streaming failed") + + var chunkCount int + var streamErrors []string + var receivedText string + var lastTokenLatency int64 + + for response := range responseChannel { + if response == nil { + streamErrors = append(streamErrors, "Received nil stream response with custom prompt") + continue + } + + if response.BifrostError != nil { + streamErrors = append(streamErrors, FormatErrorConcise(ParseBifrostError(response.BifrostError))) + continue + } + + if response.BifrostTranscriptionStreamResponse != nil { + lastTokenLatency = response.BifrostTranscriptionStreamResponse.ExtraFields.Latency + } + + if response.BifrostTranscriptionStreamResponse != nil && response.BifrostTranscriptionStreamResponse.Text != "" { + chunkCount++ + chunkText := response.BifrostTranscriptionStreamResponse.Text + receivedText += chunkText + t.Logf("βœ… Custom prompt chunk %d: '%s'", chunkCount, chunkText) + } + } + + if len(streamErrors) > 0 { + t.Logf("⚠️ Custom prompt stream errors: %v", streamErrors) + } + + if chunkCount == 0 { + t.Fatal("Should receive at least one transcription chunk") + } + + // Additional validation for custom prompt effectiveness + if receivedText != "" { + t.Logf("βœ… Custom prompt produced transcription: '%s'", receivedText) + } else { + t.Logf("⚠️ Custom prompt produced empty transcription") + } + + if lastTokenLatency == 0 { + t.Errorf("❌ Last token latency is 0") + } + + t.Logf("βœ… Custom prompt streaming successful: %d chunks received", chunkCount) + }) + }) +} diff --git a/tests/core-providers/scenarios/utils.go b/tests/core-providers/scenarios/utils.go new file mode 100644 index 000000000..6a91a01d4 --- /dev/null +++ b/tests/core-providers/scenarios/utils.go @@ -0,0 +1,574 @@ +package scenarios + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" +) + +// Shared test texts for TTS->SST round-trip validation +const ( + // Basic test text for simple round-trip validation + TTSTestTextBasic = "Hello, this is a comprehensive test of speech synthesis capabilities from Bifrost AI Gateway. We are testing various aspects of text-to-speech conversion including clarity, pronunciation, and overall audio quality. This basic test should demonstrate the fundamental functionality of converting written text into natural-sounding speech audio." + + // Medium length text with punctuation for comprehensive testing + TTSTestTextMedium = "Testing speech synthesis and transcription round-trip functionality with Bifrost AI Gateway. This comprehensive text includes various punctuation marks: commas, periods, exclamation points! Question marks? Semicolons; and colons: for thorough testing. We also include numbers like 123, 456.789, and technical terms such as API, HTTP, JSON, WebSocket, and machine learning algorithms. The system should handle abbreviations like Dr., Mr., Mrs., and acronyms like NASA, FBI, and CPU correctly. Additionally, we test special characters and symbols: @, #, $, %, &, *, +, =, and various currency symbols like €, Β£, Β₯." + + // Technical text for comprehensive format testing + TTSTestTextTechnical = "Bifrost AI Gateway is a sophisticated artificial intelligence proxy server that efficiently processes and routes audio requests, chat completions, embeddings, and various machine learning workloads across multiple provider endpoints. The system implements advanced load balancing algorithms, request queuing mechanisms, and intelligent failover strategies to ensure high availability and optimal performance. It supports multiple audio formats including MP3, WAV, FLAC, and OGG, with configurable bitrates, sample rates, and encoding parameters. The gateway handles authentication, rate limiting, request validation, response transformation, and comprehensive logging for enterprise-grade deployments. Performance metrics indicate sub-100ms latency for most operations with 99.9% uptime reliability." +) + +// GetProviderVoice returns an appropriate voice for the given provider +func GetProviderVoice(provider schemas.ModelProvider, voiceType string) string { + switch provider { + case schemas.OpenAI: + switch voiceType { + case "primary": + return "alloy" + case "secondary": + return "nova" + case "tertiary": + return "echo" + default: + return "alloy" + } + case schemas.Gemini: + switch voiceType { + case "primary": + return "achernar" + case "secondary": + return "aoede" + case "tertiary": + return "erinome" + default: + return "achernar" + } + default: + // Default to OpenAI voices for other providers + switch voiceType { + case "primary": + return "alloy" + case "secondary": + return "nova" + case "tertiary": + return "echo" + default: + return "alloy" + } + } +} + +type SampleToolType string + +const ( + SampleToolTypeWeather SampleToolType = "weather" + SampleToolTypeCalculate SampleToolType = "calculate" + SampleToolTypeTime SampleToolType = "time" +) + +var SampleToolFunctions = map[SampleToolType]*schemas.ChatToolFunction{ + SampleToolTypeWeather: WeatherToolFunction, + SampleToolTypeCalculate: CalculatorToolFunction, + SampleToolTypeTime: TimeToolFunction, +} + +var sampleToolDescriptions = map[SampleToolType]string{ + SampleToolTypeWeather: "Get the current weather in a given location", + SampleToolTypeCalculate: "Perform basic mathematical calculations", + SampleToolTypeTime: "Get the current time in a specific timezone", +} + +var WeatherToolFunction = &schemas.ChatToolFunction{ + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{ + "location": map[string]interface{}{ + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "unit": map[string]interface{}{ + "type": "string", + "enum": []string{"celsius", "fahrenheit"}, + }, + }, + Required: []string{"location"}, + }, +} + +var CalculatorToolFunction = &schemas.ChatToolFunction{ + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{ + "expression": map[string]interface{}{ + "type": "string", + "description": "The mathematical expression to evaluate, e.g. '2 + 3' or '10 * 5'", + }, + }, + Required: []string{"expression"}, + }, +} + +var TimeToolFunction = &schemas.ChatToolFunction{ + Parameters: &schemas.ToolFunctionParameters{ + Type: "object", + Properties: &map[string]interface{}{ + "timezone": map[string]interface{}{ + "type": "string", + "description": "The timezone identifier, e.g. 'America/New_York' or 'UTC'", + }, + }, + Required: []string{"timezone"}, + }, +} + +func GetSampleChatTool(toolName SampleToolType) *schemas.ChatTool { + function, ok := SampleToolFunctions[toolName] + if !ok { + return nil + } + + description, ok := sampleToolDescriptions[toolName] + if !ok { + return nil + } + + return &schemas.ChatTool{ + Type: "function", + Function: &schemas.ChatToolFunction{ + Name: string(toolName), + Description: bifrost.Ptr(description), + Parameters: function.Parameters, + }, + } +} + +func GetSampleResponsesTool(toolName SampleToolType) *schemas.ResponsesTool { + function, ok := SampleToolFunctions[toolName] + if !ok { + return nil + } + + description, ok := sampleToolDescriptions[toolName] + if !ok { + return nil + } + + return &schemas.ResponsesTool{ + Type: "function", + Name: bifrost.Ptr(string(toolName)), + Description: bifrost.Ptr(description), + ResponsesToolFunction: &schemas.ResponsesToolFunction{ + Parameters: function.Parameters, + }, + } +} + +// Test image of an ant +const TestImageURL = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Carpenter_ant_Tanzania_crop.jpg/1200px-Carpenter_ant_Tanzania_crop.png" + +// Test image of the Eiffel Tower +const TestImageURL2 = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/La_Tour_Eiffel_vue_de_la_Tour_Saint-Jacques%2C_Paris_ao%C3%BBt_2014_%282%29.jpg/960px-La_Tour_Eiffel_vue_de_la_Tour_Saint-Jacques%2C_Paris_ao%C3%BBt_2014_%282%29.png" + +// Test image base64 of a grey solid +const TestImageBase64 = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAIAAoDASIAAhEBAxEB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" + +// GetLionBase64Image loads and returns the lion base64 image data from file +func GetLionBase64Image() (string, error) { + data, err := os.ReadFile("scenarios/media/lion_base64.txt") + if err != nil { + return "", err + } + return "data:image/png;base64," + string(data), nil +} + +// CreateSpeechInput creates a basic speech input for testing +func CreateSpeechRequest(text, voice, format string) *schemas.BifrostSpeechRequest { + return &schemas.BifrostSpeechRequest{ + Input: &schemas.SpeechInput{ + Input: text, + }, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: format, + }, + } +} + +// CreateTranscriptionInput creates a basic transcription input for testing +func CreateTranscriptionInput(audioData []byte, language, responseFormat *string) *schemas.BifrostTranscriptionRequest { + return &schemas.BifrostTranscriptionRequest{ + Input: &schemas.TranscriptionInput{ + File: audioData, + }, + Params: &schemas.TranscriptionParameters{ + Language: language, + ResponseFormat: responseFormat, + }, + } +} + +// Helper functions for creating requests +func CreateBasicChatMessage(content string) schemas.ChatMessage { + return schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr(content), + }, + } +} + +func CreateBasicResponsesMessage(content string) schemas.ResponsesMessage { + return schemas.ResponsesMessage{ + Type: bifrost.Ptr(schemas.ResponsesMessageTypeMessage), + Role: bifrost.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: bifrost.Ptr(content), + }, + } +} + +func CreateImageChatMessage(text, imageURL string) schemas.ChatMessage { + return schemas.ChatMessage{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + {Type: schemas.ChatContentBlockTypeText, Text: bifrost.Ptr(text)}, + {Type: schemas.ChatContentBlockTypeImage, ImageURLStruct: &schemas.ChatInputImage{URL: imageURL}}, + }, + }, + } +} + +func CreateImageResponsesMessage(text, imageURL string) schemas.ResponsesMessage { + return schemas.ResponsesMessage{ + Type: bifrost.Ptr(schemas.ResponsesMessageTypeMessage), + Role: bifrost.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + {Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: bifrost.Ptr(text)}, + {Type: schemas.ResponsesInputMessageContentBlockTypeImage, + ResponsesInputMessageContentBlockImage: &schemas.ResponsesInputMessageContentBlockImage{ + ImageURL: bifrost.Ptr(imageURL), + }, + }, + }, + }, + } +} + +func CreateToolChatMessage(content string, toolCallID string) schemas.ChatMessage { + return schemas.ChatMessage{ + Role: schemas.ChatMessageRoleTool, + Content: &schemas.ChatMessageContent{ + ContentStr: bifrost.Ptr(content), + }, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: bifrost.Ptr(toolCallID), + }, + } +} + +func CreateToolResponsesMessage(content string, toolCallID string) schemas.ResponsesMessage { + return schemas.ResponsesMessage{ + Type: bifrost.Ptr(schemas.ResponsesMessageTypeFunctionCallOutput), + // Note: function_call_output messages don't have a role field per OpenAI API + ResponsesToolMessage: &schemas.ResponsesToolMessage{ + CallID: bifrost.Ptr(toolCallID), + // Set ResponsesFunctionToolCallOutput for OpenAI's native Responses API + Output: &schemas.ResponsesToolMessageOutputStruct{ + ResponsesToolCallOutputStr: bifrost.Ptr(content), + }, + }, + } +} + +// ToolCallInfo represents extracted tool call information for both API formats +type ToolCallInfo struct { + Name string + Arguments string + ID string +} + +// GetChatContent returns the string content from a BifrostChatResponse +func GetChatContent(response *schemas.BifrostChatResponse) string { + if response == nil || response.Choices == nil { + return "" + } + + // Try to find content from any choice, prioritizing non-empty content + for _, choice := range response.Choices { + if choice.Message.Content != nil { + // Check if content has any data (either ContentStr or ContentBlocks) + if choice.Message.Content.ContentStr != nil && *choice.Message.Content.ContentStr != "" { + return *choice.Message.Content.ContentStr + } else if choice.Message.Content.ContentBlocks != nil { + var builder strings.Builder + for _, block := range choice.Message.Content.ContentBlocks { + if block.Text != nil { + builder.WriteString(*block.Text) + } + } + content := builder.String() + if content != "" { + return content + } + } + } + } + + return "" +} + +// GetTextCompletionContent returns the string content from a BifrostTextCompletionResponse +func GetTextCompletionContent(response *schemas.BifrostTextCompletionResponse) string { + if response == nil || response.Choices == nil { + return "" + } + + // Try to find content from any choice, prioritizing non-empty content + for _, choice := range response.Choices { + if choice.Text != nil && *choice.Text != "" { + return *choice.Text + } + } + + return "" +} + +// GetResponsesContent returns the string content from a BifrostResponsesResponse +func GetResponsesContent(response *schemas.BifrostResponsesResponse) string { + if response == nil || response.Output == nil { + return "" + } + + for _, output := range response.Output { + // Check for regular content first + if output.Content != nil { + if output.Content.ContentStr != nil && *output.Content.ContentStr != "" { + return *output.Content.ContentStr + } else if output.Content.ContentBlocks != nil { + var builder strings.Builder + for _, block := range output.Content.ContentBlocks { + if block.Text != nil { + builder.WriteString(*block.Text) + } + } + content := builder.String() + if content != "" { + return content + } + } + } + + // Check for reasoning content in summary field + if output.Type != nil && *output.Type == schemas.ResponsesMessageTypeReasoning { + if output.ResponsesReasoning != nil && output.ResponsesReasoning.Summary != nil { + var builder strings.Builder + for _, summaryBlock := range output.ResponsesReasoning.Summary { + if summaryBlock.Text != "" { + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(summaryBlock.Text) + } + } + content := builder.String() + if content != "" { + return content + } + } + } + } + + return "" +} + +// ExtractChatToolCalls extracts tool call information from a BifrostChatResponse +func ExtractChatToolCalls(response *schemas.BifrostChatResponse) []ToolCallInfo { + var toolCalls []ToolCallInfo + + if response == nil || response.Choices == nil { + return toolCalls + } + + for _, choice := range response.Choices { + if choice.Message.ChatAssistantMessage != nil && choice.Message.ChatAssistantMessage.ToolCalls != nil { + for _, toolCall := range choice.Message.ChatAssistantMessage.ToolCalls { + info := ToolCallInfo{ + ID: *toolCall.ID, + } + if toolCall.Function.Name != nil { + info.Name = *toolCall.Function.Name + } + info.Arguments = toolCall.Function.Arguments + toolCalls = append(toolCalls, info) + } + } + } + + return toolCalls +} + +// ExtractResponsesToolCalls extracts tool call information from a BifrostResponsesResponse +func ExtractResponsesToolCalls(response *schemas.BifrostResponsesResponse) []ToolCallInfo { + var toolCalls []ToolCallInfo + + if response == nil || response.Output == nil { + return toolCalls + } + + for _, output := range response.Output { + if output.Type != nil && *output.Type == schemas.ResponsesMessageTypeFunctionCall && output.ResponsesToolMessage != nil { + info := ToolCallInfo{} + if output.ResponsesToolMessage.Name != nil { + info.Name = *output.ResponsesToolMessage.Name + } + if output.ResponsesToolMessage.Arguments != nil { + info.Arguments = *output.ResponsesToolMessage.Arguments + } + if output.ResponsesToolMessage.CallID != nil { + info.ID = *output.ResponsesToolMessage.CallID + } + toolCalls = append(toolCalls, info) + } + } + + return toolCalls +} + +func GetResultContent(response *schemas.BifrostResponse) string { + if response == nil { + return "" + } + + if response.ChatResponse != nil { + return GetChatContent(response.ChatResponse) + } else if response.ResponsesResponse != nil { + return GetResponsesContent(response.ResponsesResponse) + } else if response.TextCompletionResponse != nil { + return GetTextCompletionContent(response.TextCompletionResponse) + } + return "" +} + +func ExtractToolCalls(response *schemas.BifrostResponse) []ToolCallInfo { + if response == nil { + return []ToolCallInfo{} + } + + if response.ChatResponse != nil { + return ExtractChatToolCalls(response.ChatResponse) + } else if response.ResponsesResponse != nil { + return ExtractResponsesToolCalls(response.ResponsesResponse) + } + return []ToolCallInfo{} +} + +// getEmbeddingVector extracts the float32 vector from a BifrostEmbeddingResponse +func getEmbeddingVector(embedding schemas.EmbeddingData) ([]float32, error) { + + if embedding.Embedding.EmbeddingArray != nil { + return embedding.Embedding.EmbeddingArray, nil + } + + if embedding.Embedding.Embedding2DArray != nil { + // For 2D arrays, return the first vector + if len(embedding.Embedding.Embedding2DArray) > 0 { + return embedding.Embedding.Embedding2DArray[0], nil + } + return nil, fmt.Errorf("2D embedding array is empty") + } + + if embedding.Embedding.EmbeddingStr != nil { + return nil, fmt.Errorf("string embeddings not supported for vector extraction") + } + + return nil, fmt.Errorf("no valid embedding data found") +} + +// --- Additional test helpers appended below (imported on demand) --- + +// NOTE: importing context, os, testing only in this block to avoid breaking existing imports. +// We duplicate types by fully qualifying to not touch import list above. + +// GenerateTTSAudioForTest generates real audio using TTS and writes a temp file. +// Returns audio bytes and temp filepath. Caller’s t will clean it up. +func GenerateTTSAudioForTest(ctx context.Context, t *testing.T, client *bifrost.Bifrost, provider schemas.ModelProvider, ttsModel string, text string, voiceType string, format string) ([]byte, string) { + // inline import guard comment: context/testing/os are required at call sites; Go compiler will include them. + voice := GetProviderVoice(provider, voiceType) + if voice == "" { + voice = GetProviderVoice(provider, "primary") + } + if format == "" { + format = "mp3" + } + + req := &schemas.BifrostSpeechRequest{ + Provider: provider, + Model: ttsModel, + Input: &schemas.SpeechInput{Input: text}, + Params: &schemas.SpeechParameters{ + VoiceConfig: &schemas.SpeechVoiceInput{ + Voice: &voice, + }, + ResponseFormat: format, + }, + } + + resp, err := client.SpeechRequest(ctx, req) + if err != nil { + t.Fatalf("TTS request failed: %v", err) + } + if resp == nil || resp.Audio == nil || len(resp.Audio) == 0 { + t.Fatalf("TTS response missing audio data") + } + + suffix := "." + format + f, cerr := os.CreateTemp("", "bifrost-tts-*"+suffix) + if cerr != nil { + t.Fatalf("failed to create temp audio file: %v", cerr) + } + tempPath := f.Name() + if _, werr := f.Write(resp.Audio); werr != nil { + _ = f.Close() + t.Fatalf("failed to write temp audio file: %v", werr) + } + _ = f.Close() + + t.Cleanup(func() { _ = os.Remove(tempPath) }) + + return resp.Audio, tempPath +} + +func GetErrorMessage(err *schemas.BifrostError) string { + if err == nil { + return "" + } + + errorType := "" + if err.Type != nil && *err.Type != "" { + errorType = *err.Type + } + + if errorType == "" && err.Error.Type != nil && *err.Error.Type != "" { + errorType = *err.Error.Type + } + + errorCode := "" + if err.Error.Code != nil && *err.Error.Code != "" { + errorCode = *err.Error.Code + } + + errorMessage := err.Error.Message + + errorString := fmt.Sprintf("%s %s: %s", errorType, errorCode, errorMessage) + + return errorString +} diff --git a/tests/core-providers/scenarios/validation_presets.go b/tests/core-providers/scenarios/validation_presets.go new file mode 100644 index 000000000..b37acde0c --- /dev/null +++ b/tests/core-providers/scenarios/validation_presets.go @@ -0,0 +1,466 @@ +package scenarios + +import ( + "regexp" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +// ============================================================================= +// PRESET VALIDATION EXPECTATIONS FOR COMMON SCENARIOS +// ============================================================================= + +// BasicChatExpectations returns validation expectations for basic chat scenarios +func BasicChatExpectations() ResponseExpectations { + return ResponseExpectations{ + ShouldHaveContent: true, + MinContentLength: 5, // At least a few characters + MaxContentLength: 2000, // Reasonable upper bound + ExpectedChoiceCount: 1, // Usually expect one choice, will be used on outputs for responses API + ShouldHaveUsageStats: true, + ShouldHaveTimestamps: true, + ShouldHaveModel: true, + ShouldHaveLatency: true, // Global expectation: latency should always be present + ShouldNotContainWords: []string{ + "i can't", "i cannot", "i'm unable", "i am unable", + "i don't know", "i'm not sure", "i am not sure", + }, + } +} + +// ToolCallExpectations returns validation expectations for tool calling scenarios +func ToolCallExpectations(toolName string, requiredArgs []string) ResponseExpectations { + expectations := BasicChatExpectations() + expectations.ExpectedToolCalls = []ToolCallExpectation{ + { + FunctionName: toolName, + RequiredArgs: requiredArgs, + ValidateArgsJSON: true, + }, + } + // Tool calls might not have text content + expectations.ShouldHaveContent = false + expectations.MinContentLength = 0 + + return expectations +} + +// WeatherToolExpectations returns validation expectations for weather tool calls +func WeatherToolExpectations() ResponseExpectations { + return ToolCallExpectations(string(SampleToolTypeWeather), []string{"location"}) +} + +// CalculatorToolExpectations returns validation expectations for calculator tool calls +func CalculatorToolExpectations() ResponseExpectations { + return ToolCallExpectations(string(SampleToolTypeCalculate), []string{"expression"}) +} + +// TimeToolExpectations returns validation expectations for time tool calls +func TimeToolExpectations() ResponseExpectations { + return ToolCallExpectations(string(SampleToolTypeTime), []string{"timezone"}) +} + +// MultipleToolExpectations returns validation expectations for multiple tool calls +func MultipleToolExpectations(tools []string, requiredArgsPerTool [][]string) ResponseExpectations { + expectations := BasicChatExpectations() + expectations.ShouldHaveContent = false // Tool calls might not have text content + expectations.MinContentLength = 0 + + for i, tool := range tools { + var args []string + if i < len(requiredArgsPerTool) { + args = requiredArgsPerTool[i] + } + + expectations.ExpectedToolCalls = append(expectations.ExpectedToolCalls, ToolCallExpectation{ + FunctionName: tool, + RequiredArgs: args, + ValidateArgsJSON: true, + }) + } + + return expectations +} + +// ImageAnalysisExpectations returns validation expectations for image analysis scenarios +func ImageAnalysisExpectations() ResponseExpectations { + expectations := BasicChatExpectations() + expectations.MinContentLength = 20 // Image descriptions should be more detailed + expectations.ShouldContainKeywords = []string{"image", "picture", "photo", "see", "shows", "contains"} + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{ + "i can't see", "i cannot see", "unable to see", "can't view", + "cannot view", "no image", "not able to see", "i don't see", + }...) + + return expectations +} + +// TextCompletionExpectations returns validation expectations for text completion scenarios +func TextCompletionExpectations() ResponseExpectations { + expectations := BasicChatExpectations() + expectations.MinContentLength = 10 // Completions should have reasonable length + + return expectations +} + +// EmbeddingExpectations returns validation expectations for embedding scenarios +func EmbeddingExpectations(expectedTexts []string) ResponseExpectations { + return ResponseExpectations{ + ShouldHaveContent: false, // Embeddings don't have text content + ExpectedChoiceCount: 0, // Embeddings use different structure + ShouldHaveModel: true, + ShouldHaveLatency: true, // Global expectation: latency should always be present + // Custom validation will be needed for embedding data + ProviderSpecific: map[string]interface{}{ + "expected_embedding_count": len(expectedTexts), + "expected_texts": expectedTexts, + }, + } +} + +// StreamingExpectations returns validation expectations for streaming scenarios +func StreamingExpectations() ResponseExpectations { + expectations := BasicChatExpectations() + + return expectations +} + +// ConversationExpectations returns validation expectations for multi-turn conversation scenarios +func ConversationExpectations(contextKeywords []string) ResponseExpectations { + expectations := BasicChatExpectations() + expectations.MinContentLength = 15 // Conversation responses should be more substantial + expectations.ShouldContainAnyOf = contextKeywords // Should reference conversation context + + return expectations +} + +// VisionExpectations returns validation expectations for vision/image processing scenarios +func VisionExpectations(expectedKeywords []string) ResponseExpectations { + expectations := ImageAnalysisExpectations() // Use existing image analysis base + if len(expectedKeywords) > 0 { + expectations.ShouldContainKeywords = expectedKeywords + } + expectations.MinContentLength = 20 // Vision responses should be descriptive + expectations.MaxContentLength = 1200 // Vision models can be verbose + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, + "cannot see", "unable to view", "no image", "can't see", + "image not found", "invalid image", "corrupted image", + "failed to load", "error processing", + ) + expectations.IsRelevantToPrompt = true + return expectations +} + +// SpeechExpectations returns validation expectations for speech synthesis scenarios +func SpeechExpectations(minAudioBytes int) ResponseExpectations { + return ResponseExpectations{ + ShouldHaveContent: false, // Speech responses don't have text content + ExpectedChoiceCount: 0, // Speech responses don't have choices + ShouldHaveUsageStats: true, + ShouldHaveTimestamps: true, + ShouldHaveModel: true, + ShouldHaveLatency: true, // Global expectation: latency should always be present + // Speech-specific validations stored in ProviderSpecific + ProviderSpecific: map[string]interface{}{ + "min_audio_bytes": minAudioBytes, + "should_have_audio": true, + "expected_format": "audio", // General audio format + "response_type": "speech_synthesis", + }, + } +} + +// TranscriptionExpectations returns validation expectations for transcription scenarios +func TranscriptionExpectations(minTextLength int) ResponseExpectations { + return ResponseExpectations{ + ShouldHaveContent: false, // Transcription has transcribed text, not chat content + ExpectedChoiceCount: 0, // Transcription responses don't have choices + ShouldHaveUsageStats: true, + ShouldHaveTimestamps: true, + ShouldHaveModel: true, + ShouldHaveLatency: true, // Global expectation: latency should always be present + // Transcription-specific validations + ShouldNotContainWords: []string{ + "could not transcribe", "failed to process", + "invalid audio", "corrupted audio", + "unsupported format", "transcription error", + "no audio detected", "silence detected", + }, + ProviderSpecific: map[string]interface{}{ + "min_transcription_length": minTextLength, + "should_have_transcription": true, + "response_type": "transcription", + }, + } +} + +// ReasoningExpectations returns validation expectations for reasoning scenarios +func ReasoningExpectations() ResponseExpectations { + return ResponseExpectations{ + ShouldHaveContent: true, + MinContentLength: 50, // Reasoning requires substantial content + MaxContentLength: 3000, // Reasoning can be very verbose + ShouldHaveUsageStats: true, + ShouldHaveTimestamps: true, + ShouldHaveModel: true, + ProviderSpecific: map[string]interface{}{ + "response_type": "reasoning", + "expects_step_by_step": true, + }, + } +} + +// ============================================================================= +// SCENARIO-SPECIFIC EXPECTATION BUILDERS +// ============================================================================= + +// GetExpectationsForScenario returns appropriate validation expectations for a given scenario +func GetExpectationsForScenario(scenarioName string, testConfig config.ComprehensiveTestConfig, customParams map[string]interface{}) ResponseExpectations { + switch scenarioName { + case "SimpleChat": + return BasicChatExpectations() + + case "TextCompletion": + return TextCompletionExpectations() + + case "ToolCalls": + if toolName, ok := customParams["tool_name"].(string); ok { + if args, ok := customParams["required_args"].([]string); ok { + return ToolCallExpectations(toolName, args) + } + } + return WeatherToolExpectations() // Default to weather tool + + case "MultipleToolCalls": + if tools, ok := customParams["tool_names"].([]string); ok { + if argsPerTool, ok := customParams["required_args_per_tool"].([][]string); ok { + return MultipleToolExpectations(tools, argsPerTool) + } + } + // Default to weather and calculator + return MultipleToolExpectations( + []string{string(SampleToolTypeWeather), string(SampleToolTypeCalculate)}, + [][]string{{"location"}, {"expression"}}, + ) + + case "End2EndToolCalling": + return ConversationExpectations([]string{"weather", "temperature", "result"}) + + case "AutomaticFunctionCalling": + expectations := WeatherToolExpectations() + expectations.ShouldHaveContent = true // Should have follow-up text after tool call + expectations.MinContentLength = 20 + return expectations + + case "ImageURL", "ImageBase64": + return VisionExpectations([]string{"image", "picture", "see"}) + + case "MultipleImages": + return VisionExpectations([]string{"compare", "similar", "different", "images"}) + + case "ChatCompletionStream": + return StreamingExpectations() + + case "MultiTurnConversation": + if keywords, ok := customParams["context_keywords"].([]string); ok { + return ConversationExpectations(keywords) + } + return ConversationExpectations([]string{"context", "previous", "mentioned"}) + + case "Embedding": + if texts, ok := customParams["input_texts"].([]string); ok { + return EmbeddingExpectations(texts) + } + return EmbeddingExpectations([]string{"Hello, world!", "Hi, world!", "Goodnight, moon!"}) + + case "CompleteEnd2End": + return ConversationExpectations([]string{"complete", "comprehensive", "full"}) + + case "SpeechSynthesis": + if minBytes, ok := customParams["min_audio_bytes"].(int); ok { + return SpeechExpectations(minBytes) + } + return SpeechExpectations(500) // Default minimum 500 bytes + + case "Transcription": + if minLength, ok := customParams["min_transcription_length"].(int); ok { + return TranscriptionExpectations(minLength) + } + return TranscriptionExpectations(10) // Default minimum 10 characters + + case "Reasoning": + expectations := ReasoningExpectations() + return expectations + + case "ProviderSpecific": + expectations := BasicChatExpectations() + expectations.ShouldContainKeywords = []string{"unique", "specific", "capability"} + return expectations + + default: + // Default to basic chat expectations + return BasicChatExpectations() + } +} + +// ============================================================================= +// PROVIDER-SPECIFIC EXPECTATION MODIFIERS +// ============================================================================= + +// ModifyExpectationsForProvider adjusts expectations based on provider capabilities +func ModifyExpectationsForProvider(expectations ResponseExpectations, provider schemas.ModelProvider) ResponseExpectations { + switch provider { + case schemas.OpenAI: + expectations.ShouldHaveUsageStats = true + expectations.ShouldHaveTimestamps = true + expectations.ShouldHaveModel = true + + case schemas.Anthropic: + expectations.ShouldHaveUsageStats = true + expectations.ShouldHaveModel = true + // Claude might have different response patterns + + case schemas.Bedrock: + expectations.ShouldHaveModel = true + // AWS Bedrock has different usage reporting + expectations.ShouldHaveUsageStats = false // Often not included + + case schemas.Cohere: + expectations.ShouldHaveModel = true + expectations.ShouldHaveUsageStats = true + + case schemas.Vertex: + expectations.ShouldHaveModel = true + // Google Vertex AI has different metadata + + case schemas.Mistral: + expectations.ShouldHaveModel = true + expectations.ShouldHaveUsageStats = true + + case schemas.Ollama: + // Local models might have different metadata expectations + expectations.ShouldHaveUsageStats = false + expectations.ShouldHaveTimestamps = false + + case schemas.Groq: + expectations.ShouldHaveUsageStats = true + expectations.ShouldHaveModel = true + + case schemas.Gemini: + expectations.ShouldHaveModel = true + expectations.ShouldHaveUsageStats = true + + default: + // Keep default expectations + } + + return expectations +} + +// ============================================================================= +// ADVANCED VALIDATION EXPECTATIONS +// ============================================================================= + +// SemanticCoherenceExpectations returns expectations for semantic coherence tests +func SemanticCoherenceExpectations(inputPrompt string, expectedTopics []string) ResponseExpectations { + expectations := BasicChatExpectations() + expectations.MinContentLength = 30 // More substantial response needed + expectations.ShouldContainKeywords = expectedTopics + expectations.IsRelevantToPrompt = true + + // Add pattern for coherent responses (no contradictions, proper flow) + expectations.ContentPattern = regexp.MustCompile(`^[A-Z].*[.!?]$`) // Should start with capital and end with punctuation + + return expectations +} + +// ConsistencyExpectations returns expectations for consistency tests +func ConsistencyExpectations(expectedConsistencyMarkers []string) ResponseExpectations { + expectations := BasicChatExpectations() + expectations.ShouldContainKeywords = expectedConsistencyMarkers + expectations.ShouldNotContainWords = append(expectations.ShouldNotContainWords, []string{ + "however", "but", "on the other hand", // Contradiction markers + "i'm not sure", "maybe", "possibly", "might be", // Uncertainty markers + }...) + + return expectations +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +// stringPtr returns a pointer to a string +func stringPtr(s string) *string { + return &s +} + +// CombineExpectations merges multiple expectations (later ones override earlier ones) +func CombineExpectations(expectations ...ResponseExpectations) ResponseExpectations { + if len(expectations) == 0 { + return BasicChatExpectations() + } + + base := expectations[0] + + for _, exp := range expectations[1:] { + // Override fields that are set in the new expectation + if exp.ShouldHaveContent { + base.ShouldHaveContent = exp.ShouldHaveContent + } + if exp.MinContentLength > 0 { + base.MinContentLength = exp.MinContentLength + } + if exp.MaxContentLength > 0 { + base.MaxContentLength = exp.MaxContentLength + } + if exp.ExpectedChoiceCount > 0 { + base.ExpectedChoiceCount = exp.ExpectedChoiceCount + } + if exp.ExpectedFinishReason != nil { + base.ExpectedFinishReason = exp.ExpectedFinishReason + } + + // Append arrays + base.ShouldContainKeywords = append(base.ShouldContainKeywords, exp.ShouldContainKeywords...) + base.ShouldNotContainWords = append(base.ShouldNotContainWords, exp.ShouldNotContainWords...) + base.ExpectedToolCalls = append(base.ExpectedToolCalls, exp.ExpectedToolCalls...) + + // Override other fields + if exp.ContentPattern != nil { + base.ContentPattern = exp.ContentPattern + } + if exp.IsRelevantToPrompt { + base.IsRelevantToPrompt = exp.IsRelevantToPrompt + } + if exp.ShouldNotHaveFunctionCalls { + base.ShouldNotHaveFunctionCalls = exp.ShouldNotHaveFunctionCalls + } + if exp.ShouldHaveUsageStats { + base.ShouldHaveUsageStats = exp.ShouldHaveUsageStats + } + if exp.ShouldHaveTimestamps { + base.ShouldHaveTimestamps = exp.ShouldHaveTimestamps + } + if exp.ShouldHaveModel { + base.ShouldHaveModel = exp.ShouldHaveModel + } + if exp.ShouldHaveLatency { + base.ShouldHaveLatency = exp.ShouldHaveLatency + } + + // Merge provider specific data + if len(exp.ProviderSpecific) > 0 { + if base.ProviderSpecific == nil { + base.ProviderSpecific = make(map[string]interface{}) + } + for k, v := range exp.ProviderSpecific { + base.ProviderSpecific[k] = v + } + } + } + + return base +} diff --git a/tests/core-providers/sgl_test.go b/tests/core-providers/sgl_test.go new file mode 100644 index 000000000..247fffd02 --- /dev/null +++ b/tests/core-providers/sgl_test.go @@ -0,0 +1,53 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestSGL(t *testing.T) { + t.Parallel() + if os.Getenv("SGL_BASE_URL") == "" { + t.Skip("Skipping SGL tests because SGL_BASE_URL is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.SGL, + ChatModel: "qwen/qwen2.5-0.5b-instruct", + VisionModel: "Qwen/Qwen2.5-VL-7B-Instruct", + TextModel: "qwen/qwen2.5-0.5b-instruct", + EmbeddingModel: "Alibaba-NLP/gte-Qwen2-1.5B-instruct", + Scenarios: config.TestScenarios{ + TextCompletion: true, + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + Embedding: true, + ListModels: true, + }, + } + + t.Run("SGLTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/core-providers/tests.go b/tests/core-providers/tests.go new file mode 100644 index 000000000..c35ca1fcf --- /dev/null +++ b/tests/core-providers/tests.go @@ -0,0 +1,120 @@ +package tests + +import ( + "context" + "strings" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + "github.com/maximhq/bifrost/tests/core-providers/scenarios" + + bifrost "github.com/maximhq/bifrost/core" +) + +// TestScenarioFunc defines the function signature for test scenario functions +type TestScenarioFunc func(*testing.T, *bifrost.Bifrost, context.Context, config.ComprehensiveTestConfig) + +// runAllComprehensiveTests executes all comprehensive test scenarios for a given configuration +func runAllComprehensiveTests(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig config.ComprehensiveTestConfig) { + if testConfig.SkipReason != "" { + t.Skipf("Skipping %s: %s", testConfig.Provider, testConfig.SkipReason) + return + } + + t.Logf("πŸš€ Running comprehensive tests for provider: %s", testConfig.Provider) + + // Define all test scenario functions in a slice + testScenarios := []TestScenarioFunc{ + scenarios.RunTextCompletionTest, + scenarios.RunTextCompletionStreamTest, + scenarios.RunSimpleChatTest, + scenarios.RunChatCompletionStreamTest, + scenarios.RunResponsesStreamTest, + scenarios.RunMultiTurnConversationTest, + scenarios.RunToolCallsTest, + scenarios.RunToolCallsStreamingTest, + scenarios.RunMultipleToolCallsTest, + scenarios.RunEnd2EndToolCallingTest, + scenarios.RunAutomaticFunctionCallingTest, + scenarios.RunImageURLTest, + scenarios.RunImageBase64Test, + scenarios.RunMultipleImagesTest, + scenarios.RunCompleteEnd2EndTest, + scenarios.RunSpeechSynthesisTest, + scenarios.RunSpeechSynthesisAdvancedTest, + scenarios.RunSpeechSynthesisStreamTest, + scenarios.RunSpeechSynthesisStreamAdvancedTest, + scenarios.RunTranscriptionTest, + scenarios.RunTranscriptionAdvancedTest, + scenarios.RunTranscriptionStreamTest, + scenarios.RunTranscriptionStreamAdvancedTest, + scenarios.RunEmbeddingTest, + scenarios.RunReasoningTest, + scenarios.RunListModelsTest, + scenarios.RunListModelsPaginationTest, + } + + // Execute all test scenarios + for _, scenarioFunc := range testScenarios { + scenarioFunc(t, client, ctx, testConfig) + } + + // Print comprehensive summary based on configuration + printTestSummary(t, testConfig) +} + +// printTestSummary prints a detailed summary of all test scenarios +func printTestSummary(t *testing.T, testConfig config.ComprehensiveTestConfig) { + testScenarios := []struct { + name string + supported bool + }{ + {"TextCompletion", testConfig.Scenarios.TextCompletion && testConfig.TextModel != ""}, + {"SimpleChat", testConfig.Scenarios.SimpleChat}, + {"CompletionStream", testConfig.Scenarios.CompletionStream}, + {"MultiTurnConversation", testConfig.Scenarios.MultiTurnConversation}, + {"ToolCalls", testConfig.Scenarios.ToolCalls}, + {"ToolCallsStreaming", testConfig.Scenarios.ToolCallsStreaming}, + {"MultipleToolCalls", testConfig.Scenarios.MultipleToolCalls}, + {"End2EndToolCalling", testConfig.Scenarios.End2EndToolCalling}, + {"AutomaticFunctionCall", testConfig.Scenarios.AutomaticFunctionCall}, + {"ImageURL", testConfig.Scenarios.ImageURL}, + {"ImageBase64", testConfig.Scenarios.ImageBase64}, + {"MultipleImages", testConfig.Scenarios.MultipleImages}, + {"CompleteEnd2End", testConfig.Scenarios.CompleteEnd2End}, + {"SpeechSynthesis", testConfig.Scenarios.SpeechSynthesis}, + {"SpeechSynthesisStream", testConfig.Scenarios.SpeechSynthesisStream}, + {"Transcription", testConfig.Scenarios.Transcription}, + {"TranscriptionStream", testConfig.Scenarios.TranscriptionStream}, + {"Embedding", testConfig.Scenarios.Embedding && testConfig.EmbeddingModel != ""}, + {"Reasoning", testConfig.Scenarios.Reasoning && testConfig.ReasoningModel != ""}, + {"ListModels", testConfig.Scenarios.ListModels}, + } + + supported := 0 + unsupported := 0 + + t.Logf("\n%s", strings.Repeat("=", 80)) + t.Logf("COMPREHENSIVE TEST SUMMARY FOR PROVIDER: %s", strings.ToUpper(string(testConfig.Provider))) + t.Logf("%s", strings.Repeat("=", 80)) + + for _, scenario := range testScenarios { + if scenario.supported { + supported++ + t.Logf("βœ… SUPPORTED: %-25s βœ… Configured to run", scenario.name) + } else { + unsupported++ + t.Logf("❌ UNSUPPORTED: %-25s ❌ Not supported by provider", scenario.name) + } + } + + t.Logf("%s", strings.Repeat("-", 80)) + t.Logf("CONFIGURATION SUMMARY:") + t.Logf(" βœ… Supported Tests: %d", supported) + t.Logf(" ❌ Unsupported Tests: %d", unsupported) + t.Logf(" πŸ“Š Total Test Types: %d", len(testScenarios)) + t.Logf("") + t.Logf("ℹ️ NOTE: Actual PASS/FAIL results are shown in the individual test output above.") + t.Logf("ℹ️ Look for individual test results like 'PASS: TestOpenAI/SimpleChat' or 'FAIL: TestOpenAI/ToolCalls'") + t.Logf("%s\n", strings.Repeat("=", 80)) +} diff --git a/tests/core-providers/vertex_test.go b/tests/core-providers/vertex_test.go new file mode 100644 index 000000000..a1c86dae7 --- /dev/null +++ b/tests/core-providers/vertex_test.go @@ -0,0 +1,53 @@ +package tests + +import ( + "os" + "testing" + + "github.com/maximhq/bifrost/tests/core-providers/config" + + "github.com/maximhq/bifrost/core/schemas" +) + +func TestVertex(t *testing.T) { + t.Parallel() + if os.Getenv("VERTEX_API_KEY") == "" && (os.Getenv("VERTEX_PROJECT_ID") == "" || os.Getenv("VERTEX_CREDENTIALS") == "") { + t.Skip("Skipping Vertex tests because VERTEX_API_KEY is not set and VERTEX_PROJECT_ID or VERTEX_CREDENTIALS is not set") + } + + client, ctx, cancel, err := config.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + + testConfig := config.ComprehensiveTestConfig{ + Provider: schemas.Vertex, + ChatModel: "google/gemini-2.0-flash-001", + VisionModel: "google/gemini-2.0-flash-001", + TextModel: "", // Vertex doesn't support text completion in newer models + EmbeddingModel: "text-multilingual-embedding-002", + Scenarios: config.TestScenarios{ + TextCompletion: false, // Not supported + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: true, + ImageBase64: true, + MultipleImages: true, + CompleteEnd2End: true, + Embedding: true, + ListModels: true, + }, + } + + t.Run("VertexTests", func(t *testing.T) { + runAllComprehensiveTests(t, client, ctx, testConfig) + }) + client.Shutdown() +} diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 000000000..b45bed796 --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,59 @@ +services: + # Weaviate instance for basic tests + 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 + 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: 30s + timeout: 10s + retries: 3 + +networks: + bifrost_network: + driver: bridge + ipam: + config: + - subnet: 172.38.0.0/16 + gateway: 172.38.0.1 + +volumes: + weaviate_data: + redis_data: \ No newline at end of file diff --git a/tests/governance/README.md b/tests/governance/README.md new file mode 100644 index 000000000..1cbc0f988 --- /dev/null +++ b/tests/governance/README.md @@ -0,0 +1,388 @@ +# Bifrost Governance Plugin Test Suite + +A comprehensive test suite for the Bifrost Governance Plugin, testing hierarchical governance, budgets, rate limiting, usage tracking, and CRUD operations. + +## Overview + +This test suite provides extensive coverage of the Bifrost governance system including: + +- **Virtual Key Management**: Complete CRUD operations with comprehensive field update testing +- **Team Management**: Team CRUD with customer relationships and budget inheritance +- **Customer Management**: Customer CRUD with team hierarchies and budget controls +- **Usage Tracking**: Real-time usage monitoring and audit logging +- **Rate Limiting**: Flexible token and request rate limiting with configurable reset periods +- **Budget Enforcement**: Hierarchical budget controls (Customer β†’ Team β†’ Virtual Key) +- **Integration Testing**: End-to-end testing with chat completion API +- **Edge Cases**: Boundary conditions, concurrency, and error scenarios + +## Test Structure + +### Test Files + +1. **`test_virtual_keys_crud.py`** - Virtual Key CRUD operations + - Complete CRUD lifecycle testing + - Comprehensive field update testing (individual and batch) + - Mutual exclusivity validation (team_id vs customer_id) + - Budget and rate limit management + - Relationship testing with teams and customers + +2. **`test_teams_crud.py`** - Team CRUD operations + - Team lifecycle management + - Customer association testing + - Budget inheritance and conflicts + - Comprehensive field updates + - Filtering and relationships + +3. **`test_customers_crud.py`** - Customer CRUD operations + - Customer lifecycle management + - Team relationship management + - Budget management and hierarchies + - Comprehensive field updates + - Cascading operations + +4. **`test_usage_tracking.py`** - Usage tracking and monitoring + - Chat completion integration with governance headers + - Usage tracking and budget enforcement + - Rate limiting enforcement + - Monitoring endpoints + - Reset functionality + - Debug and health endpoints + +### Configuration Files + +- **`conftest.py`** - Test fixtures, utilities, and configuration +- **`pytest.ini`** - pytest configuration with markers and settings +- **`requirements.txt`** - Test dependencies +- **`__init__.py`** - Package initialization + +## Key Features + +### Comprehensive Field Update Testing + +Each entity (Virtual Key, Team, Customer) has exhaustive field update tests that verify: + +- **Individual field updates** - Each field updated independently +- **Unchanged field verification** - Other fields remain unmodified +- **Relationship preservation** - Associated data maintained correctly +- **Timestamp validation** - updated_at changes, created_at preserved +- **Multiple field updates** - Batch field modifications +- **Nested object updates** - Budget and rate limit sub-objects +- **Edge cases** - Empty updates, null values, invalid data + +### Mutual Exclusivity Testing + +Critical validation of Virtual Key constraints: +- VK can have `team_id` OR `customer_id`, but NEVER both +- Switching between team and customer associations +- Validation error scenarios for invalid combinations + +### Hierarchical Testing + +Testing the Customer β†’ Team β†’ Virtual Key hierarchy: +- Budget inheritance and override scenarios +- Rate limit cascading and conflicts +- Usage tracking across hierarchy levels +- Permission and access control validation + +### Integration Testing + +End-to-end testing with actual chat completion requests: +- Governance header validation (`x-bf-vk`) +- Usage tracking during real requests +- Budget enforcement during streaming +- Rate limiting during concurrent requests +- Provider and model access control + +## Setup and Usage + +### Prerequisites + +1. **Bifrost Server Running**: The governance plugin must be running on `localhost:8080` +2. **Python 3.8+**: Required for the test suite +3. **Dependencies**: Install via `pip install -r requirements.txt` + +### Environment Configuration + +Set the following environment variables (optional): + +```bash +export BIFROST_BASE_URL="http://localhost:8080" # Default +export GOVERNANCE_TEST_TIMEOUT="300" # Test timeout in seconds +export GOVERNANCE_TEST_CLEANUP="true" # Auto-cleanup entities +``` + +### Running Tests + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run all governance tests +pytest + +# Run specific test files +pytest test_virtual_keys_crud.py +pytest test_teams_crud.py +pytest test_customers_crud.py +pytest test_usage_tracking.py + +# Run with specific markers +pytest -m "virtual_keys" +pytest -m "field_updates" +pytest -m "edge_cases" +pytest -m "integration" + +# Run with coverage +pytest --cov=. --cov-report=html + +# Run in parallel +pytest -n auto + +# Run with verbose output +pytest -v + +# Run smoke tests only +pytest -m "smoke" +``` + +### Test Markers + +The test suite uses pytest markers for categorization: + +- `@pytest.mark.virtual_keys` - Virtual Key related tests +- `@pytest.mark.teams` - Team related tests +- `@pytest.mark.customers` - Customer related tests +- `@pytest.mark.field_updates` - Comprehensive field update tests +- `@pytest.mark.mutual_exclusivity` - Mutual exclusivity constraint tests +- `@pytest.mark.budget` - Budget related tests +- `@pytest.mark.rate_limit` - Rate limiting tests +- `@pytest.mark.usage_tracking` - Usage tracking tests +- `@pytest.mark.integration` - Integration tests +- `@pytest.mark.edge_cases` - Edge case tests +- `@pytest.mark.concurrency` - Concurrency tests +- `@pytest.mark.slow` - Slow running tests (>5s) +- `@pytest.mark.smoke` - Quick smoke tests + +## API Endpoints Tested + +### Virtual Key Endpoints +- `GET /api/governance/virtual-keys` - List all VKs with relationships +- `POST /api/governance/virtual-keys` - Create VK with optional budget/rate limits +- `GET /api/governance/virtual-keys/{vk_id}` - Get specific VK +- `PUT /api/governance/virtual-keys/{vk_id}` - Update VK +- `DELETE /api/governance/virtual-keys/{vk_id}` - Delete VK + +### Team Endpoints +- `GET /api/governance/teams` - List teams with optional customer filter +- `POST /api/governance/teams` - Create team with optional customer/budget +- `GET /api/governance/teams/{team_id}` - Get specific team +- `PUT /api/governance/teams/{team_id}` - Update team +- `DELETE /api/governance/teams/{team_id}` - Delete team + +### Customer Endpoints +- `GET /api/governance/customers` - List customers with teams/budgets +- `POST /api/governance/customers` - Create customer with optional budget +- `GET /api/governance/customers/{customer_id}` - Get specific customer +- `PUT /api/governance/customers/{customer_id}` - Update customer +- `DELETE /api/governance/customers/{customer_id}` - Delete customer + +### Monitoring Endpoints +- `GET /api/governance/usage-stats` - Usage statistics with optional VK filter +- `POST /api/governance/usage-reset` - Reset VK usage counters +- `GET /api/governance/debug/stats` - Debug statistics +- `GET /api/governance/debug/counters` - All VK usage counters +- `GET /api/governance/debug/health` - Health check + +### Integration Endpoints +- `POST /v1/chat/completions` - Chat completion with governance headers + +## Test Data and Schemas + +### Virtual Key Request Schema +```json +{ + "name": "string (required)", + "description": "string (optional)", + "allowed_models": ["string"] (optional), + "allowed_providers": ["string"] (optional), + "team_id": "string (optional, mutually exclusive with customer_id)", + "customer_id": "string (optional, mutually exclusive with team_id)", + "budget": { + "max_limit": "integer (cents)", + "reset_duration": "string (e.g., '1h', '1d')" + }, + "rate_limit": { + "token_max_limit": "integer (optional)", + "token_reset_duration": "string (optional)", + "request_max_limit": "integer (optional)", + "request_reset_duration": "string (optional)" + }, + "is_active": "boolean (optional, default true)" +} +``` + +### Team Request Schema +```json +{ + "name": "string (required)", + "customer_id": "string (optional)", + "budget": { + "max_limit": "integer (cents)", + "reset_duration": "string" + } +} +``` + +### Customer Request Schema +```json +{ + "name": "string (required)", + "budget": { + "max_limit": "integer (cents)", + "reset_duration": "string" + } +} +``` + +## Edge Cases Covered + +### Budget Edge Cases +- Boundary values: 0, negative, max int64, overflow +- Reset timing: exact boundaries, concurrent resets +- Hierarchical conflicts: VK vs Team vs Customer budgets +- Fractional costs: proper cents handling +- Concurrent usage: multiple requests hitting limits +- Reset during flight: budget resets while processing +- Streaming cost tracking: partial vs final costs + +### Rate Limiting Edge Cases +- Independent limits: token vs request limits with different resets +- Sub-second precision: very short reset durations +- Burst scenarios: simultaneous requests +- Provider variations: different limits per provider/model +- Streaming rate limits: token counting across chunks +- Reset race conditions: limits resetting during validation + +### Relationship Edge Cases +- Orphaned entities: VKs without parent relationships +- Invalid references: team_id pointing to non-existent team +- Mutual exclusivity: VK with both team_id and customer_id (MUST FAIL) +- Circular dependencies: prevention testing +- Deep hierarchies: Customer β†’ Team β†’ VK inheritance + +### Update Edge Cases +- Partial updates: only some fields updated +- Null handling: null values clearing optional fields +- Type validation: wrong data types in requests +- Concurrent updates: multiple clients updating same entity +- Cache invalidation: in-memory cache updates after DB changes +- Rollback scenarios: failed updates don't leave partial changes + +### Integration Edge Cases +- Missing headers: requests without x-bf-vk header +- Invalid headers: malformed or non-existent VK values +- Provider/model validation: invalid combinations +- Error propagation: governance vs completion errors +- Streaming interruption: governance blocking mid-stream +- Context preservation: headers passed through request lifecycle + +## Utilities and Helpers + +### Test Fixtures +- `governance_client` - API client for governance endpoints +- `cleanup_tracker` - Automatic entity cleanup after tests +- `sample_customer` - Pre-created customer for testing +- `sample_team` - Pre-created team for testing +- `sample_virtual_key` - Pre-created virtual key for testing +- `field_update_tester` - Helper for comprehensive field update testing + +### Utility Functions +- `generate_unique_name()` - Generate unique test entity names +- `wait_for_condition()` - Wait for async conditions +- `assert_response_success()` - Assert HTTP response success +- `deep_compare_entities()` - Deep comparison of entity data +- `verify_unchanged_fields()` - Verify fields remain unchanged +- `create_complete_virtual_key_data()` - Generate complete VK data + +### Error Handling +- Comprehensive error assertion helpers +- Automatic retry for transient failures +- Detailed error logging and reporting +- Clean failure modes with proper cleanup + +## Performance and Concurrency + +### Performance Testing +- Response time benchmarks for all endpoints +- Memory usage monitoring during tests +- Database query optimization validation +- Cache performance verification + +### Concurrency Testing +- Race condition detection +- Concurrent entity creation/updates +- Simultaneous budget usage scenarios +- Rate limit burst testing +- Cache consistency under load + +## Debugging and Monitoring + +### Test Logging +- Comprehensive test execution logging +- API request/response logging +- Error details and stack traces +- Performance metrics and timing + +### Debug Endpoints +- Test coverage of debug/stats endpoint +- Usage counter validation +- Health check verification +- Database state inspection + +## Contributing + +When adding new tests: + +1. **Follow naming conventions**: `test__.py` +2. **Use appropriate markers**: Mark tests with relevant pytest markers +3. **Include cleanup**: Use `cleanup_tracker` fixture for entity cleanup +4. **Document edge cases**: Comment complex test scenarios +5. **Add field update tests**: For any new entity fields, add comprehensive update tests +6. **Test relationships**: Verify entity relationships and cascading effects +7. **Include negative tests**: Test validation and error scenarios + +### Test Development Guidelines + +1. **Comprehensive Coverage**: Test all CRUD operations, field updates, and edge cases +2. **Isolation**: Tests should be independent and not rely on other test state +3. **Cleanup**: Always clean up created entities to avoid test interference +4. **Documentation**: Comment complex test logic and expected behaviors +5. **Performance**: Mark slow tests appropriately and optimize where possible +6. **Error Scenarios**: Test both success and failure paths +7. **Relationships**: Verify entity relationships are properly maintained + +## Troubleshooting + +### Common Issues + +1. **Server Not Running**: Ensure Bifrost server is running on localhost:8080 +2. **Permission Errors**: Check that test has access to create/delete entities +3. **Cleanup Failures**: Manually clean up test entities if auto-cleanup fails +4. **Timeout Errors**: Increase timeout for slow-running tests +5. **Concurrency Issues**: Use appropriate locks for shared resource tests + +### Debug Commands + +```bash +# Run with maximum verbosity +pytest -vvv --tb=long + +# Run single test with debugging +pytest -s test_virtual_keys_crud.py::test_vk_create_basic + +# Run with profiling +pytest --profile-svg + +# Check test coverage +pytest --cov=. --cov-report=term-missing +``` \ No newline at end of file diff --git a/tests/governance/__init__.py b/tests/governance/__init__.py new file mode 100644 index 000000000..2936e67c9 --- /dev/null +++ b/tests/governance/__init__.py @@ -0,0 +1,31 @@ +""" +Bifrost Governance Plugin Test Suite + +Comprehensive test suite for the Bifrost governance system covering: +- Virtual Key CRUD operations with comprehensive field updates +- Team CRUD operations with hierarchical relationships +- Customer CRUD operations with budget management +- Usage tracking and monitoring +- Rate limiting and budget enforcement +- Integration testing with chat completions +- Edge cases and validation testing +- Concurrency and race condition testing + +Test Structure: +- test_virtual_keys_crud.py: Virtual Key CRUD and field update tests +- test_teams_crud.py: Team CRUD and field update tests +- test_customers_crud.py: Customer CRUD and field update tests +- test_usage_tracking.py: Usage tracking, monitoring, and integration tests +- conftest.py: Test fixtures and utilities + +Key Features: +- Comprehensive field update testing for all entities +- Mutual exclusivity validation (VK team_id vs customer_id) +- Hierarchical budget and rate limit testing +- Automatic test entity cleanup +- Concurrent testing support +- Edge case and boundary condition coverage +""" + +__version__ = "1.0.0" +__author__ = "Bifrost Team" diff --git a/tests/governance/conftest.py b/tests/governance/conftest.py new file mode 100644 index 000000000..84d77c2d0 --- /dev/null +++ b/tests/governance/conftest.py @@ -0,0 +1,668 @@ +""" +Pytest configuration for Bifrost Governance Plugin testing. + +Provides comprehensive setup, fixtures, and utilities for testing the +Bifrost governance system with hierarchical budgets, rate limiting, +usage tracking, and CRUD operations for Virtual Keys, Teams, and Customers. +""" + +import pytest +import requests +import json +import uuid +import time +import os +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Tuple +from concurrent.futures import ThreadPoolExecutor +import threading +from dataclasses import dataclass +import copy + + +# Test Configuration +BIFROST_BASE_URL = os.getenv("BIFROST_BASE_URL", "http://localhost:8080") +GOVERNANCE_API_BASE = f"{BIFROST_BASE_URL}/api/governance" +COMPLETION_API_BASE = f"{BIFROST_BASE_URL}/v1" + + +def pytest_configure(config): + """Configure pytest with custom markers for governance testing""" + markers = [ + "governance: mark test as governance-related", + "virtual_keys: mark test as virtual key test", + "teams: mark test as team test", + "customers: mark test as customer test", + "budget: mark test as budget-related", + "rate_limit: mark test as rate limit-related", + "usage_tracking: mark test as usage tracking test", + "crud: mark test as CRUD operation test", + "field_updates: mark test as comprehensive field update test", + "validation: mark test as validation test", + "integration: mark test as integration test", + "edge_cases: mark test as edge case test", + "concurrency: mark test as concurrency test", + "mutual_exclusivity: mark test as mutual exclusivity test", + "hierarchical: mark test as hierarchical governance test", + "slow: mark test as slow running (>5s)", + "smoke: mark test as smoke test", + ] + + for marker in markers: + config.addinivalue_line("markers", marker) + + +@dataclass +class TestEntity: + """Base class for test entities""" + + id: str + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +@dataclass +class TestBudget(TestEntity): + """Test budget entity""" + + max_limit: int = 0 + reset_duration: str = "" + current_usage: int = 0 + last_reset: Optional[str] = None + + +@dataclass +class TestRateLimit(TestEntity): + """Test rate limit entity""" + + token_max_limit: Optional[int] = None + token_reset_duration: Optional[str] = None + request_max_limit: Optional[int] = None + request_reset_duration: Optional[str] = None + token_current_usage: int = 0 + request_current_usage: int = 0 + token_last_reset: Optional[str] = None + request_last_reset: Optional[str] = None + + +@dataclass +class TestCustomer(TestEntity): + """Test customer entity""" + + name: str = "" + budget_id: Optional[str] = None + budget: Optional[TestBudget] = None + teams: Optional[List["TestTeam"]] = None + + +@dataclass +class TestTeam(TestEntity): + """Test team entity""" + + name: str = "" + customer_id: Optional[str] = None + budget_id: Optional[str] = None + customer: Optional[TestCustomer] = None + budget: Optional[TestBudget] = None + + +@dataclass +class TestVirtualKey(TestEntity): + """Test virtual key entity""" + + name: str = "" + value: str = "" + description: str = "" + allowed_models: Optional[List[str]] = None + allowed_providers: Optional[List[str]] = None + team_id: Optional[str] = None + customer_id: Optional[str] = None + budget_id: Optional[str] = None + rate_limit_id: Optional[str] = None + is_active: bool = True + team: Optional[TestTeam] = None + customer: Optional[TestCustomer] = None + budget: Optional[TestBudget] = None + rate_limit: Optional[TestRateLimit] = None + + +class GovernanceTestClient: + """HTTP client for governance API testing with comprehensive error handling""" + + def __init__(self, base_url: str = GOVERNANCE_API_BASE): + self.base_url = base_url + self.session = requests.Session() + self.session.headers.update({"Content-Type": "application/json"}) + + def request(self, method: str, endpoint: str, **kwargs) -> requests.Response: + """Make HTTP request with comprehensive error handling""" + url = f"{self.base_url}/{endpoint.lstrip('/')}" + try: + response = self.session.request(method, url, **kwargs) + return response + except requests.exceptions.RequestException as e: + pytest.fail(f"Request failed: {method} {url} - {str(e)}") + + # Virtual Key operations + def list_virtual_keys(self, **params) -> requests.Response: + """List all virtual keys""" + return self.request("GET", "/virtual-keys", params=params) + + def create_virtual_key(self, data: Dict[str, Any]) -> requests.Response: + """Create a virtual key""" + return self.request("POST", "/virtual-keys", json=data) + + def get_virtual_key(self, vk_id: str) -> requests.Response: + """Get virtual key by ID""" + return self.request("GET", f"/virtual-keys/{vk_id}") + + def update_virtual_key(self, vk_id: str, data: Dict[str, Any]) -> requests.Response: + """Update virtual key""" + return self.request("PUT", f"/virtual-keys/{vk_id}", json=data) + + def delete_virtual_key(self, vk_id: str) -> requests.Response: + """Delete virtual key""" + return self.request("DELETE", f"/virtual-keys/{vk_id}") + + # Team operations + def list_teams(self, **params) -> requests.Response: + """List all teams""" + return self.request("GET", "/teams", params=params) + + def create_team(self, data: Dict[str, Any]) -> requests.Response: + """Create a team""" + return self.request("POST", "/teams", json=data) + + def get_team(self, team_id: str) -> requests.Response: + """Get team by ID""" + return self.request("GET", f"/teams/{team_id}") + + def update_team(self, team_id: str, data: Dict[str, Any]) -> requests.Response: + """Update team""" + return self.request("PUT", f"/teams/{team_id}", json=data) + + def delete_team(self, team_id: str) -> requests.Response: + """Delete team""" + return self.request("DELETE", f"/teams/{team_id}") + + # Customer operations + def list_customers(self, **params) -> requests.Response: + """List all customers""" + return self.request("GET", "/customers", params=params) + + def create_customer(self, data: Dict[str, Any]) -> requests.Response: + """Create a customer""" + return self.request("POST", "/customers", json=data) + + def get_customer(self, customer_id: str) -> requests.Response: + """Get customer by ID""" + return self.request("GET", f"/customers/{customer_id}") + + def update_customer( + self, customer_id: str, data: Dict[str, Any] + ) -> requests.Response: + """Update customer""" + return self.request("PUT", f"/customers/{customer_id}", json=data) + + def delete_customer(self, customer_id: str) -> requests.Response: + """Delete customer""" + return self.request("DELETE", f"/customers/{customer_id}") + + # Monitoring and usage operations + def get_usage_stats(self, **params) -> requests.Response: + """Get usage statistics""" + return self.request("GET", "/usage-stats", params=params) + + def reset_usage(self, data: Dict[str, Any]) -> requests.Response: + """Reset usage counters""" + return self.request("POST", "/usage-reset", json=data) + + def get_debug_stats(self) -> requests.Response: + """Get debug statistics""" + return self.request("GET", "/debug/stats") + + def get_debug_counters(self) -> requests.Response: + """Get debug counters""" + return self.request("GET", "/debug/counters") + + def get_health_check(self) -> requests.Response: + """Get health check""" + return self.request("GET", "/debug/health") + + # Chat completion for integration testing + def chat_completion( + self, + messages: List[Dict], + model: str = "gpt-3.5-turbo", + headers: Optional[Dict] = None, + **kwargs, + ) -> requests.Response: + """Make chat completion request""" + data = {"model": model, "messages": messages, **kwargs} + + session_headers = self.session.headers.copy() + if headers: + session_headers.update(headers) + + url = f"{COMPLETION_API_BASE}/chat/completions" + try: + response = requests.post(url, json=data, headers=session_headers) + return response + except requests.exceptions.RequestException as e: + pytest.fail(f"Chat completion request failed: {url} - {str(e)}") + + +class CleanupTracker: + """Tracks entities created during tests for cleanup""" + + def __init__(self): + self.virtual_keys = [] + self.teams = [] + self.customers = [] + self._lock = threading.Lock() + + def add_virtual_key(self, vk_id: str): + """Add virtual key for cleanup""" + with self._lock: + if vk_id not in self.virtual_keys: + self.virtual_keys.append(vk_id) + + def add_team(self, team_id: str): + """Add team for cleanup""" + with self._lock: + if team_id not in self.teams: + self.teams.append(team_id) + + def add_customer(self, customer_id: str): + """Add customer for cleanup""" + with self._lock: + if customer_id not in self.customers: + self.customers.append(customer_id) + + def cleanup(self, client: GovernanceTestClient): + """Cleanup all tracked entities""" + with self._lock: + # Delete in dependency order: VKs -> Teams -> Customers + for vk_id in self.virtual_keys: + try: + client.delete_virtual_key(vk_id) + except Exception: + pass # Ignore cleanup errors + + for team_id in self.teams: + try: + client.delete_team(team_id) + except Exception: + pass + + for customer_id in self.customers: + try: + client.delete_customer(customer_id) + except Exception: + pass + + # Clear lists + self.virtual_keys.clear() + self.teams.clear() + self.customers.clear() + + +# Fixtures + + +@pytest.fixture(scope="session") +def governance_client(): + """Governance API client for the session""" + return GovernanceTestClient() + + +@pytest.fixture +def cleanup_tracker(): + """Cleanup tracker for test entities""" + return CleanupTracker() + + +@pytest.fixture(autouse=True) +def auto_cleanup(cleanup_tracker, governance_client): + """Automatically cleanup test entities after each test""" + yield + cleanup_tracker.cleanup(governance_client) + + +@pytest.fixture +def sample_budget_data(): + """Sample budget data for testing""" + return {"max_limit": 10000, "reset_duration": "1h"} # $100.00 in cents + + +@pytest.fixture +def sample_rate_limit_data(): + """Sample rate limit data for testing""" + return { + "token_max_limit": 1000, + "token_reset_duration": "1m", + "request_max_limit": 100, + "request_reset_duration": "1h", + } + + +@pytest.fixture +def sample_customer(governance_client, cleanup_tracker): + """Create a sample customer for testing""" + data = {"name": f"Test Customer {uuid.uuid4().hex[:8]}"} + response = governance_client.create_customer(data) + assert response.status_code == 201 + customer_data = response.json()["customer"] + cleanup_tracker.add_customer(customer_data["id"]) + return customer_data + + +@pytest.fixture +def sample_team(governance_client, cleanup_tracker): + """Create a sample team for testing""" + data = {"name": f"Test Team {uuid.uuid4().hex[:8]}"} + response = governance_client.create_team(data) + assert response.status_code == 201 + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + return team_data + + +@pytest.fixture +def sample_team_with_customer(governance_client, cleanup_tracker, sample_customer): + """Create a sample team associated with a customer""" + data = { + "name": f"Test Team with Customer {uuid.uuid4().hex[:8]}", + "customer_id": sample_customer["id"], + } + response = governance_client.create_team(data) + assert response.status_code == 201 + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + return team_data + + +@pytest.fixture +def sample_virtual_key(governance_client, cleanup_tracker): + """Create a sample virtual key for testing""" + data = {"name": f"Test VK {uuid.uuid4().hex[:8]}"} + response = governance_client.create_virtual_key(data) + assert response.status_code == 201 + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + return vk_data + + +@pytest.fixture +def sample_virtual_key_with_team(governance_client, cleanup_tracker, sample_team): + """Create a sample virtual key associated with a team""" + data = { + "name": f"Test VK with Team {uuid.uuid4().hex[:8]}", + "team_id": sample_team["id"], + } + response = governance_client.create_virtual_key(data) + assert response.status_code == 201 + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + return vk_data + + +@pytest.fixture +def sample_virtual_key_with_customer( + governance_client, cleanup_tracker, sample_customer +): + """Create a sample virtual key associated with a customer""" + data = { + "name": f"Test VK with Customer {uuid.uuid4().hex[:8]}", + "customer_id": sample_customer["id"], + } + response = governance_client.create_virtual_key(data) + assert response.status_code == 201 + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + return vk_data + + +# Utility functions + + +def generate_unique_name(prefix: str = "Test") -> str: + """Generate a unique name for testing""" + return f"{prefix} {uuid.uuid4().hex[:8]} {int(time.time())}" + + +def wait_for_condition( + condition_func, timeout: float = 5.0, interval: float = 0.1 +) -> bool: + """Wait for a condition to be true""" + start_time = time.time() + while time.time() - start_time < timeout: + if condition_func(): + return True + time.sleep(interval) + return False + + +def assert_response_success(response: requests.Response, expected_status: int = 200): + """Assert that response is successful with expected status""" + if response.status_code != expected_status: + try: + error_data = response.json() + pytest.fail( + f"Expected status {expected_status}, got {response.status_code}: {error_data}" + ) + except: + pytest.fail( + f"Expected status {expected_status}, got {response.status_code}: {response.text}" + ) + + +def assert_field_unchanged(actual_value, expected_value, field_name: str): + """Assert that a field value hasn't changed""" + if actual_value != expected_value: + pytest.fail( + f"Field '{field_name}' changed unexpectedly. Expected: {expected_value}, Got: {actual_value}" + ) + + +def deep_compare_entities( + entity1: Dict, entity2: Dict, ignore_fields: List[str] = None +) -> List[str]: + """Deep compare two entities and return list of differences""" + if ignore_fields is None: + ignore_fields = ["updated_at", "created_at"] + + differences = [] + + def compare_values(path: str, val1, val2): + if isinstance(val1, dict) and isinstance(val2, dict): + for key in set(val1.keys()) | set(val2.keys()): + if key in ignore_fields: + continue + new_path = f"{path}.{key}" if path else key + if key not in val1: + differences.append(f"{new_path}: missing in first entity") + elif key not in val2: + differences.append(f"{new_path}: missing in second entity") + else: + compare_values(new_path, val1[key], val2[key]) + elif isinstance(val1, list) and isinstance(val2, list): + if len(val1) != len(val2): + differences.append( + f"{path}: list length differs ({len(val1)} vs {len(val2)})" + ) + else: + for i, (item1, item2) in enumerate(zip(val1, val2)): + compare_values(f"{path}[{i}]", item1, item2) + elif val1 != val2: + differences.append(f"{path}: {val1} != {val2}") + + compare_values("", entity1, entity2) + return differences + + +def create_complete_virtual_key_data( + name: str = None, + team_id: str = None, + customer_id: str = None, + include_budget: bool = True, + include_rate_limit: bool = True, +) -> Dict[str, Any]: + """Create complete virtual key data for testing""" + data = { + "name": name or generate_unique_name("Complete VK"), + "description": "Complete test virtual key with all fields", + "allowed_models": ["gpt-4", "claude-3-5-sonnet-20240620"], + "allowed_providers": ["openai", "anthropic"], + "is_active": True, + } + + if team_id: + data["team_id"] = team_id + elif customer_id: + data["customer_id"] = customer_id + + if include_budget: + data["budget"] = { + "max_limit": 50000, # $500.00 in cents + "reset_duration": "1d", + } + + if include_rate_limit: + data["rate_limit"] = { + "token_max_limit": 5000, + "token_reset_duration": "1h", + "request_max_limit": 500, + "request_reset_duration": "1h", + } + + return data + + +def verify_entity_relationships( + entity: Dict[str, Any], expected_relationships: Dict[str, Any] +): + """Verify that entity has expected relationship data loaded""" + for rel_name, expected_data in expected_relationships.items(): + if expected_data is None: + assert entity.get(rel_name) is None, f"Expected {rel_name} to be None" + else: + assert entity.get(rel_name) is not None, f"Expected {rel_name} to be loaded" + if isinstance(expected_data, dict): + for key, value in expected_data.items(): + assert ( + entity[rel_name].get(key) == value + ), f"Expected {rel_name}.{key} to be {value}" + + +def verify_unchanged_fields( + updated_entity: Dict, original_entity: Dict, exclude_fields: List[str] +): + """Verify that all fields except specified ones remain unchanged""" + ignore_fields = ["updated_at", "created_at"] + exclude_fields + + def check_field(path: str, updated_val, original_val): + if path in ignore_fields: + return + + if isinstance(updated_val, dict) and isinstance(original_val, dict): + for key in original_val.keys(): + if key not in ignore_fields: + new_path = f"{path}.{key}" if path else key + if key in updated_val: + check_field(new_path, updated_val[key], original_val[key]) + elif updated_val != original_val: + pytest.fail( + f"Field '{path}' should not have changed. Expected: {original_val}, Got: {updated_val}" + ) + + for field in original_entity.keys(): + if field not in ignore_fields: + if field in updated_entity: + check_field(field, updated_entity[field], original_entity[field]) + + +class FieldUpdateTester: + """Helper class for comprehensive field update testing""" + + def __init__(self, client: GovernanceTestClient, cleanup_tracker: CleanupTracker): + self.client = client + self.cleanup_tracker = cleanup_tracker + + def test_individual_field_updates( + self, entity_type: str, entity_id: str, field_test_cases: List[Dict] + ): + """Test updating individual fields one by one""" + + # Get original entity state + if entity_type == "virtual_key": + original_response = self.client.get_virtual_key(entity_id) + update_func = self.client.update_virtual_key + elif entity_type == "team": + original_response = self.client.get_team(entity_id) + update_func = self.client.update_team + elif entity_type == "customer": + original_response = self.client.get_customer(entity_id) + update_func = self.client.update_customer + else: + raise ValueError(f"Unknown entity type: {entity_type}") + + assert original_response.status_code == 200 + original_entity = original_response.json()[entity_type] + + for test_case in field_test_cases: + # Reset entity to original state if needed + if test_case.get("reset_before", True): + self._reset_entity_state(entity_type, entity_id, original_entity) + + # Perform field update + update_data = test_case["update_data"] + response = update_func(entity_id, update_data) + + # Verify update succeeded + assert ( + response.status_code == 200 + ), f"Field update failed for {test_case['field']}: {response.json()}" + updated_entity = response.json()[entity_type] + + # Verify target field was updated + if test_case.get("custom_validation"): + test_case["custom_validation"](updated_entity) + else: + self._verify_field_updated( + updated_entity, test_case["field"], test_case["expected_value"] + ) + + # Verify other fields unchanged if specified + if test_case.get("verify_unchanged", True): + exclude_fields = test_case.get( + "exclude_from_unchanged_check", [test_case["field"]] + ) + verify_unchanged_fields(updated_entity, original_entity, exclude_fields) + + def _reset_entity_state(self, entity_type: str, entity_id: str, target_state: Dict): + """Reset entity to target state""" + # This would require implementing a reset mechanism + # For now, we'll rely on test isolation + pass + + def _verify_field_updated(self, entity: Dict, field_path: str, expected_value): + """Verify that a field was updated to expected value""" + field_parts = field_path.split(".") + current_value = entity + + for part in field_parts: + if isinstance(current_value, dict): + current_value = current_value.get(part) + else: + pytest.fail(f"Cannot access field '{field_path}' in entity") + + assert ( + current_value == expected_value + ), f"Field '{field_path}' not updated correctly. Expected: {expected_value}, Got: {current_value}" + + +@pytest.fixture +def field_update_tester(governance_client, cleanup_tracker): + """Field update testing helper""" + return FieldUpdateTester(governance_client, cleanup_tracker) diff --git a/tests/governance/pytest.ini b/tests/governance/pytest.ini new file mode 100644 index 000000000..2f6bde148 --- /dev/null +++ b/tests/governance/pytest.ini @@ -0,0 +1,88 @@ +[tool:pytest] +# Pytest configuration for Bifrost Governance Plugin Testing + +# Test discovery +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Minimum version +minversion = 7.0 + +# Add options +addopts = + -ra + --strict-markers + --strict-config + --color=yes + --tb=short + --maxfail=10 + --durations=10 + --verbose + +# Markers for test categorization +markers = + governance: Tests for governance functionality + virtual_keys: Virtual Key CRUD and management tests + teams: Team CRUD and management tests + customers: Customer CRUD and management tests + budget: Budget-related tests + rate_limit: Rate limiting tests + usage_tracking: Usage tracking and monitoring tests + crud: CRUD operation tests + field_updates: Comprehensive field update tests + validation: Validation and constraint tests + integration: Integration and end-to-end tests + edge_cases: Edge cases and boundary condition tests + concurrency: Concurrency and race condition tests + mutual_exclusivity: Mutual exclusivity constraint tests + hierarchical: Hierarchical governance tests + slow: Tests that run slowly (> 5 seconds) + smoke: Smoke tests for quick validation + regression: Regression tests + api: API endpoint tests + relationships: Entity relationship tests + cleanup: Tests that require special cleanup + security: Security-related tests + +# Test timeout (in seconds) +timeout = 300 + +# Warnings configuration +filterwarnings = + error + ignore::UserWarning + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::requests.packages.urllib3.disable_warnings + +# Logging configuration +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +log_file = governance_tests.log +log_file_level = DEBUG +log_file_format = %(asctime)s [%(levelname)8s] %(filename)s:%(lineno)d %(funcName)s(): %(message)s +log_file_date_format = %Y-%m-%d %H:%M:%S + +# Coverage configuration (when using --cov) +[coverage:run] +source = . +omit = + */tests/* + */test_* + */__pycache__/* + */venv/* + */env/* + .tox/* + +[coverage:report] +precision = 2 +show_missing = true +skip_covered = false + +[coverage:html] +directory = htmlcov \ No newline at end of file diff --git a/tests/governance/requirements.txt b/tests/governance/requirements.txt new file mode 100644 index 000000000..c25a0301f --- /dev/null +++ b/tests/governance/requirements.txt @@ -0,0 +1,52 @@ +# Bifrost Governance Plugin Test Suite Dependencies + +# Core testing framework +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-xdist>=3.3.0 # For parallel test execution +pytest-cov>=4.1.0 # For coverage reporting +pytest-html>=3.2.0 # For HTML reports +pytest-json-report>=1.5.0 # For JSON reports +pytest-timeout>=2.1.0 # For test timeouts + +# HTTP client and API testing +requests>=2.31.0 +urllib3>=2.0.0 + +# Concurrency and async support +aiohttp>=3.8.0 + +# Data handling and validation +pydantic>=2.0.0 +jsonschema>=4.18.0 + +# Performance monitoring +psutil>=5.9.0 # For system metrics +memory-profiler>=0.61.0 # For memory profiling + +# Date/time handling +python-dateutil>=2.8.0 + +# Utilities +faker>=19.0.0 # For generating test data +factory-boy>=3.3.0 # For test data factories + +# Development and debugging +ipdb>=0.13.0 # Debugger +rich>=13.0.0 # Rich console output + +# Configuration management +python-dotenv>=1.0.0 # For environment configuration +pyyaml>=6.0 # For YAML configuration files + +# Type checking (development) +mypy>=1.5.0 # Static type checking +types-requests>=2.31.0 # Type stubs for requests + +# Testing utilities +pytest-mock>=3.11.0 # For mocking +pytest-benchmark>=4.0.0 # For benchmarking +freezegun>=1.2.0 # For time mocking + +# Load testing +locust>=2.15.0 # For load testing scenarios \ No newline at end of file diff --git a/tests/governance/test_customers_crud.py b/tests/governance/test_customers_crud.py new file mode 100644 index 000000000..7040b7f1f --- /dev/null +++ b/tests/governance/test_customers_crud.py @@ -0,0 +1,981 @@ +""" +Comprehensive Customer CRUD Tests for Bifrost Governance Plugin + +This module provides exhaustive testing of Customer operations including: +- Complete CRUD lifecycle testing +- Comprehensive field update testing (individual and batch) +- Team relationship management +- Budget management and hierarchies +- Cascading operations +- Edge cases and validation scenarios +- Concurrency and race condition testing +""" + +import pytest +import time +import uuid +from typing import Dict, Any, List +from concurrent.futures import ThreadPoolExecutor +import copy + +from conftest import ( + assert_response_success, + verify_unchanged_fields, + generate_unique_name, + verify_entity_relationships, + deep_compare_entities, +) + + +class TestCustomerBasicCRUD: + """Test basic CRUD operations for Customers""" + + @pytest.mark.customers + @pytest.mark.crud + @pytest.mark.smoke + def test_customer_create_minimal(self, governance_client, cleanup_tracker): + """Test creating customer with minimal required data""" + data = {"name": generate_unique_name("Minimal Customer")} + + response = governance_client.create_customer(data) + assert_response_success(response, 201) + + customer_data = response.json()["customer"] + cleanup_tracker.add_customer(customer_data["id"]) + + # Verify required fields + assert customer_data["name"] == data["name"] + assert customer_data["id"] is not None + assert customer_data["created_at"] is not None + assert customer_data["updated_at"] is not None + + # Verify optional fields are None/empty + assert customer_data["teams"] == [] + assert customer_data["virtual_keys"] is None + + @pytest.mark.customers + @pytest.mark.crud + @pytest.mark.budget + def test_customer_create_with_budget(self, governance_client, cleanup_tracker): + """Test creating customer with budget""" + data = { + "name": generate_unique_name("Budget Customer"), + "budget": { + "max_limit": 500000, # $5000.00 in cents + "reset_duration": "1M", + }, + } + + response = governance_client.create_customer(data) + assert_response_success(response, 201) + + customer_data = response.json()["customer"] + cleanup_tracker.add_customer(customer_data["id"]) + + # Verify budget was created + assert customer_data["budget"] is not None + assert customer_data["budget"]["max_limit"] == 500000 + assert customer_data["budget"]["reset_duration"] == "1M" + assert customer_data["budget"]["current_usage"] == 0 + assert customer_data["budget_id"] is not None + + @pytest.mark.customers + @pytest.mark.crud + def test_customer_list_all(self, governance_client, sample_customer): + """Test listing all customers""" + response = governance_client.list_customers() + assert_response_success(response, 200) + + data = response.json() + assert "customers" in data + assert "count" in data + assert isinstance(data["customers"], list) + assert data["count"] >= 1 + + # Find our test customer + test_customer = next( + ( + customer + for customer in data["customers"] + if customer["id"] == sample_customer["id"] + ), + None, + ) + assert test_customer is not None + + @pytest.mark.customers + @pytest.mark.crud + def test_customer_get_by_id(self, governance_client, sample_customer): + """Test getting customer by ID with relationships loaded""" + response = governance_client.get_customer(sample_customer["id"]) + assert_response_success(response, 200) + + customer_data = response.json()["customer"] + assert customer_data["id"] == sample_customer["id"] + assert customer_data["name"] == sample_customer["name"] + + # Verify teams relationship is loaded (empty list if no teams) + assert "teams" in customer_data + assert ( + isinstance(customer_data["teams"], list) or customer_data["teams"] is None + ) + + @pytest.mark.customers + @pytest.mark.crud + def test_customer_get_nonexistent(self, governance_client): + """Test getting non-existent customer returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.get_customer(fake_id) + assert response.status_code == 404 + + @pytest.mark.customers + @pytest.mark.crud + def test_customer_delete(self, governance_client, cleanup_tracker): + """Test deleting a customer""" + # Create customer to delete + data = {"name": generate_unique_name("Delete Test Customer")} + create_response = governance_client.create_customer(data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + + # Delete customer + delete_response = governance_client.delete_customer(customer_id) + assert_response_success(delete_response, 200) + + # Verify customer is gone + get_response = governance_client.get_customer(customer_id) + assert get_response.status_code == 404 + + @pytest.mark.customers + @pytest.mark.crud + def test_customer_delete_nonexistent(self, governance_client): + """Test deleting non-existent customer returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.delete_customer(fake_id) + assert response.status_code == 404 + + +class TestCustomerValidation: + """Test validation rules for Customer operations""" + + @pytest.mark.customers + @pytest.mark.validation + def test_customer_create_missing_name(self, governance_client): + """Test creating customer without name fails""" + data = {"budget": {"max_limit": 1000, "reset_duration": "1h"}} + response = governance_client.create_customer(data) + assert response.status_code == 400 + + @pytest.mark.customers + @pytest.mark.validation + def test_customer_create_empty_name(self, governance_client): + """Test creating customer with empty name fails""" + data = {"name": ""} + response = governance_client.create_customer(data) + assert response.status_code == 400 + + @pytest.mark.customers + @pytest.mark.validation + def test_customer_create_invalid_budget(self, governance_client): + """Test creating customer with invalid budget data""" + # Test negative budget + data = { + "name": generate_unique_name("Negative Budget Customer"), + "budget": {"max_limit": -10000, "reset_duration": "1h"}, + } + response = governance_client.create_customer(data) + assert response.status_code == 400 + + # Test invalid reset duration + data = { + "name": generate_unique_name("Invalid Duration Customer"), + "budget": {"max_limit": 10000, "reset_duration": "invalid_duration"}, + } + response = governance_client.create_customer(data) + assert response.status_code == 400 + + @pytest.mark.customers + @pytest.mark.validation + def test_customer_create_invalid_json(self, governance_client): + """Test creating customer with invalid data types""" + data = { + "name": 12345, # Should be string + "budget": "not_an_object", # Should be object + } + response = governance_client.create_customer(data) + assert response.status_code == 400 + + +class TestCustomerFieldUpdates: + """Comprehensive tests for Customer field updates""" + + @pytest.mark.customers + @pytest.mark.field_updates + def test_customer_update_individual_fields( + self, governance_client, cleanup_tracker + ): + """Test updating each customer field individually""" + # Create customer with all fields for testing + original_data = { + "name": generate_unique_name("Complete Update Test Customer"), + "budget": {"max_limit": 250000, "reset_duration": "1w"}, + } + create_response = governance_client.create_customer(original_data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + cleanup_tracker.add_customer(customer_id) + + # Get original state + original_response = governance_client.get_customer(customer_id) + original_customer = original_response.json()["customer"] + + # Test individual field updates + field_test_cases = [ + { + "field": "name", + "update_data": {"name": "Updated Customer Name"}, + "expected_value": "Updated Customer Name", + } + ] + + for test_case in field_test_cases: + # Reset customer to original state + reset_data = {"name": original_customer["name"]} + governance_client.update_customer(customer_id, reset_data) + + # Perform field update + response = governance_client.update_customer( + customer_id, test_case["update_data"] + ) + assert_response_success(response, 200) + updated_customer = response.json()["customer"] + + # Verify target field was updated + if test_case.get("custom_validation"): + test_case["custom_validation"](updated_customer) + else: + field_parts = test_case["field"].split(".") + current_value = updated_customer + for part in field_parts: + current_value = current_value[part] + assert ( + current_value == test_case["expected_value"] + ), f"Field {test_case['field']} not updated correctly" + + # Verify other fields unchanged (if specified) + if test_case.get("verify_unchanged", True): + exclude_fields = test_case.get( + "exclude_from_unchanged_check", [test_case["field"]] + ) + verify_unchanged_fields( + updated_customer, original_customer, exclude_fields + ) + + @pytest.mark.customers + @pytest.mark.field_updates + @pytest.mark.budget + def test_customer_budget_updates(self, governance_client, cleanup_tracker): + """Test comprehensive budget creation, update, and modification""" + # Create customer without budget + data = {"name": generate_unique_name("Budget Update Test Customer")} + create_response = governance_client.create_customer(data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + cleanup_tracker.add_customer(customer_id) + + # Test 1: Add budget to customer without budget + budget_data = {"max_limit": 100000, "reset_duration": "1M"} + response = governance_client.update_customer( + customer_id, {"budget": budget_data} + ) + assert_response_success(response, 200) + updated_customer = response.json()["customer"] + assert updated_customer["budget"]["max_limit"] == 100000 + assert updated_customer["budget"]["reset_duration"] == "1M" + assert updated_customer["budget_id"] is not None + + # Test 2: Update existing budget completely + new_budget_data = {"max_limit": 200000, "reset_duration": "3M"} + response = governance_client.update_customer( + customer_id, {"budget": new_budget_data} + ) + assert_response_success(response, 200) + updated_customer = response.json()["customer"] + assert updated_customer["budget"]["max_limit"] == 200000 + assert updated_customer["budget"]["reset_duration"] == "3M" + + # Test 3: Partial budget update (only max_limit) + response = governance_client.update_customer( + customer_id, {"budget": {"max_limit": 300000}} + ) + assert_response_success(response, 200) + updated_customer = response.json()["customer"] + assert updated_customer["budget"]["max_limit"] == 300000 + assert ( + updated_customer["budget"]["reset_duration"] == "3M" + ) # Should remain unchanged + + # Test 4: Partial budget update (only reset_duration) + response = governance_client.update_customer( + customer_id, {"budget": {"reset_duration": "6M"}} + ) + assert_response_success(response, 200) + updated_customer = response.json()["customer"] + assert ( + updated_customer["budget"]["max_limit"] == 300000 + ) # Should remain unchanged + assert updated_customer["budget"]["reset_duration"] == "6M" + + @pytest.mark.customers + @pytest.mark.field_updates + def test_customer_multiple_field_updates(self, governance_client, cleanup_tracker): + """Test updating multiple fields simultaneously""" + # Create customer with initial data + initial_data = { + "name": generate_unique_name("Multi-Field Test Customer"), + } + create_response = governance_client.create_customer(initial_data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + cleanup_tracker.add_customer(customer_id) + + # Update multiple fields at once + update_data = { + "name": "Updated Multi-Field Customer Name", + "budget": {"max_limit": 500000, "reset_duration": "1Y"}, + } + + response = governance_client.update_customer(customer_id, update_data) + assert_response_success(response, 200) + + updated_customer = response.json()["customer"] + assert updated_customer["name"] == "Updated Multi-Field Customer Name" + assert updated_customer["budget"]["max_limit"] == 500000 + assert updated_customer["budget"]["reset_duration"] == "1Y" + + @pytest.mark.customers + @pytest.mark.field_updates + @pytest.mark.edge_cases + def test_customer_update_edge_cases(self, governance_client, cleanup_tracker): + """Test edge cases in customer updates""" + # Create test customer + data = {"name": generate_unique_name("Edge Case Customer")} + create_response = governance_client.create_customer(data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + cleanup_tracker.add_customer(customer_id) + + original_response = governance_client.get_customer(customer_id) + original_customer = original_response.json()["customer"] + + # Test 1: Empty update (should return unchanged customer) + response = governance_client.update_customer(customer_id, {}) + assert_response_success(response, 200) + updated_customer = response.json()["customer"] + + # Compare ignoring timestamps + differences = deep_compare_entities( + updated_customer, original_customer, ignore_fields=["updated_at"] + ) + assert len(differences) == 0, f"Empty update changed fields: {differences}" + + # Test 2: Update with same values + response = governance_client.update_customer( + customer_id, {"name": original_customer["name"]} + ) + assert_response_success(response, 200) + + # Test 3: Very long customer name (test field length limits) + long_name = "x" * 1000 # Adjust based on actual field limits + response = governance_client.update_customer(customer_id, {"name": long_name}) + # Expected behavior depends on API validation rules + + @pytest.mark.customers + @pytest.mark.field_updates + def test_customer_update_nonexistent(self, governance_client): + """Test updating non-existent customer returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.update_customer(fake_id, {"name": "test"}) + assert response.status_code == 404 + + +class TestCustomerBudgetManagement: + """Test customer budget specific functionality""" + + @pytest.mark.customers + @pytest.mark.budget + def test_customer_budget_creation_and_validation( + self, governance_client, cleanup_tracker + ): + """Test budget creation with various configurations""" + # Test valid budget configurations + budget_test_cases = [ + {"max_limit": 50000, "reset_duration": "1d"}, + {"max_limit": 250000, "reset_duration": "1w"}, + {"max_limit": 1000000, "reset_duration": "1M"}, + {"max_limit": 5000000, "reset_duration": "3M"}, + {"max_limit": 10000000, "reset_duration": "1Y"}, + ] + + for budget_config in budget_test_cases: + data = { + "name": generate_unique_name( + f"Budget Customer {budget_config['reset_duration']}" + ), + "budget": budget_config, + } + + response = governance_client.create_customer(data) + assert_response_success(response, 201) + + customer_data = response.json()["customer"] + cleanup_tracker.add_customer(customer_data["id"]) + + assert customer_data["budget"]["max_limit"] == budget_config["max_limit"] + assert ( + customer_data["budget"]["reset_duration"] + == budget_config["reset_duration"] + ) + assert customer_data["budget"]["current_usage"] == 0 + assert customer_data["budget"]["last_reset"] is not None + + @pytest.mark.customers + @pytest.mark.budget + @pytest.mark.edge_cases + def test_customer_budget_edge_cases(self, governance_client, cleanup_tracker): + """Test budget edge cases and boundary conditions""" + # Test boundary values + edge_case_budgets = [ + {"max_limit": 0, "reset_duration": "1h"}, # Zero budget + {"max_limit": 1, "reset_duration": "1s"}, # Minimal values + {"max_limit": 9223372036854775807, "reset_duration": "1h"}, # Max int64 + ] + + for budget_config in edge_case_budgets: + data = { + "name": generate_unique_name( + f"Edge Budget Customer {budget_config['max_limit']}" + ), + "budget": budget_config, + } + + response = governance_client.create_customer(data) + # Adjust assertions based on API validation rules + if ( + budget_config["max_limit"] >= 0 + ): # Assuming non-negative budgets are valid + assert_response_success(response, 201) + cleanup_tracker.add_customer(response.json()["customer"]["id"]) + else: + assert response.status_code == 400 + + @pytest.mark.customers + @pytest.mark.budget + @pytest.mark.hierarchical + def test_customer_budget_hierarchy_foundation( + self, governance_client, cleanup_tracker + ): + """Test customer budget as foundation of hierarchical budget system""" + # Create customer with large budget (top of hierarchy) + customer_data = { + "name": generate_unique_name("Hierarchy Foundation Customer"), + "budget": {"max_limit": 1000000, "reset_duration": "1M"}, # $10,000 + } + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create teams under this customer with smaller budgets + team1_data = { + "name": generate_unique_name("Sub-Team 1"), + "customer_id": customer["id"], + "budget": {"max_limit": 300000, "reset_duration": "1M"}, # $3,000 + } + team1_response = governance_client.create_team(team1_data) + assert_response_success(team1_response, 201) + team1 = team1_response.json()["team"] + cleanup_tracker.add_team(team1["id"]) + + team2_data = { + "name": generate_unique_name("Sub-Team 2"), + "customer_id": customer["id"], + "budget": {"max_limit": 200000, "reset_duration": "1M"}, # $2,000 + } + team2_response = governance_client.create_team(team2_data) + assert_response_success(team2_response, 201) + team2 = team2_response.json()["team"] + cleanup_tracker.add_team(team2["id"]) + + # Create VKs under teams with even smaller budgets + vk1_data = { + "name": generate_unique_name("Team1 VK"), + "team_id": team1["id"], + "budget": {"max_limit": 100000, "reset_duration": "1M"}, # $1,000 + } + vk1_response = governance_client.create_virtual_key(vk1_data) + assert_response_success(vk1_response, 201) + vk1 = vk1_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk1["id"]) + + # Verify hierarchy structure + assert customer["budget"]["max_limit"] == 1000000 + assert team1["budget"]["max_limit"] == 300000 + assert team2["budget"]["max_limit"] == 200000 + assert vk1["budget"]["max_limit"] == 100000 + + # Verify relationships + assert team1["customer_id"] == customer["id"] + assert team2["customer_id"] == customer["id"] + assert vk1["team_id"] == team1["id"] + + @pytest.mark.customers + @pytest.mark.budget + def test_customer_budget_large_scale(self, governance_client, cleanup_tracker): + """Test customer budgets for large enterprise scenarios""" + # Test very large budget for enterprise customer + enterprise_data = { + "name": generate_unique_name("Enterprise Customer"), + "budget": { + "max_limit": 100000000000, # $1 billion in cents + "reset_duration": "1Y", + }, + } + + response = governance_client.create_customer(enterprise_data) + assert_response_success(response, 201) + customer = response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + assert customer["budget"]["max_limit"] == 100000000000 + assert customer["budget"]["reset_duration"] == "1Y" + + +class TestCustomerTeamRelationships: + """Test customer relationships with teams""" + + @pytest.mark.customers + @pytest.mark.relationships + def test_customer_teams_relationship_loading( + self, governance_client, cleanup_tracker + ): + """Test that customer properly loads teams relationships""" + # Create customer + customer_data = {"name": generate_unique_name("Team Parent Customer")} + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create teams under this customer + team_names = [] + for i in range(3): + team_name = generate_unique_name(f"Customer Team {i}") + team_names.append(team_name) + team_data = {"name": team_name, "customer_id": customer["id"]} + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + cleanup_tracker.add_team(team_response.json()["team"]["id"]) + + # Fetch customer with teams loaded + customer_response = governance_client.get_customer(customer["id"]) + assert_response_success(customer_response, 200) + customer_with_teams = customer_response.json()["customer"] + + # Verify teams relationship loaded + assert "teams" in customer_with_teams + teams = customer_with_teams["teams"] + assert isinstance(teams, list) + assert len(teams) == 3 + + # Verify all team names are present + loaded_team_names = {team["name"] for team in teams} + for name in team_names: + assert name in loaded_team_names + + # Verify all teams have correct customer_id + for team in teams: + assert team["customer_id"] == customer["id"] + + @pytest.mark.customers + @pytest.mark.relationships + def test_customer_with_no_teams(self, governance_client, cleanup_tracker): + """Test customer with no teams has empty teams list""" + # Create customer without teams + customer_data = {"name": generate_unique_name("No Teams Customer")} + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Fetch customer with teams loaded + customer_response = governance_client.get_customer(customer["id"]) + assert_response_success(customer_response, 200) + customer_data = customer_response.json()["customer"] + + # Teams should be empty list or None + teams = customer_data.get("teams") + assert teams == [] or teams is None + + @pytest.mark.customers + @pytest.mark.relationships + def test_customer_teams_cascading_operations( + self, governance_client, cleanup_tracker + ): + """Test cascading operations between customers and teams""" + # Create customer + customer_data = {"name": generate_unique_name("Cascade Test Customer")} + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create teams under customer + team_ids = [] + for i in range(2): + team_data = { + "name": generate_unique_name(f"Cascade Team {i}"), + "customer_id": customer["id"], + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + team_id = team_response.json()["team"]["id"] + team_ids.append(team_id) + cleanup_tracker.add_team(team_id) + + # Create VKs under teams + vk_ids = [] + for team_id in team_ids: + vk_data = {"name": generate_unique_name("Cascade VK"), "team_id": team_id} + vk_response = governance_client.create_virtual_key(vk_data) + assert_response_success(vk_response, 201) + vk_id = vk_response.json()["virtual_key"]["id"] + vk_ids.append(vk_id) + cleanup_tracker.add_virtual_key(vk_id) + + # Verify all entities exist and are properly linked + customer_response = governance_client.get_customer(customer["id"]) + customer_with_teams = customer_response.json()["customer"] + assert len(customer_with_teams["teams"]) == 2 + + for vk_id in vk_ids: + vk_response = governance_client.get_virtual_key(vk_id) + vk = vk_response.json()["virtual_key"] + assert vk["team"] is not None + assert vk["team"]["customer_id"] == customer["id"] + + @pytest.mark.customers + @pytest.mark.relationships + @pytest.mark.edge_cases + def test_customer_orphaned_teams_handling(self, governance_client, cleanup_tracker): + """Test customer behavior when teams reference non-existent customer""" + # This test simulates data integrity issues + # In practice, this would be prevented by foreign key constraints + + # Create customer and team + customer_data = {"name": generate_unique_name("Temp Customer")} + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + team_data = { + "name": generate_unique_name("Orphan Test Team"), + "customer_id": customer["id"], + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + team = team_response.json()["team"] + cleanup_tracker.add_team(team["id"]) + + # If we were to delete the customer, what happens to the team? + # This depends on database constraints and API implementation + # For now, we just verify the relationship exists correctly + assert team["customer_id"] == customer["id"] + assert team["customer"]["id"] == customer["id"] + + +class TestCustomerConcurrency: + """Test concurrent operations on Customers""" + + @pytest.mark.customers + @pytest.mark.concurrency + @pytest.mark.slow + def test_customer_concurrent_creation(self, governance_client, cleanup_tracker): + """Test creating multiple customers concurrently""" + + def create_customer(index): + data = {"name": generate_unique_name(f"Concurrent Customer {index}")} + response = governance_client.create_customer(data) + return response + + # Create 10 customers concurrently + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(create_customer, i) for i in range(10)] + responses = [future.result() for future in futures] + + # Verify all succeeded + created_customers = [] + for response in responses: + assert_response_success(response, 201) + customer_data = response.json()["customer"] + created_customers.append(customer_data) + cleanup_tracker.add_customer(customer_data["id"]) + + # Verify all customers have unique IDs + customer_ids = [customer["id"] for customer in created_customers] + assert len(set(customer_ids)) == 10 # All unique IDs + + @pytest.mark.customers + @pytest.mark.concurrency + @pytest.mark.slow + def test_customer_concurrent_updates(self, governance_client, cleanup_tracker): + """Test updating same customer concurrently""" + # Create customer to update + data = {"name": generate_unique_name("Concurrent Update Customer")} + create_response = governance_client.create_customer(data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + cleanup_tracker.add_customer(customer_id) + + # Update concurrently with different names + def update_customer(index): + update_data = {"name": f"Updated by thread {index}"} + response = governance_client.update_customer(customer_id, update_data) + return response, index + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(update_customer, i) for i in range(5)] + results = [future.result() for future in futures] + + # All updates should succeed (last one wins) + for response, index in results: + assert_response_success(response, 200) + + # Verify final state + final_response = governance_client.get_customer(customer_id) + final_customer = final_response.json()["customer"] + assert final_customer["name"].startswith("Updated by thread") + + @pytest.mark.customers + @pytest.mark.concurrency + @pytest.mark.slow + def test_customer_concurrent_budget_updates( + self, governance_client, cleanup_tracker + ): + """Test concurrent budget updates on same customer""" + # Create customer with budget + data = { + "name": generate_unique_name("Concurrent Budget Customer"), + "budget": {"max_limit": 100000, "reset_duration": "1d"}, + } + create_response = governance_client.create_customer(data) + assert_response_success(create_response, 201) + customer_id = create_response.json()["customer"]["id"] + cleanup_tracker.add_customer(customer_id) + + # Update budget concurrently with different limits + def update_budget(index): + limit = 100000 + (index * 10000) # Different limits + update_data = {"budget": {"max_limit": limit}} + response = governance_client.update_customer(customer_id, update_data) + return response, limit + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(update_budget, i) for i in range(5)] + results = [future.result() for future in futures] + + # All updates should succeed + for response, limit in results: + assert_response_success(response, 200) + + # Verify final state has one of the updated limits + final_response = governance_client.get_customer(customer_id) + final_customer = final_response.json()["customer"] + final_limit = final_customer["budget"]["max_limit"] + expected_limits = [100000 + (i * 10000) for i in range(5)] + assert final_limit in expected_limits + + +class TestCustomerComplexScenarios: + """Test complex scenarios involving customers""" + + @pytest.mark.customers + @pytest.mark.hierarchical + @pytest.mark.slow + def test_customer_large_hierarchy_creation( + self, governance_client, cleanup_tracker + ): + """Test creating large hierarchical structure under customer""" + # Create customer + customer_data = { + "name": generate_unique_name("Large Hierarchy Customer"), + "budget": {"max_limit": 10000000, "reset_duration": "1M"}, # $100,000 + } + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create multiple teams + team_ids = [] + for i in range(5): + team_data = { + "name": generate_unique_name(f"Large Hierarchy Team {i}"), + "customer_id": customer["id"], + "budget": { + "max_limit": 1000000, + "reset_duration": "1M", + }, # $10,000 each + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + team_id = team_response.json()["team"]["id"] + team_ids.append(team_id) + cleanup_tracker.add_team(team_id) + + # Create multiple VKs per team + vk_count = 0 + for team_id in team_ids: + for j in range(3): # 3 VKs per team + vk_data = { + "name": generate_unique_name(f"Large Hierarchy VK {team_id}-{j}"), + "team_id": team_id, + "budget": { + "max_limit": 100000, + "reset_duration": "1M", + }, # $1,000 each + } + vk_response = governance_client.create_virtual_key(vk_data) + assert_response_success(vk_response, 201) + vk_id = vk_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + vk_count += 1 + + # Verify hierarchy structure + customer_response = governance_client.get_customer(customer["id"]) + customer_with_teams = customer_response.json()["customer"] + + assert len(customer_with_teams["teams"]) == 5 + assert vk_count == 15 # 5 teams * 3 VKs each + + # Verify budget hierarchy makes sense + total_team_budgets = sum( + team.get("budget", {}).get("max_limit", 0) + for team in customer_with_teams["teams"] + ) + assert ( + total_team_budgets <= customer["budget"]["max_limit"] + ) # Teams shouldn't exceed customer + + @pytest.mark.customers + @pytest.mark.performance + @pytest.mark.slow + def test_customer_performance_with_many_teams( + self, governance_client, cleanup_tracker + ): + """Test customer performance when loading many teams""" + # Create customer + customer_data = {"name": generate_unique_name("Performance Test Customer")} + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create many teams + team_count = 50 # Adjust based on performance requirements + start_time = time.time() + + for i in range(team_count): + team_data = { + "name": generate_unique_name(f"Perf Team {i}"), + "customer_id": customer["id"], + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + cleanup_tracker.add_team(team_response.json()["team"]["id"]) + + creation_time = time.time() - start_time + + # Test customer loading performance + start_time = time.time() + customer_response = governance_client.get_customer(customer["id"]) + assert_response_success(customer_response, 200) + load_time = time.time() - start_time + + customer_with_teams = customer_response.json()["customer"] + assert len(customer_with_teams["teams"]) == team_count + + # Log performance metrics (adjust thresholds based on requirements) + print(f"Created {team_count} teams in {creation_time:.2f}s") + print(f"Loaded customer with {team_count} teams in {load_time:.2f}s") + + # Performance assertions (adjust based on requirements) + assert ( + load_time < 5.0 + ), f"Loading customer with {team_count} teams took too long: {load_time}s" + + @pytest.mark.customers + @pytest.mark.integration + def test_customer_full_lifecycle_scenario(self, governance_client, cleanup_tracker): + """Test complete customer lifecycle scenario""" + # 1. Create customer with budget + customer_data = { + "name": generate_unique_name("Lifecycle Customer"), + "budget": {"max_limit": 1000000, "reset_duration": "1M"}, + } + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # 2. Update customer name and budget + update_data = { + "name": "Updated Lifecycle Customer", + "budget": {"max_limit": 2000000, "reset_duration": "3M"}, + } + update_response = governance_client.update_customer(customer["id"], update_data) + assert_response_success(update_response, 200) + updated_customer = update_response.json()["customer"] + assert updated_customer["name"] == "Updated Lifecycle Customer" + assert updated_customer["budget"]["max_limit"] == 2000000 + + # 3. Create teams under customer + team_data = { + "name": generate_unique_name("Lifecycle Team"), + "customer_id": customer["id"], + "budget": {"max_limit": 500000, "reset_duration": "1M"}, + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + team = team_response.json()["team"] + cleanup_tracker.add_team(team["id"]) + + # 4. Create VKs under team + vk_data = { + "name": generate_unique_name("Lifecycle VK"), + "team_id": team["id"], + "budget": {"max_limit": 100000, "reset_duration": "1d"}, + } + vk_response = governance_client.create_virtual_key(vk_data) + assert_response_success(vk_response, 201) + vk = vk_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk["id"]) + + # 5. Verify complete hierarchy + final_customer_response = governance_client.get_customer(customer["id"]) + final_customer = final_customer_response.json()["customer"] + + assert final_customer["name"] == "Updated Lifecycle Customer" + assert len(final_customer["teams"]) == 1 + assert final_customer["teams"][0]["id"] == team["id"] + + final_vk_response = governance_client.get_virtual_key(vk["id"]) + final_vk = final_vk_response.json()["virtual_key"] + + # Verify VK belongs to team (customer relationship not preloaded in VK->team) + assert final_vk["team"]["id"] == team["id"] + assert final_vk["team"].get("customer_id") == customer["id"] + + # 6. Clean up (automatic via cleanup_tracker) + # This tests the full CRUD lifecycle diff --git a/tests/governance/test_helpers.py b/tests/governance/test_helpers.py new file mode 100644 index 000000000..605f8f398 --- /dev/null +++ b/tests/governance/test_helpers.py @@ -0,0 +1,644 @@ +""" +Helper utilities and test data generators for Bifrost Governance Plugin tests. + +This module provides additional utilities for test data generation, validation, +and common test operations to support the comprehensive governance test suite. +""" + +import pytest +import uuid +import time +import json +import random +from typing import Dict, Any, List, Optional, Union +from datetime import datetime, timedelta +from faker import Faker + +from conftest import assert_response_success, generate_unique_name, GovernanceTestClient + +# Initialize Faker for generating test data +fake = Faker() + + +class TestDataFactory: + """Factory for generating realistic test data""" + + @staticmethod + def generate_budget_config( + min_limit: int = 1000, + max_limit: int = 1000000, + duration_options: List[str] = None, + ) -> Dict[str, Any]: + """Generate realistic budget configuration""" + if duration_options is None: + duration_options = ["1h", "1d", "1w", "1M", "3M", "6M", "1Y"] + + return { + "max_limit": random.randint(min_limit, max_limit), + "reset_duration": random.choice(duration_options), + } + + @staticmethod + def generate_rate_limit_config( + include_tokens: bool = True, include_requests: bool = True + ) -> Dict[str, Any]: + """Generate realistic rate limit configuration""" + config = {} + + if include_tokens: + config.update( + { + "token_max_limit": random.randint(100, 100000), + "token_reset_duration": random.choice(["1m", "5m", "1h", "1d"]), + } + ) + + if include_requests: + config.update( + { + "request_max_limit": random.randint(10, 10000), + "request_reset_duration": random.choice(["1m", "5m", "1h", "1d"]), + } + ) + + return config + + @staticmethod + def generate_customer_data(include_budget: bool = False) -> Dict[str, Any]: + """Generate realistic customer data""" + data = {"name": f"{fake.company()} ({generate_unique_name('Customer')})"} + + if include_budget: + data["budget"] = TestDataFactory.generate_budget_config( + min_limit=100000, max_limit=10000000 # Customers have larger budgets + ) + + return data + + @staticmethod + def generate_team_data( + customer_id: Optional[str] = None, include_budget: bool = False + ) -> Dict[str, Any]: + """Generate realistic team data""" + team_types = [ + "Engineering", + "Marketing", + "Sales", + "Research", + "Support", + "Operations", + ] + data = { + "name": f"{random.choice(team_types)} Team ({generate_unique_name('Team')})" + } + + if customer_id: + data["customer_id"] = customer_id + + if include_budget: + data["budget"] = TestDataFactory.generate_budget_config( + min_limit=10000, max_limit=1000000 # Teams have medium budgets + ) + + return data + + @staticmethod + def generate_virtual_key_data( + team_id: Optional[str] = None, + customer_id: Optional[str] = None, + include_budget: bool = False, + include_rate_limit: bool = False, + model_restrictions: bool = False, + ) -> Dict[str, Any]: + """Generate realistic virtual key data""" + purposes = [ + "Development", + "Production", + "Testing", + "Staging", + "Demo", + "Research", + ] + data = { + "name": f"{random.choice(purposes)} VK ({generate_unique_name('VK')})", + "description": fake.sentence(), + "is_active": random.choice([True, True, True, False]), # 75% active + } + + if team_id: + data["team_id"] = team_id + elif customer_id: + data["customer_id"] = customer_id + + if model_restrictions: + all_models = [ + "gpt-4", + "gpt-3.5-turbo", + "gpt-4-turbo", + "claude-3-5-sonnet-20240620", + "claude-3-7-sonnet-20250219", + ] + all_providers = ["openai", "anthropic"] + + data["allowed_models"] = random.sample( + all_models, random.randint(1, len(all_models)) + ) + data["allowed_providers"] = random.sample( + all_providers, random.randint(1, len(all_providers)) + ) + + if include_budget: + data["budget"] = TestDataFactory.generate_budget_config( + min_limit=1000, max_limit=100000 # VKs have smaller budgets + ) + + if include_rate_limit: + data["rate_limit"] = TestDataFactory.generate_rate_limit_config() + + return data + + +class ValidationHelper: + """Helper functions for validating test results""" + + @staticmethod + def validate_entity_structure( + entity: Dict[str, Any], entity_type: str + ) -> List[str]: + """Validate that entity has expected structure""" + errors = [] + + # Common fields all entities should have + required_fields = ["id", "created_at", "updated_at"] + for field in required_fields: + if field not in entity: + errors.append(f"Missing required field: {field}") + elif entity[field] is None: + errors.append(f"Required field is None: {field}") + + # Entity-specific validation + if entity_type == "virtual_key": + vk_fields = ["name", "value", "is_active"] + for field in vk_fields: + if field not in entity: + errors.append(f"VK missing field: {field}") + + elif entity_type == "team": + team_fields = ["name"] + for field in team_fields: + if field not in entity: + errors.append(f"Team missing field: {field}") + + elif entity_type == "customer": + customer_fields = ["name"] + for field in customer_fields: + if field not in entity: + errors.append(f"Customer missing field: {field}") + + return errors + + @staticmethod + def validate_budget_structure(budget: Dict[str, Any]) -> List[str]: + """Validate budget structure""" + errors = [] + required_fields = [ + "id", + "max_limit", + "reset_duration", + "current_usage", + "last_reset", + ] + + for field in required_fields: + if field not in budget: + errors.append(f"Budget missing field: {field}") + + if budget.get("max_limit") is not None and budget["max_limit"] < 0: + errors.append("Budget max_limit cannot be negative") + + if budget.get("current_usage") is not None and budget["current_usage"] < 0: + errors.append("Budget current_usage cannot be negative") + + return errors + + @staticmethod + def validate_rate_limit_structure(rate_limit: Dict[str, Any]) -> List[str]: + """Validate rate limit structure""" + errors = [] + required_fields = ["id"] + + for field in required_fields: + if field not in rate_limit: + errors.append(f"Rate limit missing field: {field}") + + # At least one limit should be specified + token_fields = ["token_max_limit", "token_reset_duration"] + request_fields = ["request_max_limit", "request_reset_duration"] + + has_token_limits = any( + rate_limit.get(field) is not None for field in token_fields + ) + has_request_limits = any( + rate_limit.get(field) is not None for field in request_fields + ) + + if not has_token_limits and not has_request_limits: + errors.append("Rate limit must have either token or request limits") + + return errors + + @staticmethod + def validate_hierarchy_consistency( + customer: Dict, teams: List[Dict], vks: List[Dict] + ) -> List[str]: + """Validate hierarchical consistency""" + errors = [] + + # Check team customer references + for team in teams: + if team.get("customer_id") != customer["id"]: + errors.append(f"Team {team['id']} has incorrect customer_id") + + # Check VK team references + team_ids = {team["id"] for team in teams} + for vk in vks: + if vk.get("team_id") and vk["team_id"] not in team_ids: + errors.append(f"VK {vk['id']} references non-existent team") + + return errors + + +class TestScenarioBuilder: + """Builder for complex test scenarios""" + + def __init__(self, client: GovernanceTestClient, cleanup_tracker): + self.client = client + self.cleanup_tracker = cleanup_tracker + self.created_entities = {"customers": [], "teams": [], "virtual_keys": []} + + def create_customer(self, **kwargs) -> Dict[str, Any]: + """Create a customer with automatic cleanup tracking""" + data = TestDataFactory.generate_customer_data(**kwargs) + response = self.client.create_customer(data) + assert_response_success(response, 201) + + customer = response.json()["customer"] + self.cleanup_tracker.add_customer(customer["id"]) + self.created_entities["customers"].append(customer) + return customer + + def create_team( + self, customer_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Create a team with automatic cleanup tracking""" + data = TestDataFactory.generate_team_data(customer_id=customer_id, **kwargs) + response = self.client.create_team(data) + assert_response_success(response, 201) + + team = response.json()["team"] + self.cleanup_tracker.add_team(team["id"]) + self.created_entities["teams"].append(team) + return team + + def create_virtual_key( + self, team_id: Optional[str] = None, customer_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Create a virtual key with automatic cleanup tracking""" + data = TestDataFactory.generate_virtual_key_data( + team_id=team_id, customer_id=customer_id, **kwargs + ) + response = self.client.create_virtual_key(data) + assert_response_success(response, 201) + + vk = response.json()["virtual_key"] + self.cleanup_tracker.add_virtual_key(vk["id"]) + self.created_entities["virtual_keys"].append(vk) + return vk + + def create_simple_hierarchy(self) -> Dict[str, Any]: + """Create a simple Customer -> Team -> VK hierarchy""" + customer = self.create_customer(include_budget=True) + team = self.create_team(customer_id=customer["id"], include_budget=True) + vk = self.create_virtual_key( + team_id=team["id"], include_budget=True, include_rate_limit=True + ) + + return {"customer": customer, "team": team, "virtual_key": vk} + + def create_complex_hierarchy( + self, team_count: int = 3, vk_per_team: int = 2 + ) -> Dict[str, Any]: + """Create a complex hierarchy with multiple teams and VKs""" + customer = self.create_customer(include_budget=True) + + teams = [] + for i in range(team_count): + team = self.create_team(customer_id=customer["id"], include_budget=True) + teams.append(team) + + vks = [] + for team in teams: + for j in range(vk_per_team): + vk = self.create_virtual_key( + team_id=team["id"], + include_budget=True, + include_rate_limit=True, + model_restrictions=random.choice([True, False]), + ) + vks.append(vk) + + return {"customer": customer, "teams": teams, "virtual_keys": vks} + + def create_mixed_vk_associations(self) -> Dict[str, Any]: + """Create VKs with mixed team/customer associations""" + customer = self.create_customer(include_budget=True) + team = self.create_team(customer_id=customer["id"], include_budget=True) + + # VK directly associated with customer + customer_vk = self.create_virtual_key( + customer_id=customer["id"], include_budget=True + ) + + # VK associated with team (indirect customer association) + team_vk = self.create_virtual_key(team_id=team["id"], include_budget=True) + + # Standalone VK + standalone_vk = self.create_virtual_key( + include_budget=True, include_rate_limit=True + ) + + return { + "customer": customer, + "team": team, + "customer_vk": customer_vk, + "team_vk": team_vk, + "standalone_vk": standalone_vk, + } + + +class PerformanceTracker: + """Track performance metrics during tests""" + + def __init__(self): + self.measurements = [] + + def time_operation(self, operation_name: str, operation_func, *args, **kwargs): + """Time an operation and record the measurement""" + start_time = time.time() + try: + result = operation_func(*args, **kwargs) + success = True + error = None + except Exception as e: + result = None + success = False + error = str(e) + + end_time = time.time() + duration = end_time - start_time + + measurement = { + "operation": operation_name, + "duration": duration, + "success": success, + "error": error, + "timestamp": datetime.now().isoformat(), + } + + self.measurements.append(measurement) + return result, measurement + + def get_stats(self) -> Dict[str, Any]: + """Get performance statistics""" + if not self.measurements: + return {"count": 0} + + durations = [m["duration"] for m in self.measurements] + successes = [m for m in self.measurements if m["success"]] + failures = [m for m in self.measurements if not m["success"]] + + return { + "count": len(self.measurements), + "success_count": len(successes), + "failure_count": len(failures), + "success_rate": len(successes) / len(self.measurements), + "avg_duration": sum(durations) / len(durations), + "min_duration": min(durations), + "max_duration": max(durations), + "total_duration": sum(durations), + } + + def print_report(self): + """Print performance report""" + stats = self.get_stats() + if stats["count"] == 0: + print("No measurements recorded") + return + + print(f"\nPerformance Report:") + print(f" Total operations: {stats['count']}") + print(f" Success rate: {stats['success_rate']:.2%}") + print(f" Average duration: {stats['avg_duration']:.3f}s") + print(f" Min duration: {stats['min_duration']:.3f}s") + print(f" Max duration: {stats['max_duration']:.3f}s") + print(f" Total duration: {stats['total_duration']:.3f}s") + + +class ChatCompletionHelper: + """Helper for chat completion testing""" + + @staticmethod + def generate_test_messages( + complexity: str = "simple", token_count_estimate: int = None + ) -> List[Dict[str, str]]: + """Generate test messages of varying complexity""" + if complexity == "simple": + return [{"role": "user", "content": "Hello, how are you?"}] + + elif complexity == "medium": + return [ + {"role": "user", "content": "Can you explain quantum computing?"}, + { + "role": "assistant", + "content": "Quantum computing is a type of computation that harnesses quantum mechanics...", + }, + { + "role": "user", + "content": "How does it differ from classical computing?", + }, + ] + + elif complexity == "complex": + content = fake.text(max_nb_chars=2000) + return [ + {"role": "system", "content": "You are a helpful AI assistant."}, + {"role": "user", "content": content}, + { + "role": "assistant", + "content": "I understand. Let me help you with that.", + }, + {"role": "user", "content": "Please provide a detailed analysis."}, + ] + + elif complexity == "custom" and token_count_estimate: + # Rough estimate: 4 characters per token + char_count = token_count_estimate * 4 + content = fake.text(max_nb_chars=char_count) + return [{"role": "user", "content": content}] + + else: + return [{"role": "user", "content": fake.sentence()}] + + @staticmethod + def make_test_request( + client: GovernanceTestClient, + vk_value: str, + model: str = "gpt-3.5-turbo", + max_tokens: int = 50, + **kwargs, + ) -> Dict[str, Any]: + """Make a standardized test chat completion request""" + messages = ( + kwargs.get("messages") or ChatCompletionHelper.generate_test_messages() + ) + headers = {"x-bf-vk": vk_value} + + response = client.chat_completion( + messages=messages, + model=model, + headers=headers, + max_tokens=max_tokens, + **{k: v for k, v in kwargs.items() if k != "messages"}, + ) + + return { + "response": response, + "status_code": response.status_code, + "success": response.status_code == 200, + "rate_limited": response.status_code == 429, + "budget_exceeded": response.status_code == 402, + "unauthorized": response.status_code in [401, 403], + "data": ( + response.json() + if response.headers.get("content-type", "").startswith( + "application/json" + ) + else response.text + ), + } + + +# Pytest fixtures for helpers + + +@pytest.fixture +def test_data_factory(): + """Test data factory fixture""" + return TestDataFactory() + + +@pytest.fixture +def validation_helper(): + """Validation helper fixture""" + return ValidationHelper() + + +@pytest.fixture +def scenario_builder(governance_client, cleanup_tracker): + """Test scenario builder fixture""" + return TestScenarioBuilder(governance_client, cleanup_tracker) + + +@pytest.fixture +def performance_tracker(): + """Performance tracker fixture""" + return PerformanceTracker() + + +@pytest.fixture +def chat_completion_helper(): + """Chat completion helper fixture""" + return ChatCompletionHelper() + + +# Test helper usage examples +class TestHelperExamples: + """Examples of how to use the test helpers""" + + @pytest.mark.helpers + def test_data_factory_usage( + self, test_data_factory, governance_client, cleanup_tracker + ): + """Example of using TestDataFactory""" + # Generate and create customer + customer_data = test_data_factory.generate_customer_data(include_budget=True) + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Verify data structure + assert customer["name"].endswith("Customer") + assert customer["budget"] is not None + + @pytest.mark.helpers + def test_scenario_builder_usage(self, scenario_builder): + """Example of using TestScenarioBuilder""" + # Create simple hierarchy + hierarchy = scenario_builder.create_simple_hierarchy() + + # Verify hierarchy structure + assert hierarchy["customer"]["id"] is not None + assert hierarchy["team"]["customer_id"] == hierarchy["customer"]["id"] + assert hierarchy["virtual_key"]["team_id"] == hierarchy["team"]["id"] + + @pytest.mark.helpers + def test_validation_helper_usage(self, validation_helper, sample_virtual_key): + """Example of using ValidationHelper""" + # Validate VK structure + errors = validation_helper.validate_entity_structure( + sample_virtual_key, "virtual_key" + ) + assert len(errors) == 0, f"VK validation errors: {errors}" + + # Validate budget if present + if sample_virtual_key.get("budget"): + budget_errors = validation_helper.validate_budget_structure( + sample_virtual_key["budget"] + ) + assert len(budget_errors) == 0, f"Budget validation errors: {budget_errors}" + + @pytest.mark.helpers + def test_performance_tracker_usage(self, performance_tracker, governance_client): + """Example of using PerformanceTracker""" + # Time an operation + result, measurement = performance_tracker.time_operation( + "list_customers", governance_client.list_customers + ) + + assert measurement["success"] is True + assert measurement["duration"] > 0 + + # Get performance stats + stats = performance_tracker.get_stats() + assert stats["count"] == 1 + assert stats["success_rate"] == 1.0 + + @pytest.mark.helpers + def test_chat_completion_helper_usage( + self, chat_completion_helper, governance_client, sample_virtual_key + ): + """Example of using ChatCompletionHelper""" + # Generate test messages + simple_messages = chat_completion_helper.generate_test_messages("simple") + assert len(simple_messages) == 1 + assert simple_messages[0]["role"] == "user" + + # Make test request + result = chat_completion_helper.make_test_request( + governance_client, sample_virtual_key["value"], max_tokens=10 + ) + + assert "status_code" in result + assert "success" in result + assert isinstance(result["success"], bool) diff --git a/tests/governance/test_teams_crud.py b/tests/governance/test_teams_crud.py new file mode 100644 index 000000000..169e6b63a --- /dev/null +++ b/tests/governance/test_teams_crud.py @@ -0,0 +1,897 @@ +""" +Comprehensive Team CRUD Tests for Bifrost Governance Plugin + +This module provides exhaustive testing of Team operations including: +- Complete CRUD lifecycle testing +- Comprehensive field update testing (individual and batch) +- Customer association testing +- Budget inheritance and management +- Filtering and query operations +- Edge cases and validation scenarios +- Concurrency and race condition testing +""" + +import pytest +import time +import uuid +from typing import Dict, Any, List +from concurrent.futures import ThreadPoolExecutor +import copy + +from conftest import ( + assert_response_success, + verify_unchanged_fields, + generate_unique_name, + verify_entity_relationships, + deep_compare_entities, +) + + +class TestTeamBasicCRUD: + """Test basic CRUD operations for Teams""" + + @pytest.mark.teams + @pytest.mark.crud + @pytest.mark.smoke + def test_team_create_minimal(self, governance_client, cleanup_tracker): + """Test creating team with minimal required data""" + data = {"name": generate_unique_name("Minimal Team")} + + response = governance_client.create_team(data) + assert_response_success(response, 201) + + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + + # Verify required fields + assert team_data["name"] == data["name"] + assert team_data["id"] is not None + assert team_data["created_at"] is not None + assert team_data["updated_at"] is not None + + # Verify optional fields are None/empty + assert team_data["virtual_keys"] is None + + @pytest.mark.teams + @pytest.mark.crud + def test_team_create_with_customer( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test creating team associated with a customer""" + data = { + "name": generate_unique_name("Customer Team"), + "customer_id": sample_customer["id"], + } + + response = governance_client.create_team(data) + assert_response_success(response, 201) + + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + + # Verify customer association + assert team_data["customer_id"] == sample_customer["id"] + assert team_data["customer"] is not None + assert team_data["customer"]["id"] == sample_customer["id"] + assert team_data["customer"]["name"] == sample_customer["name"] + + @pytest.mark.teams + @pytest.mark.crud + @pytest.mark.budget + def test_team_create_with_budget(self, governance_client, cleanup_tracker): + """Test creating team with budget""" + data = { + "name": generate_unique_name("Budget Team"), + "budget": {"max_limit": 25000, "reset_duration": "1d"}, # $250.00 in cents + } + + response = governance_client.create_team(data) + assert_response_success(response, 201) + + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + + # Verify budget was created + assert team_data["budget"] is not None + assert team_data["budget"]["max_limit"] == 25000 + assert team_data["budget"]["reset_duration"] == "1d" + assert team_data["budget"]["current_usage"] == 0 + assert team_data["budget_id"] is not None + + @pytest.mark.teams + @pytest.mark.crud + @pytest.mark.budget + def test_team_create_complete( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test creating team with all possible fields""" + data = { + "name": generate_unique_name("Complete Team"), + "customer_id": sample_customer["id"], + "budget": { + "max_limit": 100000, # $1000.00 in cents + "reset_duration": "1w", + }, + } + + response = governance_client.create_team(data) + assert_response_success(response, 201) + + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + + # Verify all fields + assert team_data["name"] == data["name"] + assert team_data["customer_id"] == sample_customer["id"] + assert team_data["customer"]["id"] == sample_customer["id"] + assert team_data["budget"]["max_limit"] == 100000 + assert team_data["budget"]["reset_duration"] == "1w" + + @pytest.mark.teams + @pytest.mark.crud + def test_team_list_all(self, governance_client, sample_team): + """Test listing all teams""" + response = governance_client.list_teams() + assert_response_success(response, 200) + + data = response.json() + assert "teams" in data + assert "count" in data + assert isinstance(data["teams"], list) + assert data["count"] >= 1 + + # Find our test team + test_team = next( + (team for team in data["teams"] if team["id"] == sample_team["id"]), None + ) + assert test_team is not None + + @pytest.mark.teams + @pytest.mark.crud + def test_team_list_filter_by_customer( + self, governance_client, sample_team_with_customer + ): + """Test listing teams filtered by customer""" + customer_id = sample_team_with_customer["customer_id"] + response = governance_client.list_teams(customer_id=customer_id) + assert_response_success(response, 200) + + data = response.json() + teams = data["teams"] + + # All returned teams should belong to the specified customer + for team in teams: + assert team["customer_id"] == customer_id + + # Our test team should be in the results + test_team = next( + (team for team in teams if team["id"] == sample_team_with_customer["id"]), + None, + ) + assert test_team is not None + + @pytest.mark.teams + @pytest.mark.crud + def test_team_get_by_id(self, governance_client, sample_team): + """Test getting team by ID with relationships loaded""" + response = governance_client.get_team(sample_team["id"]) + assert_response_success(response, 200) + + team_data = response.json()["team"] + assert team_data["id"] == sample_team["id"] + assert team_data["name"] == sample_team["name"] + + @pytest.mark.teams + @pytest.mark.crud + def test_team_get_nonexistent(self, governance_client): + """Test getting non-existent team returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.get_team(fake_id) + assert response.status_code == 404 + + @pytest.mark.teams + @pytest.mark.crud + def test_team_delete(self, governance_client, cleanup_tracker): + """Test deleting a team""" + # Create team to delete + data = {"name": generate_unique_name("Delete Test Team")} + create_response = governance_client.create_team(data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + + # Delete team + delete_response = governance_client.delete_team(team_id) + assert_response_success(delete_response, 200) + + # Verify team is gone + get_response = governance_client.get_team(team_id) + assert get_response.status_code == 404 + + @pytest.mark.teams + @pytest.mark.crud + def test_team_delete_nonexistent(self, governance_client): + """Test deleting non-existent team returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.delete_team(fake_id) + assert response.status_code == 404 + + +class TestTeamValidation: + """Test validation rules for Team operations""" + + @pytest.mark.teams + @pytest.mark.validation + def test_team_create_missing_name(self, governance_client): + """Test creating team without name fails""" + data = {"customer_id": str(uuid.uuid4())} + response = governance_client.create_team(data) + assert response.status_code == 400 + + @pytest.mark.teams + @pytest.mark.validation + def test_team_create_empty_name(self, governance_client): + """Test creating team with empty name fails""" + data = {"name": ""} + response = governance_client.create_team(data) + assert response.status_code == 400 + + @pytest.mark.teams + @pytest.mark.validation + def test_team_create_invalid_customer_id(self, governance_client): + """Test creating team with non-existent customer_id""" + data = { + "name": generate_unique_name("Invalid Customer Team"), + "customer_id": str(uuid.uuid4()), + } + response = governance_client.create_team(data) + # Note: Depending on implementation, this might succeed with warning or fail + # Adjust assertion based on actual API behavior + + @pytest.mark.teams + @pytest.mark.validation + def test_team_create_invalid_budget(self, governance_client): + """Test creating team with invalid budget data""" + # Test negative budget (should be rejected) + data = { + "name": generate_unique_name("Negative Budget Team"), + "budget": {"max_limit": -1000, "reset_duration": "1h"}, + } + response = governance_client.create_team(data) + assert response.status_code == 400 # API should reject negative budgets + + # Test invalid reset duration + data = { + "name": generate_unique_name("Invalid Duration Team"), + "budget": {"max_limit": 1000, "reset_duration": "invalid"}, + } + response = governance_client.create_team(data) + assert response.status_code == 400 + + +class TestTeamFieldUpdates: + """Comprehensive tests for Team field updates""" + + @pytest.mark.teams + @pytest.mark.field_updates + def test_team_update_individual_fields( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test updating each team field individually""" + # Create team with all fields for testing + original_data = { + "name": generate_unique_name("Complete Update Test Team"), + "customer_id": sample_customer["id"], + "budget": {"max_limit": 50000, "reset_duration": "1d"}, + } + create_response = governance_client.create_team(original_data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + cleanup_tracker.add_team(team_id) + + # Get original state + original_response = governance_client.get_team(team_id) + original_team = original_response.json()["team"] + + # Create another customer for testing customer_id updates + other_customer_data = {"name": generate_unique_name("Other Customer")} + other_customer_response = governance_client.create_customer(other_customer_data) + assert_response_success(other_customer_response, 201) + other_customer = other_customer_response.json()["customer"] + cleanup_tracker.add_customer(other_customer["id"]) + + # Test individual field updates + field_test_cases = [ + { + "field": "name", + "update_data": {"name": "Updated Team Name"}, + "expected_value": "Updated Team Name", + }, + { + "field": "customer_id", + "update_data": {"customer_id": other_customer["id"]}, + "expected_value": other_customer["id"], + "exclude_from_unchanged_check": ["customer_id", "customer"], + }, + { + "field": "customer_id_clear", + "update_data": {"customer_id": None}, + "expected_value": None, + "exclude_from_unchanged_check": ["customer_id", "customer"], + "custom_validation": lambda team: team["customer_id"] is None + and team["customer"] is None, + }, + ] + + for test_case in field_test_cases: + # Reset team to original state + reset_data = { + "name": original_team["name"], + "customer_id": original_team["customer_id"], + } + governance_client.update_team(team_id, reset_data) + + # Perform field update + response = governance_client.update_team(team_id, test_case["update_data"]) + assert_response_success(response, 200) + updated_team = response.json()["team"] + + # Verify target field was updated + if test_case.get("custom_validation"): + test_case["custom_validation"](updated_team) + else: + field_parts = test_case["field"].split(".") + current_value = updated_team + for part in field_parts: + if part != "clear": # Skip suffix indicators + current_value = current_value[part] + assert ( + current_value == test_case["expected_value"] + ), f"Field {test_case['field']} not updated correctly" + + # Verify other fields unchanged (if specified) + if test_case.get("verify_unchanged", True): + exclude_fields = test_case.get( + "exclude_from_unchanged_check", [test_case["field"]] + ) + verify_unchanged_fields(updated_team, original_team, exclude_fields) + + @pytest.mark.teams + @pytest.mark.field_updates + @pytest.mark.budget + def test_team_budget_updates(self, governance_client, cleanup_tracker): + """Test comprehensive budget creation, update, and modification""" + # Create team without budget + data = {"name": generate_unique_name("Budget Update Test Team")} + create_response = governance_client.create_team(data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + cleanup_tracker.add_team(team_id) + + # Test 1: Add budget to team without budget + budget_data = {"max_limit": 15000, "reset_duration": "1h"} + response = governance_client.update_team(team_id, {"budget": budget_data}) + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["budget"]["max_limit"] == 15000 + assert updated_team["budget"]["reset_duration"] == "1h" + assert updated_team["budget_id"] is not None + + # Test 2: Update existing budget completely + new_budget_data = {"max_limit": 30000, "reset_duration": "2h"} + response = governance_client.update_team(team_id, {"budget": new_budget_data}) + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["budget"]["max_limit"] == 30000 + assert updated_team["budget"]["reset_duration"] == "2h" + + # Test 3: Partial budget update (only max_limit) + response = governance_client.update_team( + team_id, {"budget": {"max_limit": 45000}} + ) + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["budget"]["max_limit"] == 45000 + assert ( + updated_team["budget"]["reset_duration"] == "2h" + ) # Should remain unchanged + + # Test 4: Partial budget update (only reset_duration) + response = governance_client.update_team( + team_id, {"budget": {"reset_duration": "1d"}} + ) + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["budget"]["max_limit"] == 45000 # Should remain unchanged + assert updated_team["budget"]["reset_duration"] == "1d" + + @pytest.mark.teams + @pytest.mark.field_updates + def test_team_multiple_field_updates( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test updating multiple fields simultaneously""" + # Create team with initial data + initial_data = { + "name": generate_unique_name("Multi-Field Test Team"), + } + create_response = governance_client.create_team(initial_data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + cleanup_tracker.add_team(team_id) + + # Update multiple fields at once + update_data = { + "name": "Updated Multi-Field Team Name", + "customer_id": sample_customer["id"], + "budget": {"max_limit": 75000, "reset_duration": "1w"}, + } + + response = governance_client.update_team(team_id, update_data) + assert_response_success(response, 200) + + updated_team = response.json()["team"] + assert updated_team["name"] == "Updated Multi-Field Team Name" + assert updated_team["customer_id"] == sample_customer["id"] + assert updated_team["customer"]["id"] == sample_customer["id"] + assert updated_team["budget"]["max_limit"] == 75000 + assert updated_team["budget"]["reset_duration"] == "1w" + + @pytest.mark.teams + @pytest.mark.field_updates + @pytest.mark.edge_cases + def test_team_update_edge_cases(self, governance_client, cleanup_tracker): + """Test edge cases in team updates""" + # Create test team + data = {"name": generate_unique_name("Edge Case Team")} + create_response = governance_client.create_team(data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + cleanup_tracker.add_team(team_id) + + original_response = governance_client.get_team(team_id) + original_team = original_response.json()["team"] + + # Test 1: Empty update (should return unchanged team) + response = governance_client.update_team(team_id, {}) + assert_response_success(response, 200) + updated_team = response.json()["team"] + + # Compare ignoring timestamps + differences = deep_compare_entities( + updated_team, original_team, ignore_fields=["updated_at"] + ) + assert len(differences) == 0, f"Empty update changed fields: {differences}" + + # Test 2: Update with same values + response = governance_client.update_team( + team_id, {"name": original_team["name"]} + ) + assert_response_success(response, 200) + + # Test 3: Very long team name (test field length limits) + long_name = "x" * 1000 # Adjust based on actual field limits + response = governance_client.update_team(team_id, {"name": long_name}) + # Expected behavior depends on API validation rules + + @pytest.mark.teams + @pytest.mark.field_updates + def test_team_update_nonexistent(self, governance_client): + """Test updating non-existent team returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.update_team(fake_id, {"name": "test"}) + assert response.status_code == 404 + + +class TestTeamBudgetManagement: + """Test team budget specific functionality""" + + @pytest.mark.teams + @pytest.mark.budget + def test_team_budget_creation_and_validation( + self, governance_client, cleanup_tracker + ): + """Test budget creation with various configurations""" + # Test valid budget configurations + budget_test_cases = [ + {"max_limit": 5000, "reset_duration": "1h"}, + {"max_limit": 25000, "reset_duration": "1d"}, + {"max_limit": 100000, "reset_duration": "1w"}, + {"max_limit": 500000, "reset_duration": "1M"}, + ] + + for budget_config in budget_test_cases: + data = { + "name": generate_unique_name( + f"Budget Team {budget_config['reset_duration']}" + ), + "budget": budget_config, + } + + response = governance_client.create_team(data) + assert_response_success(response, 201) + + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + + assert team_data["budget"]["max_limit"] == budget_config["max_limit"] + assert ( + team_data["budget"]["reset_duration"] == budget_config["reset_duration"] + ) + assert team_data["budget"]["current_usage"] == 0 + assert team_data["budget"]["last_reset"] is not None + + @pytest.mark.teams + @pytest.mark.budget + @pytest.mark.edge_cases + def test_team_budget_edge_cases(self, governance_client, cleanup_tracker): + """Test budget edge cases and boundary conditions""" + # Test boundary values + edge_case_budgets = [ + {"max_limit": 0, "reset_duration": "1h"}, # Zero budget + {"max_limit": 1, "reset_duration": "1s"}, # Minimal values + {"max_limit": 9223372036854775807, "reset_duration": "1h"}, # Max int64 + ] + + for budget_config in edge_case_budgets: + data = { + "name": generate_unique_name( + f"Edge Budget Team {budget_config['max_limit']}" + ), + "budget": budget_config, + } + + response = governance_client.create_team(data) + # Adjust assertions based on API validation rules + if ( + budget_config["max_limit"] >= 0 + ): # Assuming non-negative budgets are valid + assert_response_success(response, 201) + cleanup_tracker.add_team(response.json()["team"]["id"]) + else: + assert response.status_code == 400 + + @pytest.mark.teams + @pytest.mark.budget + def test_team_budget_inheritance_simulation( + self, governance_client, cleanup_tracker + ): + """Test team budget in context of hierarchical inheritance""" + # This test simulates budget inheritance behavior + # Actual inheritance testing would be in integration tests + + # Create customer with budget + customer_data = { + "name": generate_unique_name("Budget Customer"), + "budget": {"max_limit": 100000, "reset_duration": "1d"}, + } + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create team with smaller budget under customer + team_data = { + "name": generate_unique_name("Sub-Budget Team"), + "customer_id": customer["id"], + "budget": { + "max_limit": 25000, + "reset_duration": "1d", + }, # Smaller than customer + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + team = team_response.json()["team"] + cleanup_tracker.add_team(team["id"]) + + # Verify both budgets exist independently + assert team["budget"]["max_limit"] == 25000 + # Note: Customer budget not preloaded in team response (use customer endpoint to verify) + customer_response = governance_client.get_customer(customer["id"]) + customer_with_budget = customer_response.json()["customer"] + assert customer_with_budget["budget"]["max_limit"] == 100000 + + # Create team without budget under customer (should inherit) + no_budget_team_data = { + "name": generate_unique_name("Inherit Budget Team"), + "customer_id": customer["id"], + } + no_budget_response = governance_client.create_team(no_budget_team_data) + assert_response_success(no_budget_response, 201) + no_budget_team = no_budget_response.json()["team"] + cleanup_tracker.add_team(no_budget_team["id"]) + + # Team without explicit budget should not have budget field (omitempty) + assert no_budget_team.get("budget") is None + # Verify customer has budget (need to fetch customer directly due to preloading limits) + customer_check = governance_client.get_customer(customer["id"]) + assert customer_check.json()["customer"]["budget"]["max_limit"] == 100000 + + +class TestTeamRelationships: + """Test team relationships with customers""" + + @pytest.mark.teams + @pytest.mark.relationships + def test_team_customer_relationship_loading( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test that team properly loads customer relationships""" + data = { + "name": generate_unique_name("Customer Relationship Team"), + "customer_id": sample_customer["id"], + } + + response = governance_client.create_team(data) + assert_response_success(response, 201) + team_data = response.json()["team"] + cleanup_tracker.add_team(team_data["id"]) + + # Verify customer relationship loaded + assert team_data["customer"] is not None + assert team_data["customer"]["id"] == sample_customer["id"] + assert team_data["customer"]["name"] == sample_customer["name"] + + # Verify customer budget relationship loaded if it exists + if sample_customer.get("budget"): + assert team_data["customer"]["budget"] is not None + + @pytest.mark.teams + @pytest.mark.relationships + def test_team_orphaned_customer_reference(self, governance_client, cleanup_tracker): + """Test team behavior with orphaned customer reference""" + # Create team with non-existent customer_id + fake_customer_id = str(uuid.uuid4()) + data = { + "name": generate_unique_name("Orphaned Team"), + "customer_id": fake_customer_id, + } + + response = governance_client.create_team(data) + # Behavior depends on API implementation: + # - Might succeed with warning + # - Might fail with validation error + # Adjust assertion based on actual behavior + + if response.status_code == 201: + cleanup_tracker.add_team(response.json()["team"]["id"]) + # Verify team was created but customer relationship is null/missing + team_data = response.json()["team"] + assert team_data.get("customer") is None + else: + assert response.status_code == 400 # Validation error expected + + @pytest.mark.teams + @pytest.mark.relationships + def test_team_customer_association_changes( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test changing team customer associations""" + # Create standalone team + data = {"name": generate_unique_name("Association Test Team")} + create_response = governance_client.create_team(data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + cleanup_tracker.add_team(team_id) + + # Create another customer + other_customer_data = {"name": generate_unique_name("Other Customer")} + other_customer_response = governance_client.create_customer(other_customer_data) + assert_response_success(other_customer_response, 201) + other_customer = other_customer_response.json()["customer"] + cleanup_tracker.add_customer(other_customer["id"]) + + # Test 1: Associate with first customer + response = governance_client.update_team( + team_id, {"customer_id": sample_customer["id"]} + ) + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["customer_id"] == sample_customer["id"] + assert updated_team["customer"]["id"] == sample_customer["id"] + + # Test 2: Switch to other customer + response = governance_client.update_team( + team_id, {"customer_id": other_customer["id"]} + ) + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["customer_id"] == other_customer["id"] + assert updated_team["customer"]["id"] == other_customer["id"] + + # Test 3: Remove customer association + response = governance_client.update_team(team_id, {"customer_id": None}) + # Note: Behavior depends on API implementation + # Adjust assertion based on actual behavior + + +class TestTeamConcurrency: + """Test concurrent operations on Teams""" + + @pytest.mark.teams + @pytest.mark.concurrency + @pytest.mark.slow + def test_team_concurrent_creation(self, governance_client, cleanup_tracker): + """Test creating multiple teams concurrently""" + + def create_team(index): + data = {"name": generate_unique_name(f"Concurrent Team {index}")} + response = governance_client.create_team(data) + return response + + # Create 10 teams concurrently + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(create_team, i) for i in range(10)] + responses = [future.result() for future in futures] + + # Verify all succeeded + created_teams = [] + for response in responses: + assert_response_success(response, 201) + team_data = response.json()["team"] + created_teams.append(team_data) + cleanup_tracker.add_team(team_data["id"]) + + # Verify all teams have unique IDs + team_ids = [team["id"] for team in created_teams] + assert len(set(team_ids)) == 10 # All unique IDs + + @pytest.mark.teams + @pytest.mark.concurrency + @pytest.mark.slow + def test_team_concurrent_updates(self, governance_client, cleanup_tracker): + """Test updating same team concurrently""" + # Create team to update + data = {"name": generate_unique_name("Concurrent Update Team")} + create_response = governance_client.create_team(data) + assert_response_success(create_response, 201) + team_id = create_response.json()["team"]["id"] + cleanup_tracker.add_team(team_id) + + # Update concurrently with different names + def update_team(index): + update_data = {"name": f"Updated by thread {index}"} + response = governance_client.update_team(team_id, update_data) + return response, index + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(update_team, i) for i in range(5)] + results = [future.result() for future in futures] + + # All updates should succeed (last one wins) + for response, index in results: + assert_response_success(response, 200) + + # Verify final state + final_response = governance_client.get_team(team_id) + final_team = final_response.json()["team"] + assert final_team["name"].startswith("Updated by thread") + + @pytest.mark.teams + @pytest.mark.concurrency + @pytest.mark.slow + def test_team_concurrent_customer_association( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test concurrent customer association updates""" + # Create multiple teams to associate with same customer + teams = [] + for i in range(5): + data = {"name": generate_unique_name(f"Concurrent Association Team {i}")} + response = governance_client.create_team(data) + assert_response_success(response, 201) + team_data = response.json()["team"] + teams.append(team_data) + cleanup_tracker.add_team(team_data["id"]) + + # Associate all teams with customer concurrently + def associate_team(team): + update_data = {"customer_id": sample_customer["id"]} + response = governance_client.update_team(team["id"], update_data) + return response, team["id"] + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(associate_team, team) for team in teams] + results = [future.result() for future in futures] + + # All associations should succeed + for response, team_id in results: + assert_response_success(response, 200) + updated_team = response.json()["team"] + assert updated_team["customer_id"] == sample_customer["id"] + + +class TestTeamFiltering: + """Test team filtering and query operations""" + + @pytest.mark.teams + @pytest.mark.api + def test_team_filter_by_customer_comprehensive( + self, governance_client, cleanup_tracker + ): + """Test comprehensive customer filtering scenarios""" + # Create customers + customer1_data = {"name": generate_unique_name("Filter Customer 1")} + customer1_response = governance_client.create_customer(customer1_data) + assert_response_success(customer1_response, 201) + customer1 = customer1_response.json()["customer"] + cleanup_tracker.add_customer(customer1["id"]) + + customer2_data = {"name": generate_unique_name("Filter Customer 2")} + customer2_response = governance_client.create_customer(customer2_data) + assert_response_success(customer2_response, 201) + customer2 = customer2_response.json()["customer"] + cleanup_tracker.add_customer(customer2["id"]) + + # Create teams for customer1 + for i in range(3): + team_data = { + "name": generate_unique_name(f"Customer1 Team {i}"), + "customer_id": customer1["id"], + } + response = governance_client.create_team(team_data) + assert_response_success(response, 201) + cleanup_tracker.add_team(response.json()["team"]["id"]) + + # Create teams for customer2 + for i in range(2): + team_data = { + "name": generate_unique_name(f"Customer2 Team {i}"), + "customer_id": customer2["id"], + } + response = governance_client.create_team(team_data) + assert_response_success(response, 201) + cleanup_tracker.add_team(response.json()["team"]["id"]) + + # Create standalone team + standalone_data = {"name": generate_unique_name("Standalone Team")} + response = governance_client.create_team(standalone_data) + assert_response_success(response, 201) + cleanup_tracker.add_team(response.json()["team"]["id"]) + + # Test filtering by customer1 + response = governance_client.list_teams(customer_id=customer1["id"]) + assert_response_success(response, 200) + teams = response.json()["teams"] + assert len(teams) == 3 + for team in teams: + assert team["customer_id"] == customer1["id"] + + # Test filtering by customer2 + response = governance_client.list_teams(customer_id=customer2["id"]) + assert_response_success(response, 200) + teams = response.json()["teams"] + assert len(teams) == 2 + for team in teams: + assert team["customer_id"] == customer2["id"] + + # Test filtering by non-existent customer + fake_customer_id = str(uuid.uuid4()) + response = governance_client.list_teams(customer_id=fake_customer_id) + assert_response_success(response, 200) + teams = response.json()["teams"] + assert len(teams) == 0 + + @pytest.mark.teams + @pytest.mark.api + def test_team_list_pagination_and_sorting(self, governance_client, cleanup_tracker): + """Test team list with pagination and sorting (if supported by API)""" + # Create multiple teams for testing + team_names = [] + for i in range(10): + name = generate_unique_name(f"Sort Test Team {i:02d}") + team_names.append(name) + data = {"name": name} + response = governance_client.create_team(data) + assert_response_success(response, 201) + cleanup_tracker.add_team(response.json()["team"]["id"]) + + # Test basic list (should include our teams) + response = governance_client.list_teams() + assert_response_success(response, 200) + teams = response.json()["teams"] + assert len(teams) >= 10 + + # Verify our teams are in the response + response_team_names = {team["name"] for team in teams} + for name in team_names: + assert name in response_team_names diff --git a/tests/governance/test_usage_tracking.py b/tests/governance/test_usage_tracking.py new file mode 100644 index 000000000..aaa5724cc --- /dev/null +++ b/tests/governance/test_usage_tracking.py @@ -0,0 +1,1061 @@ +""" +Comprehensive Usage Tracking and Monitoring Tests for Bifrost Governance Plugin + +This module provides exhaustive testing of usage tracking, monitoring, and integration including: +- Chat completion integration with governance headers +- Usage tracking and budget enforcement +- Rate limiting enforcement during real requests +- Monitoring endpoints testing +- Reset functionality testing +- Debug and health endpoints +- Integration edge cases and error scenarios +- Performance and concurrency testing +""" + +import pytest +import time +import uuid +import json +from typing import Dict, Any, List +from concurrent.futures import ThreadPoolExecutor +import threading + +from conftest import ( + assert_response_success, + generate_unique_name, + wait_for_condition, + BIFROST_BASE_URL, +) + + +class TestUsageStatsEndpoints: + """Test usage statistics and monitoring endpoints""" + + @pytest.mark.usage_tracking + @pytest.mark.api + @pytest.mark.smoke + def test_get_usage_stats_general(self, governance_client): + """Test getting general usage statistics""" + response = governance_client.get_usage_stats() + assert_response_success(response, 200) + + stats = response.json() + # Stats structure depends on implementation, but should be valid JSON + assert isinstance(stats, dict) + + @pytest.mark.usage_tracking + @pytest.mark.api + def test_get_usage_stats_for_vk(self, governance_client, sample_virtual_key): + """Test getting usage statistics for specific VK""" + response = governance_client.get_usage_stats( + virtual_key_id=sample_virtual_key["id"] + ) + assert_response_success(response, 200) + + data = response.json() + assert "virtual_key_id" in data + assert data["virtual_key_id"] == sample_virtual_key["id"] + assert "usage_stats" in data + + @pytest.mark.usage_tracking + @pytest.mark.api + def test_get_usage_stats_nonexistent_vk(self, governance_client): + """Test getting usage stats for non-existent VK""" + fake_vk_id = str(uuid.uuid4()) + response = governance_client.get_usage_stats(virtual_key_id=fake_vk_id) + # Behavior depends on implementation - might return empty stats or 404 + assert response.status_code in [200, 404] + + @pytest.mark.usage_tracking + @pytest.mark.api + def test_reset_usage_basic(self, governance_client, sample_virtual_key): + """Test basic usage reset functionality""" + reset_data = {"virtual_key_id": sample_virtual_key["id"]} + + response = governance_client.reset_usage(reset_data) + assert_response_success(response, 200) + + result = response.json() + assert "message" in result + assert "successfully" in result["message"].lower() + + @pytest.mark.usage_tracking + @pytest.mark.api + def test_reset_usage_with_provider_and_model( + self, governance_client, sample_virtual_key + ): + """Test usage reset with specific provider and model""" + reset_data = { + "virtual_key_id": sample_virtual_key["id"], + "provider": "openai", + "model": "gpt-4", + } + + response = governance_client.reset_usage(reset_data) + assert_response_success(response, 200) + + @pytest.mark.usage_tracking + @pytest.mark.api + def test_reset_usage_invalid_vk(self, governance_client): + """Test usage reset with invalid VK ID""" + reset_data = {"virtual_key_id": str(uuid.uuid4())} + + response = governance_client.reset_usage(reset_data) + assert response.status_code in [400, 404, 500] # Expected error + + +class TestDebugEndpoints: + """Test debug and monitoring endpoints""" + + @pytest.mark.usage_tracking + @pytest.mark.api + @pytest.mark.smoke + def test_get_debug_stats(self, governance_client): + """Test debug statistics endpoint""" + response = governance_client.get_debug_stats() + assert_response_success(response, 200) + + data = response.json() + assert "plugin_stats" in data + assert "database_stats" in data + assert "timestamp" in data + + @pytest.mark.usage_tracking + @pytest.mark.api + def test_get_debug_counters(self, governance_client): + """Test debug counters endpoint""" + response = governance_client.get_debug_counters() + assert_response_success(response, 200) + + data = response.json() + assert "counters" in data + assert "count" in data + assert "timestamp" in data + assert isinstance(data["counters"], list) + + @pytest.mark.usage_tracking + @pytest.mark.api + @pytest.mark.smoke + def test_get_health_check(self, governance_client): + """Test health check endpoint""" + response = governance_client.get_health_check() + # Health check should return 200 for healthy or 503 for unhealthy + assert response.status_code in [200, 503] + + data = response.json() + assert "status" in data + assert "timestamp" in data + assert "checks" in data + assert data["status"] in ["healthy", "unhealthy"] + + +class TestChatCompletionIntegration: + """Test chat completion integration with governance headers""" + + @pytest.mark.integration + @pytest.mark.usage_tracking + @pytest.mark.smoke + def test_chat_completion_with_vk_header( + self, governance_client, sample_virtual_key + ): + """Test chat completion with valid VK header""" + messages = [{"role": "user", "content": "Hello, world!"}] + headers = {"x-bf-vk": sample_virtual_key["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=10, + ) + + # Response should be successful, rate limited, budget exceeded, or VK not found + assert response.status_code in [200, 429, 402, 403] + + if response.status_code == 200: + data = response.json() + assert "choices" in data + assert len(data["choices"]) > 0 + + @pytest.mark.integration + @pytest.mark.usage_tracking + def test_chat_completion_without_vk_header(self, governance_client): + """Test chat completion without VK header""" + messages = [{"role": "user", "content": "Hello, world!"}] + + response = governance_client.chat_completion( + messages=messages, model="openai/gpt-3.5-turbo", max_tokens=10 + ) + + # Should succeed without VK header (governance skipped) + assert response.status_code in [ + 200, + 400, + ] # 200 if no governance, 400 if provider issues + + @pytest.mark.integration + @pytest.mark.usage_tracking + def test_chat_completion_invalid_vk_header(self, governance_client): + """Test chat completion with invalid VK header""" + messages = [{"role": "user", "content": "Hello, world!"}] + headers = {"x-bf-vk": "invalid-vk-value"} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=10, + ) + + # Should fail with invalid VK (governance blocks) + assert response.status_code == 403 + + @pytest.mark.integration + @pytest.mark.usage_tracking + def test_chat_completion_inactive_vk(self, governance_client, cleanup_tracker): + """Test chat completion with inactive VK""" + # Create inactive VK + vk_data = {"name": generate_unique_name("Inactive VK"), "is_active": False} + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + inactive_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(inactive_vk["id"]) + + messages = [{"role": "user", "content": "Hello, world!"}] + headers = {"x-bf-vk": inactive_vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=10, + ) + + # Should fail with inactive VK (governance blocks) + assert response.status_code == 403 + + @pytest.mark.integration + @pytest.mark.usage_tracking + def test_chat_completion_with_model_restrictions( + self, governance_client, cleanup_tracker + ): + """Test chat completion with model restrictions""" + # Create VK with model restrictions + vk_data = { + "name": generate_unique_name("Restricted VK"), + "allowed_models": ["gpt-4"], # Only allow GPT-4 + "allowed_providers": ["openai"], + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + restricted_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(restricted_vk["id"]) + + # Test with allowed model + messages = [{"role": "user", "content": "Hello, world!"}] + headers = {"x-bf-vk": restricted_vk["value"]} + + response = governance_client.chat_completion( + messages=messages, model="gpt-4", headers=headers, max_tokens=10 + ) + + # Should work with allowed model + assert response.status_code in [200, 429, 402] # Success or limits + + # Test with disallowed model + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", # Not in allowed_models + headers=headers, + max_tokens=10, + ) + + # Should fail with disallowed model + assert response.status_code in [400, 403] + + +class TestBudgetEnforcement: + """Test budget enforcement during chat completions""" + + @pytest.mark.integration + @pytest.mark.budget + @pytest.mark.usage_tracking + def test_budget_enforcement_basic(self, governance_client, cleanup_tracker): + """Test basic budget enforcement""" + # Create VK with very small budget + vk_data = { + "name": generate_unique_name("Small Budget VK"), + "budget": { + "max_limit": 1, # 1 cent - very small budget + "reset_duration": "1h", + }, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + small_budget_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(small_budget_vk["id"]) + + messages = [ + { + "role": "user", + "content": "Write a very long story about artificial intelligence" * 10, + } + ] + headers = {"x-bf-vk": small_budget_vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=1000, # Request expensive completion + ) + + # Should fail due to budget exceeded + if response.status_code == 402: # Budget exceeded + error_data = response.json() + assert "budget" in error_data.get("error", "").lower() + elif response.status_code == 200: + # If it succeeded, check that budget was tracked + stats_response = governance_client.get_usage_stats( + virtual_key_id=small_budget_vk["id"] + ) + if stats_response.status_code == 200: + # Verify usage was tracked + pass + + @pytest.mark.integration + @pytest.mark.budget + @pytest.mark.usage_tracking + def test_hierarchical_budget_enforcement(self, governance_client, cleanup_tracker): + """Test hierarchical budget enforcement (Customer -> Team -> VK)""" + # Create customer with budget + customer_data = { + "name": generate_unique_name("Budget Test Customer"), + "budget": {"max_limit": 10000, "reset_duration": "1h"}, + } + customer_response = governance_client.create_customer(customer_data) + assert_response_success(customer_response, 201) + customer = customer_response.json()["customer"] + cleanup_tracker.add_customer(customer["id"]) + + # Create team under customer with smaller budget + team_data = { + "name": generate_unique_name("Budget Test Team"), + "customer_id": customer["id"], + "budget": {"max_limit": 5000, "reset_duration": "1h"}, + } + team_response = governance_client.create_team(team_data) + assert_response_success(team_response, 201) + team = team_response.json()["team"] + cleanup_tracker.add_team(team["id"]) + + # Create VK under team with even smaller budget + vk_data = { + "name": generate_unique_name("Budget Test VK"), + "team_id": team["id"], + "budget": {"max_limit": 1, "reset_duration": "1h"}, # Smallest budget + } + vk_response = governance_client.create_virtual_key(vk_data) + assert_response_success(vk_response, 201) + vk = vk_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk["id"]) + + # Test request that should hit VK budget first + messages = [{"role": "user", "content": "Expensive request" * 50}] + headers = {"x-bf-vk": vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="gpt-4", # More expensive model + headers=headers, + max_tokens=1000, + ) + + # Should be limited by VK budget (smallest in hierarchy) + # Actual behavior depends on implementation + + @pytest.mark.integration + @pytest.mark.budget + @pytest.mark.usage_tracking + def test_budget_reset_functionality(self, governance_client, cleanup_tracker): + """Test budget reset functionality""" + # Create VK with small budget + vk_data = { + "name": generate_unique_name("Reset Budget VK"), + "budget": {"max_limit": 100, "reset_duration": "1h"}, # Small but not tiny + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk["id"]) + + # Make a request to use some budget + messages = [{"role": "user", "content": "Hello"}] + headers = {"x-bf-vk": vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Reset the usage + reset_data = {"virtual_key_id": vk["id"]} + reset_response = governance_client.reset_usage(reset_data) + assert_response_success(reset_response, 200) + + # Budget should be reset - could make another request + response2 = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Should work after reset (unless other limits apply) + assert response2.status_code in [200, 429] # Success or rate limited + + +class TestRateLimitEnforcement: + """Test rate limiting enforcement during chat completions""" + + @pytest.mark.integration + @pytest.mark.rate_limit + @pytest.mark.usage_tracking + def test_request_rate_limiting(self, governance_client, cleanup_tracker): + """Test request rate limiting""" + # Create VK with very restrictive request rate limit + vk_data = { + "name": generate_unique_name("Rate Limited VK"), + "rate_limit": { + "request_max_limit": 2, # Only 2 requests allowed + "request_reset_duration": "1m", + }, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + rate_limited_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(rate_limited_vk["id"]) + + messages = [{"role": "user", "content": "Hello"}] + headers = {"x-bf-vk": rate_limited_vk["value"]} + + # Make requests up to the limit + responses = [] + for i in range(3): # Try 3 requests, limit is 2 + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + responses.append(response) + time.sleep(0.1) # Small delay + + # First 2 should succeed, 3rd should be rate limited + success_count = sum(1 for r in responses if r.status_code == 200) + rate_limited_count = sum(1 for r in responses if r.status_code == 429) + + # Depending on implementation, might be exactly enforced or allow some variance + assert rate_limited_count > 0 or success_count <= 2 + + @pytest.mark.integration + @pytest.mark.rate_limit + @pytest.mark.usage_tracking + def test_token_rate_limiting(self, governance_client, cleanup_tracker): + """Test token rate limiting""" + # Create VK with restrictive token rate limit + vk_data = { + "name": generate_unique_name("Token Rate Limited VK"), + "rate_limit": { + "token_max_limit": 100, # Only 100 tokens allowed + "token_reset_duration": "1m", + }, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + token_limited_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(token_limited_vk["id"]) + + # Make request that would exceed token limit + messages = [ + {"role": "user", "content": "Write a very long response about AI" * 10} + ] + headers = {"x-bf-vk": token_limited_vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=500, # Request more tokens than limit + ) + + # Should be limited by token rate limit + if response.status_code == 429: + error_data = response.json() + # Check if error mentions tokens or rate limit + error_text = error_data.get("error", "").lower() + assert "token" in error_text or "rate" in error_text + + @pytest.mark.integration + @pytest.mark.rate_limit + @pytest.mark.usage_tracking + def test_independent_rate_limits(self, governance_client, cleanup_tracker): + """Test that token and request rate limits are independent""" + # Create VK with different token and request limits + vk_data = { + "name": generate_unique_name("Independent Limits VK"), + "rate_limit": { + "token_max_limit": 1000, + "token_reset_duration": "1h", + "request_max_limit": 5, + "request_reset_duration": "1m", + }, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + independent_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(independent_vk["id"]) + + messages = [{"role": "user", "content": "Short"}] + headers = {"x-bf-vk": independent_vk["value"]} + + # Make multiple small requests (should hit request limit before token limit) + responses = [] + for i in range(10): # More than request limit + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, # Small token count + ) + responses.append(response) + time.sleep(0.1) + + # Should be limited by request count, not tokens + rate_limited_responses = [r for r in responses if r.status_code == 429] + assert len(rate_limited_responses) > 0 + + @pytest.mark.integration + @pytest.mark.rate_limit + @pytest.mark.usage_tracking + def test_rate_limit_reset(self, governance_client, cleanup_tracker): + """Test rate limit reset functionality""" + # Create VK with short reset duration for testing + vk_data = { + "name": generate_unique_name("Reset Test VK"), + "rate_limit": { + "request_max_limit": 1, + "request_reset_duration": "5s", # Short duration for testing + }, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + reset_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(reset_vk["id"]) + + messages = [{"role": "user", "content": "Hello"}] + headers = {"x-bf-vk": reset_vk["value"]} + + # Make first request (should succeed) + response1 = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Make second request immediately (should be rate limited) + response2 = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Reset the rate limit manually + reset_data = {"virtual_key_id": reset_vk["id"]} + reset_response = governance_client.reset_usage(reset_data) + assert_response_success(reset_response, 200) + + # Make third request after reset (should succeed) + response3 = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Should work after reset + assert response3.status_code in [200, 429] # Success or different limit + + +class TestConcurrentUsageTracking: + """Test concurrent usage tracking and limits""" + + @pytest.mark.integration + @pytest.mark.concurrency + @pytest.mark.usage_tracking + @pytest.mark.slow + def test_concurrent_requests_same_vk(self, governance_client, cleanup_tracker): + """Test concurrent requests using same VK""" + # Create VK with moderate limits + vk_data = { + "name": generate_unique_name("Concurrent VK"), + "rate_limit": {"request_max_limit": 10, "request_reset_duration": "1m"}, + "budget": {"max_limit": 10000, "reset_duration": "1h"}, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + concurrent_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(concurrent_vk["id"]) + + messages = [{"role": "user", "content": "Hello"}] + headers = {"x-bf-vk": concurrent_vk["value"]} + + def make_request(index): + try: + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + return response.status_code, index + except Exception as e: + return str(e), index + + # Make 15 concurrent requests (more than rate limit) + with ThreadPoolExecutor(max_workers=15) as executor: + futures = [executor.submit(make_request, i) for i in range(15)] + results = [future.result() for future in futures] + + # Count success vs rate limited responses + success_codes = [r[0] for r in results if r[0] == 200] + rate_limited_codes = [r[0] for r in results if r[0] == 429] + + # Should have some successful and some rate limited + total_responses = len(success_codes) + len(rate_limited_codes) + assert total_responses > 0 + + # Rate limiting should have kicked in for some requests + assert len(success_codes) <= 10 # Shouldn't exceed rate limit + + @pytest.mark.integration + @pytest.mark.concurrency + @pytest.mark.usage_tracking + @pytest.mark.slow + def test_concurrent_budget_tracking(self, governance_client, cleanup_tracker): + """Test concurrent budget tracking accuracy""" + # Create VK with small budget for testing + vk_data = { + "name": generate_unique_name("Budget Tracking VK"), + "budget": {"max_limit": 1000, "reset_duration": "1h"}, # Small budget + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + budget_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(budget_vk["id"]) + + messages = [{"role": "user", "content": "Count to 10"}] + headers = {"x-bf-vk": budget_vk["value"]} + + def make_budget_request(index): + try: + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=20, + ) + return ( + response.status_code, + index, + response.json() if response.status_code == 200 else None, + ) + except Exception as e: + return str(e), index, None + + # Make concurrent requests that should consume budget + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(make_budget_request, i) for i in range(5)] + results = [future.result() for future in futures] + + # Check budget tracking consistency + success_count = sum(1 for r in results if r[0] == 200) + budget_exceeded_count = sum(1 for r in results if r[0] == 402) + + # Should have proper budget enforcement + assert success_count + budget_exceeded_count > 0 + + +class TestStreamingIntegration: + """Test streaming integration with governance""" + + @pytest.mark.integration + @pytest.mark.usage_tracking + def test_streaming_chat_completion_with_governance( + self, governance_client, sample_virtual_key + ): + """Test streaming chat completion with governance headers""" + messages = [{"role": "user", "content": "Count from 1 to 5"}] + headers = {"x-bf-vk": sample_virtual_key["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=50, + stream=True, + ) + + # Streaming should work with governance + if response.status_code == 200: + # For streaming, response should be text/event-stream + content_type = response.headers.get("content-type", "") + assert ( + "text/event-stream" in content_type + or "application/json" in content_type + ) + else: + # Should be properly governed (rate limited, budget exceeded, etc.) + assert response.status_code in [402, 403, 429] + + @pytest.mark.integration + @pytest.mark.usage_tracking + @pytest.mark.rate_limit + def test_streaming_rate_limit_enforcement(self, governance_client, cleanup_tracker): + """Test rate limiting during streaming requests""" + # Create VK with token rate limit + vk_data = { + "name": generate_unique_name("Streaming Rate Limit VK"), + "rate_limit": {"token_max_limit": 50, "token_reset_duration": "1m"}, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + streaming_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(streaming_vk["id"]) + + messages = [{"role": "user", "content": "Write a long story about AI"}] + headers = {"x-bf-vk": streaming_vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=200, # More than token limit + stream=True, + ) + + # Should be limited by token rate limit + if response.status_code == 429: + error_data = response.json() + assert "token" in error_data.get("error", "").lower() + + +class TestProviderModelValidation: + """Test provider and model validation during integration""" + + @pytest.mark.integration + @pytest.mark.validation + def test_anthropic_model_integration(self, governance_client, cleanup_tracker): + """Test integration with Anthropic models""" + # Create VK allowing Anthropic + vk_data = { + "name": generate_unique_name("Anthropic VK"), + "allowed_providers": ["anthropic"], + "allowed_models": ["claude-3-5-sonnet-20240620"], + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + anthropic_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(anthropic_vk["id"]) + + messages = [{"role": "user", "content": "Hello Claude"}] + headers = {"x-bf-vk": anthropic_vk["value"]} + + response = governance_client.chat_completion( + messages=messages, + model="claude-3-5-sonnet-20240620", + headers=headers, + max_tokens=10, + ) + + # Should work if Anthropic is properly configured + assert response.status_code in [200, 400, 402, 429, 503] + + @pytest.mark.integration + @pytest.mark.validation + def test_openai_model_integration(self, governance_client, cleanup_tracker): + """Test integration with OpenAI models""" + # Create VK allowing OpenAI + vk_data = { + "name": generate_unique_name("OpenAI VK"), + "allowed_providers": ["openai"], + "allowed_models": ["gpt-4", "gpt-3.5-turbo"], + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + openai_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(openai_vk["id"]) + + messages = [{"role": "user", "content": "Hello GPT"}] + headers = {"x-bf-vk": openai_vk["value"]} + + # Test GPT-4 + response = governance_client.chat_completion( + messages=messages, model="gpt-4", headers=headers, max_tokens=10 + ) + + # Should work if OpenAI is properly configured + assert response.status_code in [200, 400, 402, 429, 503] + + @pytest.mark.integration + @pytest.mark.validation + def test_disallowed_provider_model_combination( + self, governance_client, cleanup_tracker + ): + """Test disallowed provider/model combinations""" + # Create VK only allowing OpenAI + vk_data = { + "name": generate_unique_name("OpenAI Only VK"), + "allowed_providers": ["openai"], + "allowed_models": ["gpt-4"], + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + restricted_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(restricted_vk["id"]) + + messages = [{"role": "user", "content": "Hello"}] + headers = {"x-bf-vk": restricted_vk["value"]} + + # Try to use Anthropic model (should fail) + response = governance_client.chat_completion( + messages=messages, + model="claude-3-5-sonnet-20240620", + headers=headers, + max_tokens=10, + ) + + # Should be rejected for disallowed model + assert response.status_code in [400, 403] + + +class TestErrorHandlingAndEdgeCases: + """Test error handling and edge cases in usage tracking""" + + @pytest.mark.integration + @pytest.mark.edge_cases + def test_malformed_vk_header(self, governance_client): + """Test malformed VK header handling""" + messages = [{"role": "user", "content": "Hello"}] + + malformed_headers = [ + {"x-bf-vk": ""}, # Empty + {"x-bf-vk": " "}, # Whitespace + {"x-bf-vk": "short"}, # Too short + {"x-bf-vk": "x" * 100}, # Too long + {"x-bf-vk": "invalid-characters-#@!"}, # Invalid chars + ] + + for headers in malformed_headers: + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Should properly reject malformed headers + assert response.status_code in [400, 403] + + @pytest.mark.integration + @pytest.mark.edge_cases + def test_concurrent_vk_updates_during_requests( + self, governance_client, cleanup_tracker + ): + """Test VK updates during active requests""" + # Create VK + vk_data = {"name": generate_unique_name("Update Test VK")} + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + update_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(update_vk["id"]) + + messages = [{"role": "user", "content": "Hello"}] + headers = {"x-bf-vk": update_vk["value"]} + + def make_request(): + return governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + def update_vk_config(): + update_data = {"description": "Updated during request"} + return governance_client.update_virtual_key(update_vk["id"], update_data) + + # Start request and update concurrently + with ThreadPoolExecutor(max_workers=2) as executor: + request_future = executor.submit(make_request) + update_future = executor.submit(update_vk_config) + + request_response = request_future.result() + update_response = update_future.result() + + # Both should handle gracefully + assert request_response.status_code in [200, 402, 403, 429] + assert_response_success(update_response, 200) + + @pytest.mark.integration + @pytest.mark.edge_cases + def test_extreme_token_counts(self, governance_client, sample_virtual_key): + """Test extreme token count scenarios""" + headers = {"x-bf-vk": sample_virtual_key["value"]} + + # Test with 0 max_tokens + response = governance_client.chat_completion( + messages=[{"role": "user", "content": "Hello"}], + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=0, + ) + + # Should handle 0 tokens gracefully + assert response.status_code in [200, 400] + + # Test with very large max_tokens + response = governance_client.chat_completion( + messages=[{"role": "user", "content": "Hello"}], + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=100000, # Very large + ) + + # Should handle large token requests + assert response.status_code in [200, 400, 402, 429] + + @pytest.mark.integration + @pytest.mark.edge_cases + def test_empty_and_large_messages(self, governance_client, sample_virtual_key): + """Test empty and very large message scenarios""" + headers = {"x-bf-vk": sample_virtual_key["value"]} + + # Test with empty message + response = governance_client.chat_completion( + messages=[{"role": "user", "content": ""}], + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Should handle empty messages + assert response.status_code in [200, 400] + + # Test with very large message + large_content = "This is a very long message. " * 1000 + response = governance_client.chat_completion( + messages=[{"role": "user", "content": large_content}], + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=5, + ) + + # Should handle large messages + assert response.status_code in [200, 400, 402, 429] + + +class TestPerformanceAndScaling: + """Test performance and scaling of usage tracking""" + + @pytest.mark.integration + @pytest.mark.performance + @pytest.mark.slow + def test_high_frequency_requests(self, governance_client, cleanup_tracker): + """Test high frequency requests performance""" + # Create VK with high limits + vk_data = { + "name": generate_unique_name("High Frequency VK"), + "rate_limit": { + "request_max_limit": 1000, + "request_reset_duration": "1h", + "token_max_limit": 100000, + "token_reset_duration": "1h", + }, + "budget": {"max_limit": 1000000, "reset_duration": "1h"}, + } + create_response = governance_client.create_virtual_key(vk_data) + assert_response_success(create_response, 201) + high_freq_vk = create_response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(high_freq_vk["id"]) + + messages = [{"role": "user", "content": "Hi"}] + headers = {"x-bf-vk": high_freq_vk["value"]} + + # Measure performance of rapid requests + start_time = time.time() + responses = [] + + for i in range(20): # Make 20 rapid requests + response = governance_client.chat_completion( + messages=messages, + model="openai/gpt-3.5-turbo", + headers=headers, + max_tokens=1, + ) + responses.append(response.status_code) + if i % 5 == 0: + time.sleep(0.1) # Brief pause every 5 requests + + total_time = time.time() - start_time + + # Performance assertions + assert total_time < 30.0, f"20 requests took too long: {total_time}s" + + # Most requests should succeed (unless rate limited) + success_count = sum(1 for code in responses if code == 200) + print( + f"High frequency test: {success_count}/20 requests succeeded in {total_time:.2f}s" + ) + + @pytest.mark.integration + @pytest.mark.performance + @pytest.mark.slow + def test_usage_stats_performance(self, governance_client, cleanup_tracker): + """Test usage statistics endpoint performance""" + # Create multiple VKs and make requests + vk_ids = [] + for i in range(10): + vk_data = {"name": generate_unique_name(f"Stats Perf VK {i}")} + response = governance_client.create_virtual_key(vk_data) + assert_response_success(response, 201) + vk_id = response.json()["virtual_key"]["id"] + vk_ids.append(vk_id) + cleanup_tracker.add_virtual_key(vk_id) + + # Test general stats performance + start_time = time.time() + response = governance_client.get_usage_stats() + stats_time = time.time() - start_time + + assert_response_success(response, 200) + assert stats_time < 2.0, f"Usage stats took too long: {stats_time}s" + + # Test individual VK stats performance + start_time = time.time() + for vk_id in vk_ids[:5]: # Test 5 VKs + response = governance_client.get_usage_stats(virtual_key_id=vk_id) + assert_response_success(response, 200) + + individual_stats_time = time.time() - start_time + assert ( + individual_stats_time < 5.0 + ), f"Individual VK stats took too long: {individual_stats_time}s" + + print( + f"Performance test: General stats: {stats_time:.2f}s, 5 individual stats: {individual_stats_time:.2f}s" + ) diff --git a/tests/governance/test_virtual_keys_crud.py b/tests/governance/test_virtual_keys_crud.py new file mode 100644 index 000000000..f2b025956 --- /dev/null +++ b/tests/governance/test_virtual_keys_crud.py @@ -0,0 +1,928 @@ +""" +Comprehensive Virtual Key CRUD Tests for Bifrost Governance Plugin + +This module provides exhaustive testing of Virtual Key operations including: +- Complete CRUD lifecycle testing +- Comprehensive field update testing (individual and batch) +- Mutual exclusivity validation (team_id vs customer_id) +- Budget and rate limit management +- Relationship testing with teams and customers +- Edge cases and validation scenarios +- Concurrency and race condition testing +""" + +import pytest +import time +import uuid +from typing import Dict, Any, List +from concurrent.futures import ThreadPoolExecutor +import copy + +from conftest import ( + assert_response_success, + verify_unchanged_fields, + generate_unique_name, + create_complete_virtual_key_data, + verify_entity_relationships, + deep_compare_entities, +) + + +class TestVirtualKeyBasicCRUD: + """Test basic CRUD operations for Virtual Keys""" + + @pytest.mark.virtual_keys + @pytest.mark.crud + @pytest.mark.smoke + def test_vk_create_minimal(self, governance_client, cleanup_tracker): + """Test creating VK with minimal required data""" + data = {"name": generate_unique_name("Minimal VK")} + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify required fields + assert vk_data["name"] == data["name"] + assert vk_data["value"] is not None # Auto-generated + assert vk_data["is_active"] is True # Default value + assert vk_data["id"] is not None + assert vk_data["created_at"] is not None + assert vk_data["updated_at"] is not None + + # Verify optional fields are None/empty + assert vk_data["allowed_models"] is None + assert vk_data["allowed_providers"] is None + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_create_complete(self, governance_client, cleanup_tracker): + """Test creating VK with all possible fields""" + data = create_complete_virtual_key_data() + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify all fields are set correctly + assert vk_data["name"] == data["name"] + assert vk_data["description"] == data["description"] + assert vk_data["allowed_models"] == data["allowed_models"] + assert vk_data["allowed_providers"] == data["allowed_providers"] + assert vk_data["is_active"] == data["is_active"] + + # Verify budget was created + assert vk_data["budget"] is not None + assert vk_data["budget"]["max_limit"] == data["budget"]["max_limit"] + assert vk_data["budget"]["reset_duration"] == data["budget"]["reset_duration"] + + # Verify rate limit was created + assert vk_data["rate_limit"] is not None + assert ( + vk_data["rate_limit"]["token_max_limit"] + == data["rate_limit"]["token_max_limit"] + ) + assert ( + vk_data["rate_limit"]["request_max_limit"] + == data["rate_limit"]["request_max_limit"] + ) + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_create_with_team(self, governance_client, cleanup_tracker, sample_team): + """Test creating VK associated with a team""" + data = {"name": generate_unique_name("Team VK"), "team_id": sample_team["id"]} + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify team association + assert vk_data["team_id"] == sample_team["id"] + assert vk_data.get("customer_id") is None + assert vk_data["team"] is not None + assert vk_data["team"]["id"] == sample_team["id"] + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_create_with_customer( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test creating VK associated with a customer""" + data = { + "name": generate_unique_name("Customer VK"), + "customer_id": sample_customer["id"], + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify customer association + assert vk_data["customer_id"] == sample_customer["id"] + assert vk_data.get("team_id") is None + assert vk_data["customer"] is not None + assert vk_data["customer"]["id"] == sample_customer["id"] + + @pytest.mark.virtual_keys + @pytest.mark.crud + @pytest.mark.mutual_exclusivity + def test_vk_create_mutual_exclusivity_violation( + self, governance_client, sample_team, sample_customer + ): + """Test that VK cannot be created with both team_id and customer_id""" + data = { + "name": generate_unique_name("Invalid VK"), + "team_id": sample_team["id"], + "customer_id": sample_customer["id"], + } + + response = governance_client.create_virtual_key(data) + assert response.status_code == 400 + error_data = response.json() + assert "cannot be attached to both" in error_data["error"].lower() + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_list_all(self, governance_client, sample_virtual_key): + """Test listing all virtual keys""" + response = governance_client.list_virtual_keys() + assert_response_success(response, 200) + + data = response.json() + assert "virtual_keys" in data + assert "count" in data + assert isinstance(data["virtual_keys"], list) + assert data["count"] >= 1 + + # Find our test VK + test_vk = next( + (vk for vk in data["virtual_keys"] if vk["id"] == sample_virtual_key["id"]), + None, + ) + assert test_vk is not None + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_get_by_id(self, governance_client, sample_virtual_key): + """Test getting VK by ID with relationships loaded""" + response = governance_client.get_virtual_key(sample_virtual_key["id"]) + assert_response_success(response, 200) + + vk_data = response.json()["virtual_key"] + assert vk_data["id"] == sample_virtual_key["id"] + assert vk_data["name"] == sample_virtual_key["name"] + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_get_nonexistent(self, governance_client): + """Test getting non-existent VK returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.get_virtual_key(fake_id) + assert response.status_code == 404 + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_delete(self, governance_client, cleanup_tracker): + """Test deleting a virtual key""" + # Create VK to delete + data = {"name": generate_unique_name("Delete Test VK")} + create_response = governance_client.create_virtual_key(data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + + # Delete VK + delete_response = governance_client.delete_virtual_key(vk_id) + assert_response_success(delete_response, 200) + + # Verify VK is gone + get_response = governance_client.get_virtual_key(vk_id) + assert get_response.status_code == 404 + + @pytest.mark.virtual_keys + @pytest.mark.crud + def test_vk_delete_nonexistent(self, governance_client): + """Test deleting non-existent VK returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.delete_virtual_key(fake_id) + assert response.status_code == 404 + + +class TestVirtualKeyValidation: + """Test validation rules for Virtual Key operations""" + + @pytest.mark.virtual_keys + @pytest.mark.validation + def test_vk_create_missing_name(self, governance_client): + """Test creating VK without name fails""" + data = {"description": "VK without name"} + response = governance_client.create_virtual_key(data) + assert response.status_code == 400 + + @pytest.mark.virtual_keys + @pytest.mark.validation + def test_vk_create_empty_name(self, governance_client): + """Test creating VK with empty name fails""" + data = {"name": ""} + response = governance_client.create_virtual_key(data) + assert response.status_code == 400 + + @pytest.mark.virtual_keys + @pytest.mark.validation + def test_vk_create_invalid_team_id(self, governance_client): + """Test creating VK with non-existent team_id""" + data = { + "name": generate_unique_name("Invalid Team VK"), + "team_id": str(uuid.uuid4()), + } + response = governance_client.create_virtual_key(data) + # Note: Depending on implementation, this might succeed with warning or fail + # Adjust assertion based on actual API behavior + + @pytest.mark.virtual_keys + @pytest.mark.validation + def test_vk_create_invalid_customer_id(self, governance_client): + """Test creating VK with non-existent customer_id""" + data = { + "name": generate_unique_name("Invalid Customer VK"), + "customer_id": str(uuid.uuid4()), + } + response = governance_client.create_virtual_key(data) + # Note: Adjust assertion based on actual API behavior + + @pytest.mark.virtual_keys + @pytest.mark.validation + def test_vk_create_invalid_json(self, governance_client): + """Test creating VK with malformed JSON""" + # This would be tested at the HTTP level, but pytest requests handles JSON encoding + # So we test with invalid data types instead + data = { + "name": 123, # Should be string + "is_active": "not_boolean", # Should be boolean + } + response = governance_client.create_virtual_key(data) + assert response.status_code == 400 + + +class TestVirtualKeyFieldUpdates: + """Comprehensive tests for Virtual Key field updates""" + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + def test_vk_update_individual_fields( + self, governance_client, cleanup_tracker, sample_team, sample_customer + ): + """Test updating each VK field individually""" + # Create complete VK for testing + original_data = create_complete_virtual_key_data() + create_response = governance_client.create_virtual_key(original_data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + # Get original state + original_response = governance_client.get_virtual_key(vk_id) + original_vk = original_response.json()["virtual_key"] + + # Test individual field updates + field_test_cases = [ + { + "field": "description", + "update_data": {"description": "Updated description"}, + "expected_value": "Updated description", + }, + { + "field": "allowed_models", + "update_data": {"allowed_models": ["gpt-4", "claude-3-opus"]}, + "expected_value": ["gpt-4", "claude-3-opus"], + }, + { + "field": "allowed_providers", + "update_data": {"allowed_providers": ["openai"]}, + "expected_value": ["openai"], + }, + { + "field": "is_active", + "update_data": {"is_active": False}, + "expected_value": False, + }, + { + "field": "team_id", + "update_data": {"team_id": sample_team["id"]}, + "expected_value": sample_team["id"], + "exclude_from_unchanged_check": [ + "team_id", + "customer_id", + "team", + "customer", + ], + }, + { + "field": "customer_id", + "update_data": {"customer_id": sample_customer["id"]}, + "expected_value": sample_customer["id"], + "exclude_from_unchanged_check": [ + "team_id", + "customer_id", + "team", + "customer", + ], + }, + ] + + for test_case in field_test_cases: + # Reset VK to original state by updating all fields back + reset_data = { + "description": original_vk.get("description", ""), + "allowed_models": original_vk["allowed_models"], + "allowed_providers": original_vk["allowed_providers"], + "is_active": original_vk["is_active"], + "team_id": original_vk.get("team_id"), + "customer_id": original_vk.get("customer_id"), + } + governance_client.update_virtual_key(vk_id, reset_data) + + # Perform field update + response = governance_client.update_virtual_key( + vk_id, test_case["update_data"] + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + + # Verify target field was updated + field_parts = test_case["field"].split(".") + current_value = updated_vk + for part in field_parts: + current_value = current_value[part] + assert ( + current_value == test_case["expected_value"] + ), f"Field {test_case['field']} not updated correctly" + + # Verify other fields unchanged (if specified) + if test_case.get("verify_unchanged", True): + exclude_fields = test_case.get( + "exclude_from_unchanged_check", [test_case["field"]] + ) + verify_unchanged_fields(updated_vk, original_vk, exclude_fields) + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + def test_vk_budget_updates(self, governance_client, cleanup_tracker): + """Test comprehensive budget creation, update, and modification""" + # Create VK without budget + data = {"name": generate_unique_name("Budget Test VK")} + create_response = governance_client.create_virtual_key(data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + # Test 1: Add budget to VK without budget + budget_data = {"max_limit": 10000, "reset_duration": "1h"} + response = governance_client.update_virtual_key(vk_id, {"budget": budget_data}) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["budget"]["max_limit"] == 10000 + assert updated_vk["budget"]["reset_duration"] == "1h" + assert updated_vk["budget_id"] is not None + + # Test 2: Update existing budget completely + new_budget_data = {"max_limit": 20000, "reset_duration": "2h"} + response = governance_client.update_virtual_key( + vk_id, {"budget": new_budget_data} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["budget"]["max_limit"] == 20000 + assert updated_vk["budget"]["reset_duration"] == "2h" + + # Test 3: Partial budget update (only max_limit) + response = governance_client.update_virtual_key( + vk_id, {"budget": {"max_limit": 30000}} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["budget"]["max_limit"] == 30000 + assert updated_vk["budget"]["reset_duration"] == "2h" # Should remain unchanged + + # Test 4: Partial budget update (only reset_duration) + response = governance_client.update_virtual_key( + vk_id, {"budget": {"reset_duration": "24h"}} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["budget"]["max_limit"] == 30000 # Should remain unchanged + assert updated_vk["budget"]["reset_duration"] == "24h" + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + def test_vk_rate_limit_updates(self, governance_client, cleanup_tracker): + """Test comprehensive rate limit creation, update, and field-level modifications""" + # Create VK without rate limit + data = {"name": generate_unique_name("Rate Limit Test VK")} + create_response = governance_client.create_virtual_key(data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + # Test 1: Add rate limit to VK + rate_limit_data = { + "token_max_limit": 1000, + "token_reset_duration": "1m", + "request_max_limit": 100, + "request_reset_duration": "1h", + } + response = governance_client.update_virtual_key( + vk_id, {"rate_limit": rate_limit_data} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["rate_limit"]["token_max_limit"] == 1000 + assert updated_vk["rate_limit"]["request_max_limit"] == 100 + assert updated_vk["rate_limit_id"] is not None + + # Test 2: Update only token limits + response = governance_client.update_virtual_key( + vk_id, + {"rate_limit": {"token_max_limit": 2000, "token_reset_duration": "2m"}}, + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["rate_limit"]["token_max_limit"] == 2000 + assert updated_vk["rate_limit"]["token_reset_duration"] == "2m" + assert updated_vk["rate_limit"]["request_max_limit"] == 100 # Unchanged + assert updated_vk["rate_limit"]["request_reset_duration"] == "1h" # Unchanged + + # Test 3: Update only request limits + response = governance_client.update_virtual_key( + vk_id, + {"rate_limit": {"request_max_limit": 200, "request_reset_duration": "2h"}}, + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["rate_limit"]["token_max_limit"] == 2000 # Unchanged + assert updated_vk["rate_limit"]["request_max_limit"] == 200 + assert updated_vk["rate_limit"]["request_reset_duration"] == "2h" + + # Test 4: Partial rate limit update (single field) + response = governance_client.update_virtual_key( + vk_id, {"rate_limit": {"token_max_limit": 5000}} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["rate_limit"]["token_max_limit"] == 5000 + assert updated_vk["rate_limit"]["token_reset_duration"] == "2m" # Unchanged + assert updated_vk["rate_limit"]["request_max_limit"] == 200 # Unchanged + assert updated_vk["rate_limit"]["request_reset_duration"] == "2h" # Unchanged + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + def test_vk_multiple_field_updates(self, governance_client, cleanup_tracker): + """Test updating multiple fields simultaneously""" + # Create VK with some initial data + initial_data = { + "name": generate_unique_name("Multi-Field Test VK"), + "description": "Initial description", + "allowed_models": ["gpt-3.5-turbo"], + "is_active": True, + } + create_response = governance_client.create_virtual_key(initial_data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + # Update multiple fields at once + update_data = { + "description": "Updated description via multi-field", + "allowed_models": ["gpt-4", "claude-3-5-sonnet-20240620"], + "allowed_providers": ["openai", "anthropic"], + "is_active": False, + "budget": {"max_limit": 50000, "reset_duration": "1d"}, + "rate_limit": { + "token_max_limit": 5000, + "request_max_limit": 500, + "token_reset_duration": "1h", + "request_reset_duration": "1h", + }, + } + + response = governance_client.update_virtual_key(vk_id, update_data) + assert_response_success(response, 200) + + updated_vk = response.json()["virtual_key"] + assert updated_vk["description"] == "Updated description via multi-field" + assert updated_vk["allowed_models"] == ["gpt-4", "claude-3-5-sonnet-20240620"] + assert updated_vk["allowed_providers"] == ["openai", "anthropic"] + assert updated_vk["is_active"] is False + assert updated_vk["budget"]["max_limit"] == 50000 + assert updated_vk["rate_limit"]["token_max_limit"] == 5000 + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + @pytest.mark.mutual_exclusivity + def test_vk_relationship_updates( + self, governance_client, cleanup_tracker, sample_team, sample_customer + ): + """Test updating VK relationships with mutual exclusivity validation""" + # Create standalone VK + data = {"name": generate_unique_name("Relationship Test VK")} + create_response = governance_client.create_virtual_key(data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + # Test 1: Add team relationship + response = governance_client.update_virtual_key( + vk_id, {"team_id": sample_team["id"]} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["team_id"] == sample_team["id"] + assert updated_vk.get("customer_id") is None + assert updated_vk["team"]["id"] == sample_team["id"] + + # Test 2: Switch to customer (should clear team) + response = governance_client.update_virtual_key( + vk_id, {"customer_id": sample_customer["id"]} + ) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + assert updated_vk["customer_id"] == sample_customer["id"] + assert updated_vk.get("team_id") is None + assert updated_vk["customer"]["id"] == sample_customer["id"] + assert updated_vk.get("team") is None + + # Test 3: Try to set both (should fail) + response = governance_client.update_virtual_key( + vk_id, {"team_id": sample_team["id"], "customer_id": sample_customer["id"]} + ) + assert response.status_code == 400 + error_data = response.json() + assert "cannot be attached to both" in error_data["error"].lower() + + # Test 4: Clear both relationships + response = governance_client.update_virtual_key( + vk_id, {"team_id": None, "customer_id": None} + ) + # Note: Behavior depends on API implementation - adjust based on actual behavior + # Some APIs might not support explicit null setting + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + @pytest.mark.edge_cases + def test_vk_update_edge_cases(self, governance_client, cleanup_tracker): + """Test edge cases in VK updates""" + # Create test VK + data = {"name": generate_unique_name("Edge Case VK")} + create_response = governance_client.create_virtual_key(data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + original_response = governance_client.get_virtual_key(vk_id) + original_vk = original_response.json()["virtual_key"] + + # Test 1: Empty update (should return unchanged VK) + response = governance_client.update_virtual_key(vk_id, {}) + assert_response_success(response, 200) + updated_vk = response.json()["virtual_key"] + + # Compare ignoring timestamps + differences = deep_compare_entities( + updated_vk, original_vk, ignore_fields=["updated_at"] + ) + assert len(differences) == 0, f"Empty update changed fields: {differences}" + + # Test 2: Invalid field values + response = governance_client.update_virtual_key(vk_id, {"is_active": "invalid"}) + assert response.status_code == 400 + + # Test 3: Update with same values (should succeed but might not change updated_at) + response = governance_client.update_virtual_key( + vk_id, + { + "description": original_vk.get("description", ""), + }, + ) + # Note: Adjust based on API behavior for no-op updates + + # Test 4: Very long values (test field length limits) + long_description = "x" * 10000 # Adjust based on actual field limits + response = governance_client.update_virtual_key( + vk_id, {"description": long_description} + ) + # Expected behavior depends on API validation rules + + @pytest.mark.virtual_keys + @pytest.mark.field_updates + def test_vk_update_nonexistent(self, governance_client): + """Test updating non-existent VK returns 404""" + fake_id = str(uuid.uuid4()) + response = governance_client.update_virtual_key( + fake_id, {"description": "test"} + ) + assert response.status_code == 404 + + +class TestVirtualKeyBudgetAndRateLimit: + """Test budget and rate limit specific functionality""" + + @pytest.mark.virtual_keys + @pytest.mark.budget + def test_vk_budget_creation_and_validation( + self, governance_client, cleanup_tracker + ): + """Test budget creation with various configurations""" + # Test valid budget configurations + budget_test_cases = [ + {"max_limit": 1000, "reset_duration": "1h"}, + {"max_limit": 50000, "reset_duration": "1d"}, + {"max_limit": 100000, "reset_duration": "1w"}, + {"max_limit": 1000000, "reset_duration": "1M"}, + ] + + for budget_config in budget_test_cases: + data = { + "name": generate_unique_name( + f"Budget VK {budget_config['reset_duration']}" + ), + "budget": budget_config, + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + assert vk_data["budget"]["max_limit"] == budget_config["max_limit"] + assert ( + vk_data["budget"]["reset_duration"] == budget_config["reset_duration"] + ) + assert vk_data["budget"]["current_usage"] == 0 + assert vk_data["budget"]["last_reset"] is not None + + @pytest.mark.virtual_keys + @pytest.mark.budget + @pytest.mark.edge_cases + def test_vk_budget_edge_cases(self, governance_client, cleanup_tracker): + """Test budget edge cases and boundary conditions""" + # Test boundary values + edge_case_budgets = [ + {"max_limit": 0, "reset_duration": "1h"}, # Zero budget + {"max_limit": 1, "reset_duration": "1s"}, # Minimal values + {"max_limit": 9223372036854775807, "reset_duration": "1h"}, # Max int64 + ] + + for budget_config in edge_case_budgets: + data = { + "name": generate_unique_name( + f"Edge Budget VK {budget_config['max_limit']}" + ), + "budget": budget_config, + } + + response = governance_client.create_virtual_key(data) + # Adjust assertions based on API validation rules + if ( + budget_config["max_limit"] >= 0 + ): # Assuming non-negative budgets are valid + assert_response_success(response, 201) + cleanup_tracker.add_virtual_key(response.json()["virtual_key"]["id"]) + else: + assert response.status_code == 400 + + @pytest.mark.virtual_keys + @pytest.mark.rate_limit + def test_vk_rate_limit_creation_and_validation( + self, governance_client, cleanup_tracker + ): + """Test rate limit creation with various configurations""" + # Test different rate limit configurations + rate_limit_test_cases = [ + { + "token_max_limit": 1000, + "token_reset_duration": "1m", + "request_max_limit": 100, + "request_reset_duration": "1h", + }, + { + "token_max_limit": 10000, + "token_reset_duration": "1h", + # Only token limits + }, + { + "request_max_limit": 500, + "request_reset_duration": "1d", + # Only request limits + }, + { + "token_max_limit": 5000, + "token_reset_duration": "30s", + "request_max_limit": 1000, + "request_reset_duration": "5m", + }, + ] + + for rate_limit_config in rate_limit_test_cases: + data = { + "name": generate_unique_name("Rate Limit VK"), + "rate_limit": rate_limit_config, + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + rate_limit = vk_data["rate_limit"] + for key, value in rate_limit_config.items(): + assert rate_limit[key] == value + + @pytest.mark.virtual_keys + @pytest.mark.rate_limit + @pytest.mark.edge_cases + def test_vk_rate_limit_edge_cases(self, governance_client, cleanup_tracker): + """Test rate limit edge cases and boundary conditions""" + # Test minimal rate limits + minimal_rate_limit = { + "token_max_limit": 1, + "token_reset_duration": "1s", + "request_max_limit": 1, + "request_reset_duration": "1s", + } + + data = { + "name": generate_unique_name("Minimal Rate Limit VK"), + "rate_limit": minimal_rate_limit, + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + cleanup_tracker.add_virtual_key(response.json()["virtual_key"]["id"]) + + # Test large rate limits + large_rate_limit = { + "token_max_limit": 1000000, + "token_reset_duration": "1h", + "request_max_limit": 100000, + "request_reset_duration": "1h", + } + + data = { + "name": generate_unique_name("Large Rate Limit VK"), + "rate_limit": large_rate_limit, + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + cleanup_tracker.add_virtual_key(response.json()["virtual_key"]["id"]) + + +class TestVirtualKeyConcurrency: + """Test concurrent operations on Virtual Keys""" + + @pytest.mark.virtual_keys + @pytest.mark.concurrency + @pytest.mark.slow + def test_vk_concurrent_creation(self, governance_client, cleanup_tracker): + """Test creating multiple VKs concurrently""" + + def create_vk(index): + data = {"name": generate_unique_name(f"Concurrent VK {index}")} + response = governance_client.create_virtual_key(data) + return response + + # Create 10 VKs concurrently + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(create_vk, i) for i in range(10)] + responses = [future.result() for future in futures] + + # Verify all succeeded + created_vks = [] + for response in responses: + assert_response_success(response, 201) + vk_data = response.json()["virtual_key"] + created_vks.append(vk_data) + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify all VKs have unique IDs and values + vk_ids = [vk["id"] for vk in created_vks] + vk_values = [vk["value"] for vk in created_vks] + assert len(set(vk_ids)) == 10 # All unique IDs + assert len(set(vk_values)) == 10 # All unique values + + @pytest.mark.virtual_keys + @pytest.mark.concurrency + @pytest.mark.slow + def test_vk_concurrent_updates(self, governance_client, cleanup_tracker): + """Test updating same VK concurrently""" + # Create VK to update + data = {"name": generate_unique_name("Concurrent Update VK")} + create_response = governance_client.create_virtual_key(data) + assert_response_success(create_response, 201) + vk_id = create_response.json()["virtual_key"]["id"] + cleanup_tracker.add_virtual_key(vk_id) + + # Update concurrently with different descriptions + def update_vk(index): + update_data = {"description": f"Updated by thread {index}"} + response = governance_client.update_virtual_key(vk_id, update_data) + return response, index + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(update_vk, i) for i in range(5)] + results = [future.result() for future in futures] + + # All updates should succeed (last one wins) + for response, index in results: + assert_response_success(response, 200) + + # Verify final state + final_response = governance_client.get_virtual_key(vk_id) + final_vk = final_response.json()["virtual_key"] + assert final_vk["description"].startswith("Updated by thread") + + +class TestVirtualKeyRelationships: + """Test VK relationships with teams and customers""" + + @pytest.mark.virtual_keys + @pytest.mark.relationships + def test_vk_team_relationship_loading( + self, governance_client, cleanup_tracker, sample_team_with_customer + ): + """Test that VK properly loads team and customer relationships""" + data = { + "name": generate_unique_name("Relationship VK"), + "team_id": sample_team_with_customer["id"], + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify team relationship loaded + assert vk_data["team"] is not None + assert vk_data["team"]["id"] == sample_team_with_customer["id"] + assert vk_data["team"]["name"] == sample_team_with_customer["name"] + + # Verify team's customer_id is present (nested customer not preloaded) + if sample_team_with_customer.get("customer_id"): + # Note: API only preloads one level deep, so customer object isn't nested here + assert ( + vk_data["team"].get("customer_id") + == sample_team_with_customer["customer_id"] + ) + + @pytest.mark.virtual_keys + @pytest.mark.relationships + def test_vk_customer_relationship_loading( + self, governance_client, cleanup_tracker, sample_customer + ): + """Test that VK properly loads customer relationships""" + data = { + "name": generate_unique_name("Customer Relationship VK"), + "customer_id": sample_customer["id"], + } + + response = governance_client.create_virtual_key(data) + assert_response_success(response, 201) + vk_data = response.json()["virtual_key"] + cleanup_tracker.add_virtual_key(vk_data["id"]) + + # Verify customer relationship loaded + assert vk_data["customer"] is not None + assert vk_data["customer"]["id"] == sample_customer["id"] + assert vk_data["customer"]["name"] == sample_customer["name"] + + @pytest.mark.virtual_keys + @pytest.mark.relationships + def test_vk_orphaned_relationships(self, governance_client, cleanup_tracker): + """Test VK behavior with orphaned team/customer references""" + # Create VK with non-existent team_id + fake_team_id = str(uuid.uuid4()) + data = {"name": generate_unique_name("Orphaned VK"), "team_id": fake_team_id} + + response = governance_client.create_virtual_key(data) + # Behavior depends on API implementation: + # - Might succeed with warning + # - Might fail with validation error + # Adjust assertion based on actual behavior + + if response.status_code == 201: + cleanup_tracker.add_virtual_key(response.json()["virtual_key"]["id"]) + # Verify VK was created but team relationship is null/missing + vk_data = response.json()["virtual_key"] + assert vk_data.get("team") is None + else: + assert response.status_code == 400 # Validation error expected diff --git a/tests/integrations/Makefile b/tests/integrations/Makefile new file mode 100644 index 000000000..2f0b2dc61 --- /dev/null +++ b/tests/integrations/Makefile @@ -0,0 +1,120 @@ +# Bifrost Python E2E Test Makefile +# Provides convenient commands for running tests + +# Get the directory where this Makefile is located +SCRIPT_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) + +.PHONY: help install test test-all test-parallel test-verbose clean lint format check-env + +# Default target +help: + @echo "Bifrost Python E2E Test Commands:" + @echo "" + @echo "Setup:" + @echo " install Install Python dependencies" + @echo " check-env Check environment variables" + @echo "" + @echo "Testing:" + @echo " test Run all tests using master runner" + @echo " test-all Run all tests with pytest" + @echo " test-parallel Run tests in parallel" + @echo " test-verbose Run tests with verbose output" + @echo " test-openai Run OpenAI integration tests only" + @echo " test-anthropic Run Anthropic integration tests only" + @echo " test-litellm Run LiteLLM integration tests only" + @echo " test-langchain Run LangChain integration tests only" + @echo " test-langgraph Run LangGraph integration tests only" + @echo " test-mistral Run Mistral integration tests only" + @echo " test-genai Run Google GenAI integration tests only" + @echo "" + @echo "Development:" + @echo " lint Run code linting" + @echo " format Format code with black" + @echo " clean Clean up temporary files" + +# Setup commands +install: + pip install -r $(SCRIPT_DIR)requirements.txt + +check-env: + @echo "Checking environment variables..." + @python -c "import os; print('βœ“ BIFROST_BASE_URL:', os.getenv('BIFROST_BASE_URL', 'http://localhost:8080'))" + @python -c "import os; print('βœ“ OPENAI_API_KEY:', 'Set' if os.getenv('OPENAI_API_KEY') else 'Not set')" + @python -c "import os; print('βœ“ ANTHROPIC_API_KEY:', 'Set' if os.getenv('ANTHROPIC_API_KEY') else 'Not set')" + @python -c "import os; print('βœ“ MISTRAL_API_KEY:', 'Set' if os.getenv('MISTRAL_API_KEY') else 'Not set')" + @python -c "import os; print('βœ“ GOOGLE_API_KEY:', 'Set' if os.getenv('GOOGLE_API_KEY') else 'Not set')" + +# Testing commands using master runner +test: + python $(SCRIPT_DIR)run_all_tests.py + +test-parallel: + python $(SCRIPT_DIR)run_all_tests.py --parallel + +test-verbose: + python $(SCRIPT_DIR)run_all_tests.py --verbose + +test-list: + python $(SCRIPT_DIR)run_all_tests.py --list + +# Individual integration tests +test-openai: + python $(SCRIPT_DIR)run_all_tests.py --integration openai --verbose + +test-anthropic: + python $(SCRIPT_DIR)run_all_tests.py --integration anthropic --verbose + +test-litellm: + python $(SCRIPT_DIR)run_all_tests.py --integration litellm --verbose + +test-langchain: + python $(SCRIPT_DIR)run_all_tests.py --integration langchain --verbose + +test-langgraph: + python $(SCRIPT_DIR)run_all_tests.py --integration langgraph --verbose + +test-mistral: + python $(SCRIPT_DIR)run_all_tests.py --integration mistral --verbose + +test-genai: + python $(SCRIPT_DIR)run_all_tests.py --integration genai --verbose + +# Pytest commands +test-all: + pytest -v + +test-pytest-parallel: + pytest -v -n auto + +test-coverage: + pytest --cov=. --cov-report=html --cov-report=term + +# Development commands +lint: + @echo "Running flake8..." + cd $(SCRIPT_DIR) && flake8 *.py + @echo "Running mypy..." + cd $(SCRIPT_DIR) && mypy *.py + +format: + @echo "Formatting code with black..." + cd $(SCRIPT_DIR) && black *.py + +clean: + @echo "Cleaning up temporary files..." + cd $(SCRIPT_DIR) && rm -rf __pycache__/ + cd $(SCRIPT_DIR) && rm -rf .pytest_cache/ + cd $(SCRIPT_DIR) && rm -rf .coverage + cd $(SCRIPT_DIR) && rm -rf htmlcov/ + cd $(SCRIPT_DIR) && rm -rf .mypy_cache/ + cd $(SCRIPT_DIR) && find . -name "*.pyc" -delete + cd $(SCRIPT_DIR) && find . -name "*.pyo" -delete + +# Quick commands for common workflows +quick-test: check-env test + +all-tests: install check-env test-parallel + +dev-setup: install check-env + @echo "Development environment ready!" + @echo "Run 'make test' to execute all tests" \ No newline at end of file diff --git a/tests/integrations/README.md b/tests/integrations/README.md new file mode 100644 index 000000000..aa105e523 --- /dev/null +++ b/tests/integrations/README.md @@ -0,0 +1,1564 @@ +# Bifrost Integration Tests + +Production-ready end-to-end test suite for testing AI integrations through Bifrost proxy. This test suite provides uniform testing across multiple AI integrations with comprehensive coverage of chat, tool calling, image processing, embeddings, speech synthesis, and multimodal workflows. + +## πŸŒ‰ Architecture Overview + +The Bifrost integration tests use a centralized configuration system that routes all AI integration requests through Bifrost as a gateway/proxy: + +```text +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Test Client │───▢│ Bifrost Gateway │───▢│ AI Integration β”‚ +β”‚ β”‚ β”‚ localhost:8080 β”‚ β”‚ (OpenAI, etc.) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### URL Structure + +- **Base URL**: `http://localhost:8080` (configurable via `BIFROST_BASE_URL`) +- **Integration Endpoints**: + - OpenAI: `http://localhost:8080/openai` + - Anthropic: `http://localhost:8080/anthropic` + - Google: `http://localhost:8080/genai` + - LiteLLM: `http://localhost:8080/litellm` + +## πŸš€ Features + +- **πŸŒ‰ Bifrost Gateway Integration**: All integrations route through Bifrost proxy +- **πŸ€– Centralized Configuration**: YAML-based configuration with environment variable support +- **πŸ”§ Integration-Specific Clients**: Type-safe, integration-optimized implementations +- **πŸ“‹ Comprehensive Test Coverage**: 14 categories covering all major AI functionality +- **βš™οΈ Flexible Execution**: Selective test running with command-line flags +- **πŸ›‘οΈ Robust Error Handling**: Graceful error handling and detailed error reporting +- **🎯 Production-Ready**: Async support, timeouts, retries, and logging +- **🎡 Speech & Audio Support**: Text-to-speech synthesis and speech-to-text transcription testing +- **πŸ”— Embeddings Support**: Text-to-vector conversion and similarity analysis testing + +## πŸ“‹ Test Categories + +Our test suite covers 30 comprehensive scenarios for each integration: + +### Core Chat & Conversation Tests +1. **Simple Chat** - Basic single-message conversations +2. **Multi-turn Conversation** - Conversation history and context retention +3. **Streaming** - Real-time streaming responses and tool calls + +### Tool Calling & Function Tests +4. **Single Tool Call** - Basic function calling capabilities +5. **Multiple Tool Calls** - Multiple tools in single request +6. **End-to-End Tool Calling** - Complete tool workflow with results +7. **Automatic Function Calling** - Integration-managed tool execution + +### Image & Vision Tests +8. **Image Analysis (URL)** - Image processing from URLs +9. **Image Analysis (Base64)** - Image processing from base64 data +10. **Multiple Images** - Multi-image analysis and comparison + +### Speech & Audio Tests (OpenAI) +11. **Speech Synthesis** - Text-to-speech conversion with different voices +12. **Audio Transcription** - Speech-to-text conversion with multiple formats +13. **Transcription Streaming** - Real-time transcription processing +14. **Speech Round-Trip** - Complete textβ†’speechβ†’text workflow validation +15. **Speech Error Handling** - Invalid voice, model, and input error handling +16. **Transcription Error Handling** - Invalid audio format and model error handling +17. **Voice & Format Testing** - Multiple voices and audio format validation + +### Embeddings Tests (OpenAI) +18. **Single Text Embedding** - Basic text-to-vector conversion +19. **Batch Text Embeddings** - Multiple text embeddings in single request +20. **Embedding Similarity Analysis** - Cosine similarity testing for similar texts +21. **Embedding Dissimilarity Analysis** - Validation of different topic embeddings +22. **Different Embedding Models** - Testing various embedding model capabilities +23. **Long Text Embedding** - Handling of longer text inputs and token usage +24. **Embedding Error Handling** - Invalid model and input error processing +25. **Dimensionality Reduction** - Custom embedding dimensions (if supported) +26. **Encoding Format Testing** - Different embedding output formats +27. **Usage Tracking** - Token consumption and batch processing validation + +### Integration & Error Tests +28. **Complex End-to-End** - Comprehensive multimodal workflows +29. **Integration-Specific Features** - Integration-unique capabilities +30. **Error Handling** - Invalid request error processing and propagation + +## πŸ“ Directory Structure + +```text +transports-integrations/ +β”œβ”€β”€ config.yml # Central configuration file +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ run_all_tests.py # Test runner script +β”œβ”€β”€ run_integration_tests.py # Integration-specific test runner +β”œβ”€β”€ test_audio.py # Speech & transcription test runner +β”œβ”€β”€ pytest.ini # Pytest configuration +β”œβ”€β”€ Makefile # Convenience commands +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ conftest.py # Pytest configuration and fixtures +β”‚ β”œβ”€β”€ utils/ +β”‚ β”‚ β”œβ”€β”€ common.py # Shared test utilities and fixtures +β”‚ β”‚ β”œβ”€β”€ config_loader.py # Configuration system +β”‚ β”‚ └── models.py # Model configurations (compatibility layer) +β”‚ └── integrations/ +β”‚ β”œβ”€β”€ test_openai.py # OpenAI integration tests +β”‚ β”œβ”€β”€ test_anthropic.py # Anthropic integration tests +β”‚ β”œβ”€β”€ test_google.py # Google AI integration tests +β”‚ └── test_litellm.py # LiteLLM integration tests +``` + +## ⚑ Quick Start + +### 1. Installation + +```bash +# Clone the repository +git clone +cd bifrost/tests/transports-integrations + +# Option 1: Using Makefile (recommended) +make install + +# Option 2: Direct pip install +pip install -r requirements.txt +``` + +### 2. Configuration + +The system uses `config.yml` for centralized configuration. Set up your environment variables: + +```bash +# Required: Bifrost gateway +export BIFROST_BASE_URL="http://localhost:8080" + +# Required: Integration API keys +export OPENAI_API_KEY="your-openai-key" +export ANTHROPIC_API_KEY="your-anthropic-key" +export GOOGLE_API_KEY="your-google-api-key" + +# Optional: Integration-specific settings +export OPENAI_ORG_ID="org-..." +export OPENAI_PROJECT_ID="proj_..." +export GOOGLE_PROJECT_ID="your-project" +export GOOGLE_LOCATION="us-central1" +export TEST_ENV="development" + +# Quick check using Makefile +make check-env +``` + +### 3. Verify Configuration + +```bash +# Test the configuration system +python tests/utils/config_loader.py +``` + +This will display: + +- πŸŒ‰ Bifrost gateway URLs +- πŸ€– Model configurations +- βš™οΈ API settings +- βœ… Validation status + +### 4. Pytest Configuration + +The project includes a `pytest.ini` file with optimized settings: + +```ini +[pytest] +# Test discovery +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Output formatting +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --color=yes + +# Timeout settings (3 minutes per test) +timeout = 180 + +# Markers for test categorization +markers = + integration: marks tests as integration tests + slow: marks tests as slow running + e2e: marks tests as end-to-end tests + tool_calling: marks tests as tool calling tests +``` + +### 5. Run Tests + +```bash +# Option 1: Using Makefile (recommended for convenience) +make test # Run all tests using master runner +make test-openai # Run OpenAI tests only +make test-anthropic # Run Anthropic tests only +make test-genai # Run Google GenAI tests only +make test-litellm # Run LiteLLM tests only +make test-verbose # Run all tests with verbose output +make test-parallel # Run tests in parallel + +# Option 2: Using test runner scripts directly +python run_all_tests.py + +# Run specific integration +python run_integration_tests.py openai +python run_integration_tests.py anthropic +python run_integration_tests.py google +python run_integration_tests.py litellm + +# Option 3: Using pytest directly +pytest tests/integrations/test_openai.py -v + +# Run specific test categories +pytest tests/integrations/ -k "error_handling" -v # Run only error handling tests +pytest tests/integrations/ -k "test_12" -v # Run all 12th test cases (error handling) +``` + +#### Makefile Commands + +The project includes a `Makefile` with convenient commands: + +```bash +# Setup +make install # Install Python dependencies +make check-env # Check environment variables + +# Testing +make test # Run all tests using master runner +make test-all # Run all tests with pytest +make test-parallel # Run tests in parallel +make test-verbose # Run tests with verbose output +make test-openai # Run OpenAI integration tests only +make test-anthropic # Run Anthropic integration tests only +make test-genai # Run Google GenAI integration tests only +make test-litellm # Run LiteLLM integration tests only +make test-coverage # Run tests with coverage report + +# Development +make lint # Run code linting +make format # Format code with black +make clean # Clean up temporary files + +# Quick workflows +make quick-test # Check environment + run tests +make all-tests # Full install + check + parallel tests +make dev-setup # Setup development environment +``` + +## πŸ”§ Configuration System + +### Configuration Files + +#### 1. `config.yml` - Main Configuration + +Central configuration file containing: + +- Bifrost gateway settings and endpoints +- Model configurations for all integrations +- API settings (timeouts, retries) +- Test parameters and limits +- Environment-specific overrides +- Integration-specific settings + +#### 2. `tests/utils/config_loader.py` - Configuration Loader + +Python module that: + +- Loads and parses `config.yml` +- Expands environment variables with `${VAR:-default}` syntax +- Provides convenience functions for URLs and models +- Validates configuration completeness +- Handles error scenarios + +#### 3. `tests/utils/models.py` - Compatibility Layer + +Maintains backward compatibility while delegating to the new config system. + +### Key Configuration Sections + +#### Bifrost Gateway + +```yaml +bifrost: + base_url: "${BIFROST_BASE_URL:-http://localhost:8080}" + endpoints: + openai: "openai" + anthropic: "anthropic" + google: "genai" + litellm: "litellm" +``` + +#### Model Configurations + +```yaml +models: + openai: + chat: "gpt-3.5-turbo" + vision: "gpt-4o" + tools: "gpt-3.5-turbo" + speech: "tts-1" + transcription: "whisper-1" + alternatives: ["gpt-4", "gpt-4-turbo-preview", "gpt-4o", "gpt-4o-mini"] + speech_alternatives: ["tts-1-hd"] + transcription_alternatives: ["whisper-1"] +``` + +#### API Settings + +```yaml +api: + timeout: 30 + max_retries: 3 + retry_delay: 1 +``` + +### Usage Examples + +#### Getting Integration URLs + +```python +from tests.utils.config_loader import get_integration_url + +# Get Bifrost URL for OpenAI +openai_url = get_integration_url("openai") +# Returns: http://localhost:8080/openai + +# Get integration URL through Bifrost +openai_url = get_integration_url("openai") +# Returns: http://localhost:8080/openai +``` + +#### Getting Model Names + +```python +from tests.utils.config_loader import get_model + +# Get different model types +chat_model = get_model("openai", "chat") # "gpt-3.5-turbo" +vision_model = get_model("openai", "vision") # "gpt-4o" +speech_model = get_model("openai", "speech") # "tts-1" +transcription_model = get_model("openai", "transcription") # "whisper-1" +``` + +## 🎡 Speech & Transcription Testing + +The test suite includes comprehensive speech synthesis and transcription testing for supported integrations (currently OpenAI). + +### Speech & Audio Test Categories + +#### 1. Speech Synthesis (Text-to-Speech) +- **Basic synthesis**: Convert text to audio with different voices +- **Format testing**: Multiple audio formats (MP3, WAV, Opus) +- **Voice validation**: Test all available voices (alloy, echo, fable, onyx, nova, shimmer) +- **Parameter testing**: Response format, voice settings, and quality options + +#### 2. Speech Streaming +- **Real-time generation**: Streaming audio synthesis for large texts +- **Chunk validation**: Verify audio chunk integrity and format +- **Performance testing**: Measure streaming latency and throughput + +#### 3. Audio Transcription (Speech-to-Text) +- **File format support**: WAV, MP3, and other audio formats +- **Language detection**: Multi-language transcription capabilities +- **Parameter testing**: Language hints, response formats, temperature settings +- **Quality validation**: Transcription accuracy and completeness + +#### 4. Transcription Streaming +- **Real-time processing**: Streaming transcription for long audio files +- **Progressive results**: Incremental text output validation +- **Error handling**: Network interruption and recovery testing + +#### 5. Round-Trip Testing +- **Complete workflow**: Text β†’ Speech β†’ Transcription β†’ Text validation +- **Accuracy measurement**: Compare original text with round-trip result +- **Quality assessment**: Measure transcription fidelity and word preservation + +### Running Speech & Transcription Tests + +#### Quick Start + +```bash +# Run all speech and transcription tests +python test_audio.py + +# Run with verbose output +python test_audio.py --verbose + +# Run specific test +python test_audio.py --test test_14_speech_synthesis + +# List available tests +python test_audio.py --list +``` + +#### Individual Test Examples + +```bash +# Test speech synthesis +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_14_speech_synthesis -v + +# Test transcription +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_16_transcription_audio -v + +# Test round-trip workflow +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_18_speech_transcription_round_trip -v + +# Test error handling +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_19_speech_error_handling -v +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_20_transcription_error_handling -v +``` + +#### Available Test Audio Types + +1. **Sine Wave**: Pure tone audio for basic testing +2. **Chord**: Multi-frequency audio for complex signal testing +3. **Frequency Sweep**: Variable frequency audio for range testing +4. **White Noise**: Random audio for noise handling testing +5. **Silence**: Empty audio for edge case testing +6. **Various Durations**: Short (0.5s) to long (10s) audio files + +### Speech & Transcription Configuration + +#### Model Configuration + +```yaml +models: + openai: + speech: "tts-1" # Default speech synthesis model + transcription: "whisper-1" # Default transcription model + speech_alternatives: ["tts-1-hd"] # Higher quality speech model + transcription_alternatives: ["whisper-1"] # Alternative transcription models + +# Model capabilities +model_capabilities: + "tts-1": + speech: true + streaming: false # Streaming support varies + max_tokens: null + context_window: null + + "whisper-1": + transcription: true + streaming: false # Streaming support varies + max_tokens: null + context_window: null +``` + +#### Test Settings + +```yaml +test_settings: + max_tokens: + speech: null # Speech doesn't use token limits + transcription: null # Transcription doesn't use token limits + + timeouts: + speech: 60 # Speech generation timeout + transcription: 60 # Transcription processing timeout +``` + +### Speech Test Examples + +#### Basic Speech Synthesis + +```python +# Test basic speech synthesis +response = openai_client.audio.speech.create( + model="tts-1", + voice="alloy", + input="Hello, this is a test of speech synthesis.", +) +audio_content = response.content +assert len(audio_content) > 1000 # Ensure substantial audio data +``` + +#### Transcription Testing + +```python +# Test audio transcription +test_audio = generate_test_audio() # Generate test WAV file +response = openai_client.audio.transcriptions.create( + model="whisper-1", + file=("test.wav", test_audio, "audio/wav"), + language="en", +) +transcribed_text = response.text +assert len(transcribed_text) > 0 # Ensure transcription occurred +``` + +#### Round-Trip Validation + +```python +# Complete round-trip test +original_text = "The quick brown fox jumps over the lazy dog." + +# Step 1: Text to speech +speech_response = openai_client.audio.speech.create( + model="tts-1", + voice="alloy", + input=original_text, + response_format="wav", +) + +# Step 2: Speech to text +transcription_response = openai_client.audio.transcriptions.create( + model="whisper-1", + file=("speech.wav", speech_response.content, "audio/wav"), +) + +# Step 3: Validate similarity +transcribed_text = transcription_response.text +# Check for key word preservation (allowing for transcription variations) +``` + +### Error Handling Tests + +#### Speech Synthesis Errors + +```python +# Test invalid voice +with pytest.raises(Exception): + openai_client.audio.speech.create( + model="tts-1", + voice="invalid_voice", + input="This should fail", + ) + +# Test empty input +with pytest.raises(Exception): + openai_client.audio.speech.create( + model="tts-1", + voice="alloy", + input="", + ) +``` + +#### Transcription Errors + +```python +# Test invalid audio format +invalid_audio = b"This is not audio data" +with pytest.raises(Exception): + openai_client.audio.transcriptions.create( + model="whisper-1", + file=("invalid.wav", invalid_audio, "audio/wav"), + ) + +# Test unsupported file type +with pytest.raises(Exception): + openai_client.audio.transcriptions.create( + model="whisper-1", + file=("test.txt", b"text content", "text/plain"), + ) +``` + +### Integration Support Matrix + +| Integration | Speech Synthesis | Transcription | Streaming | Notes | +|------------|------------------|---------------|-----------|-------| +| OpenAI | βœ… Full Support | βœ… Full Support | πŸ”„ Varies | Complete implementation | +| Anthropic | ❌ Not Available | ❌ Not Available | ❌ No | No speech/audio APIs | +| Google | ❌ Not Available* | ❌ Not Available* | ❌ No | *Not through Gemini API | +| LiteLLM | βœ… Via OpenAI | βœ… Via OpenAI | πŸ”„ Varies | Proxies to OpenAI | + +*Note: Google offers speech services through separate APIs (Cloud Speech-to-Text, Cloud Text-to-Speech) that are not currently integrated.* + +### Performance Considerations + +#### Speech Synthesis +- **File Size**: Generated audio files range from 50KB to 5MB depending on length and quality +- **Generation Time**: Typically 2-10 seconds for short texts, longer for complex content +- **Format Impact**: WAV files are larger but offer better compatibility; MP3 is more compressed + +#### Transcription +- **Processing Time**: Usually 1-5 seconds for short audio files (under 30 seconds) +- **File Size Limits**: Most services support files up to 25MB +- **Accuracy Factors**: Audio quality, background noise, speaker clarity affect results + +### Best Practices + +#### For Speech Testing +1. **Use consistent test text** for reproducible results +2. **Test multiple voices** to ensure voice switching works +3. **Validate audio headers** to confirm proper format generation +4. **Check file sizes** to ensure reasonable audio generation + +#### For Transcription Testing +1. **Use high-quality test audio** for consistent transcription results +2. **Test various audio formats** (WAV, MP3, etc.) for compatibility +3. **Include silence and noise** tests for edge case handling +4. **Validate response formats** (JSON, text) as needed + +#### For Round-Trip Testing +1. **Use simple, clear phrases** to maximize transcription accuracy +2. **Allow for minor variations** in transcribed text +3. **Focus on key word preservation** rather than exact matches +4. **Test with different voices** to ensure consistency across voice models + +### Troubleshooting + +#### Common Issues + +1. **Audio Format Errors** + ```bash + # Check audio file headers + file test_audio.wav + # Should show: RIFF (little-endian) data, WAVE audio + ``` + +2. **API Key Issues** + ```bash + # Verify OpenAI API key + export OPENAI_API_KEY="your-key-here" + python test_audio.py --test test_14_speech_synthesis + ``` + +3. **Bifrost Configuration** + ```bash + # Ensure Bifrost is running and accessible + curl http://localhost:8080/openai/v1/audio/speech -I + ``` + +4. **Model Availability** + ```python + # Check if speech/transcription models are available + from tests.utils.config_loader import get_model + print("Speech model:", get_model("openai", "speech")) + print("Transcription model:", get_model("openai", "transcription")) + ``` + +#### Debug Commands + +```bash +# Test individual components +python test_audio.py --test test_14_speech_synthesis --verbose + +# Check Bifrost logs for audio endpoint requests +# (Check your Bifrost instance logs) +``` + +## Getting Model Names + +```python +from tests.utils.config_loader import get_model + +# Get chat model for OpenAI +chat_model = get_model("openai", "chat") +# Returns: gpt-3.5-turbo + +# Get vision model for Anthropic +vision_model = get_model("anthropic", "vision") +# Returns: claude-3-haiku-20240307 +``` + +## πŸ€– Integration Support + +### Currently Supported Integrations + +#### OpenAI + +- βœ… **Full Bifrost Integration**: Complete base URL support +- βœ… **Models**: gpt-3.5-turbo, gpt-4, gpt-4o, gpt-4o-mini, text-embedding-3-small, tts-1, whisper-1 +- βœ… **Features**: Chat, tools, vision, speech synthesis, transcription, embeddings +- βœ… **Settings**: Organization/project IDs, timeouts, retries +- βœ… **All Test Categories**: 30/30 scenarios supported (including speech & embeddings) + +#### Anthropic + +- βœ… **Full Bifrost Integration**: Complete base URL support +- βœ… **Models**: claude-3-haiku-20240307, claude-3-sonnet-20240229, claude-3-opus-20240229, claude-3-5-sonnet-20241022 +- βœ… **Features**: Chat, tools, vision +- βœ… **Settings**: API version headers, timeouts, retries +- βœ… **All Test Categories**: 11/11 scenarios supported + +#### Google AI + +- βœ… **Full Bifrost Integration**: Complete custom transport implementation +- βœ… **Models**: gemini-2.0-flash-001, gemini-1.5-pro, gemini-1.5-flash, gemini-1.0-pro +- βœ… **Features**: Chat, tools, vision, multimodal processing +- βœ… **Settings**: Project ID, location, API configuration +- βœ… **All Test Categories**: 11/11 scenarios supported +- βœ… **Custom Base64 Handling**: Resolved cross-language encoding compatibility + +#### LiteLLM + +- βœ… **Full Bifrost Integration**: Global base URL configuration +- βœ… **Models**: Supports all LiteLLM-compatible models +- βœ… **Features**: Chat, tools, vision (integration-dependent) +- βœ… **Settings**: Drop params, debug mode, integration-specific configs +- βœ… **All Test Categories**: 11/11 scenarios supported +- βœ… **Multi-Integration**: OpenAI, Anthropic, Google, Azure, Cohere, Mistral, etc. + +## πŸ§ͺ Running Tests + +### Test Execution Methods + +#### 1. Using Test Runner Scripts + +##### `run_integration_tests.py` - Advanced Integration Testing + +```bash +# Basic usage - run all available integrations +python run_integration_tests.py + +# Run specific integration +python run_integration_tests.py --integrations openai + +# Run multiple integrations +python run_integration_tests.py --integrations openai anthropic google + +# Run specific test across integrations +python run_integration_tests.py --integrations openai anthropic --test "test_03_single_tool_call" + +# Run test pattern (e.g., all tool calling tests) +python run_integration_tests.py --integrations google --test "tool_call" + +# Run with verbose output +python run_integration_tests.py --integrations openai --test "test_01_simple_chat" --verbose + +# Utility commands +python run_integration_tests.py --check-keys # Check API key availability +python run_integration_tests.py --show-models # Show model configuration +``` + +##### `run_all_tests.py` - Simple Sequential Testing + +```bash +# Run all integrations sequentially +python run_all_tests.py + +# Run with custom configuration +BIFROST_BASE_URL=https://your-bifrost.com python run_all_tests.py +``` + +#### 2. Using pytest Directly + +```bash +# Run all tests for a integration +pytest tests/integrations/test_openai.py -v + +# Run specific test categories +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_01_simple_chat -v + +# Run with coverage +pytest tests/integrations/ --cov=tests --cov-report=html + +# Run with custom markers +pytest tests/integrations/ -m "not slow" -v +``` + +#### 3. Selective Test Execution + +```bash +# Skip tests that require API keys you don't have +pytest tests/integrations/test_openai.py -v # Will skip if OPENAI_API_KEY not set + +# Run only specific test methods +pytest tests/integrations/test_anthropic.py -k "tool_call" -v + +# Run with timeout +pytest tests/integrations/ --timeout=300 -v +``` + +### πŸ” Checking and Running Specific Tests + +#### πŸš€ Quick Commands (Most Common) + +```bash +# Run specific test for specific integration (your example!) +python run_integration_tests.py --integrations google --test "test_03_single_tool_call" + +# Run all tool calling tests across multiple integrations +python run_integration_tests.py --integrations openai anthropic --test "tool_call" + +# Run all tests for one integration +python run_integration_tests.py --integrations openai -v + +# Check what integrations are available +python run_integration_tests.py --check-keys + +# Run specific test with pytest directly +pytest tests/integrations/test_google.py::TestGoogleIntegration::test_03_single_tool_call -v +``` + +#### Quick Reference: Test Categories + +```text +Test 01: Simple Chat - Basic single-message conversations +Test 02: Multi-turn Conversation - Conversation history and context +Test 03: Single Tool Call - Basic function calling +Test 04: Multiple Tool Calls - Multiple tools in one request +Test 05: End-to-End Tool Calling - Complete tool workflow with results +Test 06: Automatic Function Call - Integration-managed tool execution +Test 07: Image Analysis (URL) - Image processing from URLs +Test 08: Image Analysis (Base64) - Image processing from base64 +Test 09: Multiple Images - Multi-image analysis and comparison +Test 10: Complex End-to-End - Comprehensive multimodal workflows +Test 11: Integration-Specific - Integration-unique features +``` + +#### Listing Available Tests + +```bash +# List all tests for a specific integration +pytest tests/integrations/test_openai.py --collect-only + +# List all test methods with descriptions +pytest tests/integrations/test_openai.py --collect-only -q + +# Show test structure for all integrations +pytest tests/integrations/ --collect-only +``` + +#### Running Individual Test Categories + +```bash +# Test 1: Simple Chat +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_01_simple_chat -v + +# Test 3: Single Tool Call +pytest tests/integrations/test_anthropic.py::TestAnthropicIntegration::test_03_single_tool_call -v + +# Test 7: Image Analysis (URL) +pytest tests/integrations/test_google.py::TestGoogleIntegration::test_07_image_url -v + +# Test 9: Multiple Images +pytest tests/integrations/test_litellm.py::TestLiteLLMIntegration::test_09_multiple_images -v + +# Test 21: Single Text Embedding (OpenAI only) +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_21_single_text_embedding -v + +# Test 23: Embedding Similarity Analysis (OpenAI only) +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_23_embedding_similarity_analysis -v +``` + +#### Running Test Categories by Pattern + +```bash +# Run all simple chat tests across integrations +pytest tests/integrations/ -k "test_01_simple_chat" -v + +# Run all tool calling tests (single and multiple) +pytest tests/integrations/ -k "tool_call" -v + +# Run all image-related tests +pytest tests/integrations/ -k "image" -v + +# Run all embedding tests (OpenAI only) +pytest tests/integrations/test_openai.py -k "embedding" -v + +# Run all speech and audio tests (OpenAI only) +pytest tests/integrations/test_openai.py -k "speech or transcription" -v + +# Run all end-to-end tests +pytest tests/integrations/ -k "end2end" -v + +# Run integration-specific feature tests +pytest tests/integrations/ -k "integration_specific" -v +``` + +#### Running Tests by Integration + +```bash +# Run all OpenAI tests +pytest tests/integrations/test_openai.py -v + +# Run all Anthropic tests with detailed output +pytest tests/integrations/test_anthropic.py -v -s + +# Run Google tests with coverage +pytest tests/integrations/test_google.py --cov=tests --cov-report=term-missing -v + +# Run LiteLLM tests with timing +pytest tests/integrations/test_litellm.py --durations=10 -v +``` + +#### Advanced Test Selection + +```bash +# Run tests 1-5 (basic functionality) for OpenAI +pytest tests/integrations/test_openai.py -k "test_01 or test_02 or test_03 or test_04 or test_05" -v + +# Run only vision tests (tests 7, 8, 9, 10) +pytest tests/integrations/ -k "test_07 or test_08 or test_09 or test_10" -v + +# Run tests excluding images (skip tests 7, 8, 9, 10) +pytest tests/integrations/ -k "not (test_07 or test_08 or test_09 or test_10)" -v + +# Run only tool-related tests (tests 3, 4, 5, 6) +pytest tests/integrations/ -k "test_03 or test_04 or test_05 or test_06" -v +``` + +#### Test Status and Validation + +```bash +# Check which tests would run (dry run) +pytest tests/integrations/test_openai.py --collect-only --quiet + +# Validate test setup without running +pytest tests/integrations/test_openai.py --setup-only -v + +# Run tests with immediate failure reporting +pytest tests/integrations/ -x -v # Stop on first failure + +# Run tests with detailed failure information +pytest tests/integrations/ --tb=long -v +``` + +#### Integration-Specific Test Validation + +```bash +# Check if integration supports all test categories +python -c " +from tests.integrations.test_openai import TestOpenAIIntegration +import inspect +methods = [m for m in dir(TestOpenAIIntegration) if m.startswith('test_')] +print('OpenAI Test Methods:') +for i, method in enumerate(sorted(methods), 1): + print(f' {i:2d}. {method}') +print(f'Total: {len(methods)} tests') +" + +# Verify integration configuration +python -c " +from tests.utils.config_loader import get_config, get_model +config = get_config() +integration = 'openai' +print(f'{integration.upper()} Configuration:') +for model_type in ['chat', 'vision', 'tools']: + try: + model = get_model(integration, model_type) + print(f' {model_type}: {model}') + except Exception as e: + print(f' {model_type}: ERROR - {e}') +" +``` + +#### Test Results Analysis + +```bash +# Run tests with detailed reporting +pytest tests/integrations/test_openai.py -v --tb=short --report=term-missing + +# Generate HTML test report +pytest tests/integrations/ --html=test_report.html --self-contained-html + +# Run tests with JSON output for analysis +pytest tests/integrations/test_openai.py --json-report --json-report-file=openai_results.json + +# Compare test results across integrations +pytest tests/integrations/ -v | grep -E "(PASSED|FAILED|SKIPPED)" | sort +``` + +#### Debugging Specific Tests + +```bash +# Debug a failing test with full output +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call -v -s --tb=long + +# Run test with Python debugger +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call --pdb + +# Run test with custom logging +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call --log-cli-level=DEBUG -s + +# Test with environment variable override +OPENAI_API_KEY=sk-test pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_01_simple_chat -v +``` + +#### Practical Testing Scenarios + +```bash +# Scenario 1: Test a new integration integration +# 1. Check configuration +python tests/utils/config_loader.py + +# 2. List available tests +pytest tests/integrations/test_your_integration.py --collect-only + +# 3. Run basic tests first (using test runner) +python run_integration_tests.py --integrations your_integration --test "test_01 or test_02" -v + +# 4. Test tool calling if supported (using test runner) +python run_integration_tests.py --integrations your_integration --test "tool_call" -v + +# Alternative: Direct pytest approach +pytest tests/integrations/test_your_integration.py -k "test_01 or test_02" -v +pytest tests/integrations/test_your_integration.py -k "tool_call" -v + +# Scenario 2: Debug a failing tool call test +# 1. Run with full debugging +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call -v -s --tb=long + +# 2. Check tool extraction function +python -c " +from tests.integrations.test_openai import extract_openai_tool_calls +print('Tool extraction function available:', callable(extract_openai_tool_calls)) +" + +# 3. Test with different model +OPENAI_CHAT_MODEL=gpt-4 pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call -v + +# Scenario 3: Compare integration capabilities +# Run the same test across all integrations (using test runner) +python run_integration_tests.py --integrations openai anthropic google litellm --test "test_01_simple_chat" -v + +# Alternative: Direct pytest approach +pytest tests/integrations/ -k "test_01_simple_chat" -v --tb=short + +# Scenario 4: Test only supported features +# For a integration that doesn't support images +pytest tests/integrations/test_your_integration.py -k "not (test_07 or test_08 or test_09 or test_10)" -v + +# Scenario 5: Performance testing +# Run with timing to identify slow tests +pytest tests/integrations/test_openai.py --durations=0 -v + +# Scenario 6: Continuous integration testing +# Run all tests with coverage and reports +pytest tests/integrations/ --cov=tests --cov-report=xml --junit-xml=test_results.xml -v +``` + +#### Test Output Examples + +```bash +# Successful test run +$ pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_01_simple_chat -v +========================= test session starts ========================= +tests/integrations/test_openai.py::TestOpenAIIntegration::test_01_simple_chat PASSED [100%] +βœ“ OpenAI simple chat test passed +Response: "Hello! I'm Claude, an AI assistant. How can I help you today?" + +# Failed test with debugging info +$ pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call -v -s +========================= FAILURES ========================= +_____________ TestOpenAIIntegration.test_03_single_tool_call _____________ +AssertionError: Expected tool calls but got none +Response content: "I can help with weather information, but I need a specific location." +Tool calls found: [] + +# Test collection output +$ pytest tests/integrations/test_openai.py --collect-only -q +tests/integrations/test_openai.py::TestOpenAIIntegration::test_01_simple_chat +tests/integrations/test_openai.py::TestOpenAIIntegration::test_02_multi_turn_conversation +tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call +tests/integrations/test_openai.py::TestOpenAIIntegration::test_04_multiple_tool_calls +tests/integrations/test_openai.py::TestOpenAIIntegration::test_05_end2end_tool_calling +tests/integrations/test_openai.py::TestOpenAIIntegration::test_06_automatic_function_calling +tests/integrations/test_openai.py::TestOpenAIIntegration::test_07_image_url +tests/integrations/test_openai.py::TestOpenAIIntegration::test_08_image_base64 +tests/integrations/test_openai.py::TestOpenAIIntegration::test_09_multiple_images +tests/integrations/test_openai.py::TestOpenAIIntegration::test_10_complex_end2end +tests/integrations/test_openai.py::TestOpenAIIntegration::test_11_integration_specific_features +11 tests collected + +# Test runner script output +$ python run_integration_tests.py --integrations google --test "test_03_single_tool_call" -v +πŸš€ Starting integration tests... +πŸ“‹ Testing integrations: google +============================================================ +πŸ§ͺ TESTING GOOGLE INTEGRATION +============================================================ +========================= test session starts ========================= +tests/integrations/test_google.py::TestGoogleIntegration::test_03_single_tool_call PASSED [100%] +βœ… GOOGLE tests PASSED + +================================================================================ +🎯 FINAL SUMMARY +================================================================================ + +πŸ”‘ API Key Status: + βœ… GOOGLE: Available + +πŸ“Š Test Results: + βœ… GOOGLE: All tests passed + +πŸ† Overall Results: + Integrations tested: 1 + Integrations passed: 1 + Success rate: 100.0% +``` + +### Environment Variables + +#### Required Variables + +```bash +# Bifrost gateway (required) +export BIFROST_BASE_URL="http://localhost:8080" + +# Integration API keys (at least one required) +export OPENAI_API_KEY="sk-..." +export ANTHROPIC_API_KEY="sk-ant-..." +export GOOGLE_API_KEY="AIza..." +``` + +#### Optional Variables + +```bash +# Integration-specific settings +export OPENAI_ORG_ID="org-..." +export OPENAI_PROJECT_ID="proj_..." +export GOOGLE_PROJECT_ID="your-project" +export GOOGLE_LOCATION="us-central1" + +# Environment configuration +export TEST_ENV="development" # or "production" +``` + +### Test Output and Debugging + +#### Understanding Test Results + +```bash +# Successful test output +βœ“ OpenAI Integration Tests + βœ“ test_01_simple_chat - Response: "Hello! How can I help you today?" + βœ“ test_03_single_tool_call - Tool called: get_weather(location="New York") + βœ“ test_07_image_url - Image analyzed successfully + +# Failed test output +βœ— test_03_single_tool_call - AssertionError: Expected tool calls but got none + Response content: "I can help with weather, but I need a specific location." +``` + +#### Debug Mode + +```bash +# Enable verbose output +pytest tests/integrations/test_openai.py -v -s + +# Show full tracebacks +pytest tests/integrations/test_openai.py --tb=long + +# Enable debug logging +pytest tests/integrations/test_openai.py --log-cli-level=DEBUG +``` + +## πŸ”¨ Adding New Integrations + +### Step-by-Step Guide + +#### 1. Update Configuration + +Add your integration to `config.yml`: + +```yaml +# Add to bifrost endpoints +bifrost: + endpoints: + your_integration: "/your_integration" + +# Add model configuration +models: + your_integration: + chat: "your-chat-model" + vision: "your-vision-model" + tools: "your-tools-model" + alternatives: ["alternative-model-1", "alternative-model-2"] + +# Add model capabilities +model_capabilities: + "your-chat-model": + chat: true + tools: true + vision: false + max_tokens: 4096 + context_window: 8192 + +# Add integration settings +integration_settings: + your_integration: + api_version: "v1" + custom_header: "value" +``` + +#### 2. Create Integration Test File + +Create `tests/integrations/test_your_integration.py`: + +```python +""" +Your Integration Tests + +Tests all 11 core scenarios using Your Integration SDK. +""" + +import pytest +from your_integration_sdk import YourIntegrationClient + +from ..utils.common import ( + Config, + SIMPLE_CHAT_MESSAGES, + MULTI_TURN_MESSAGES, + # ... import all test fixtures + get_api_key, + skip_if_no_api_key, + get_model, +) + + +@pytest.fixture +def your_integration_client(): + """Create Your Integration client for testing""" + from ..utils.config_loader import get_integration_url, get_config + + api_key = get_api_key("your_integration") + base_url = get_integration_url("your_integration") + + # Get additional integration settings + config = get_config() + integration_settings = config.get_integration_settings("your_integration") + api_config = config.get_api_config() + + client_kwargs = { + "api_key": api_key, + "base_url": base_url, + "timeout": api_config.get("timeout", 30), + "max_retries": api_config.get("max_retries", 3), + } + + # Add integration-specific settings + if integration_settings.get("api_version"): + client_kwargs["api_version"] = integration_settings["api_version"] + + return YourIntegrationClient(**client_kwargs) + + +@pytest.fixture +def test_config(): + """Test configuration""" + return Config() + + +class TestYourIntegrationIntegration: + """Test suite for Your Integration covering all 11 core scenarios""" + + @skip_if_no_api_key("your_integration") + def test_01_simple_chat(self, your_integration_client, test_config): + """Test Case 1: Simple chat interaction""" + response = your_integration_client.chat.create( + model=get_model("your_integration", "chat"), + messages=SIMPLE_CHAT_MESSAGES, + max_tokens=100, + ) + + assert_valid_chat_response(response) + assert response.content is not None + assert len(response.content) > 0 + + # ... implement all 11 test methods following the same pattern + # See existing integration test files for complete examples + + +def extract_your_integration_tool_calls(response) -> List[Dict[str, Any]]: + """Extract tool calls from Your Integration response format""" + tool_calls = [] + + # Implement based on your integration's response format + if hasattr(response, 'tool_calls') and response.tool_calls: + for tool_call in response.tool_calls: + tool_calls.append({ + "name": tool_call.function.name, + "arguments": json.loads(tool_call.function.arguments) + }) + + return tool_calls +``` + +#### 3. Update Common Utilities + +Add your integration to `tests/utils/common.py`: + +```python +def get_api_key(integration: str) -> str: + """Get API key for integration""" + key_map = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "litellm": "LITELLM_API_KEY", + "your_integration": "YOUR_INTEGRATION_API_KEY", # Add this line + } + + env_var = key_map.get(integration) + if not env_var: + raise ValueError(f"Unknown integration: {integration}") + + api_key = os.getenv(env_var) + if not api_key: + raise ValueError(f"{env_var} environment variable not set") + + return api_key +``` + +#### 4. Add Integration-Specific Tool Extraction + +Update the tool extraction functions in your test file: + +```python +def extract_your_integration_tool_calls(response: Any) -> List[Dict[str, Any]]: + """Extract tool calls from Your Integration response format""" + tool_calls = [] + + try: + # Implement based on your integration's response structure + # Example for a hypothetical integration: + if hasattr(response, 'function_calls'): + for fc in response.function_calls: + tool_calls.append({ + "name": fc.name, + "arguments": fc.parameters + }) + + return tool_calls + + except Exception as e: + print(f"Error extracting tool calls: {e}") + return [] +``` + +#### 5. Test Your Implementation + +```bash +# Set up environment +export YOUR_INTEGRATION_API_KEY="your-api-key" +export BIFROST_BASE_URL="http://localhost:8080" + +# Test configuration +python tests/utils/config_loader.py + +# Run your integration tests +pytest tests/integrations/test_your_integration.py -v + +# Run specific test +pytest tests/integrations/test_your_integration.py::TestYourIntegrationIntegration::test_01_simple_chat -v +``` + +### 🎯 Key Implementation Points + +#### 1. **Follow the Pattern** + +- Use existing integration test files as templates +- Implement all 11 test scenarios +- Follow the same naming conventions and structure + +#### 2. **Handle Integration Differences** + +```python +# Example: Different response formats +def assert_valid_chat_response(response): + """Validate chat response - adapt for your integration""" + if hasattr(response, 'choices'): # OpenAI-style + assert response.choices[0].message.content + elif hasattr(response, 'content'): # Anthropic-style + assert response.content[0].text + elif hasattr(response, 'text'): # Google-style + assert response.text + # Add your integration's format here +``` + +#### 3. **Implement Tool Calling** + +```python +def convert_to_your_integration_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert common tool format to your integration's format""" + your_integration_tools = [] + + for tool in tools: + # Convert to your integration's tool schema + your_integration_tools.append({ + "name": tool["name"], + "description": tool["description"], + "parameters": tool["parameters"], + # Add integration-specific fields + }) + + return your_integration_tools +``` + +#### 4. **Handle Image Processing** + +```python +def convert_to_your_integration_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert common message format to your integration's format""" + your_integration_messages = [] + + for msg in messages: + if isinstance(msg.get("content"), list): + # Handle multimodal content (text + images) + content = [] + for item in msg["content"]: + if item["type"] == "text": + content.append({"type": "text", "text": item["text"]}) + elif item["type"] == "image_url": + # Convert to your integration's image format + content.append({ + "type": "image", + "source": item["image_url"]["url"] + }) + your_integration_messages.append({"role": msg["role"], "content": content}) + else: + your_integration_messages.append(msg) + + return your_integration_messages +``` + +#### 5. **Error Handling** + +```python +@skip_if_no_api_key("your_integration") +def test_03_single_tool_call(self, your_integration_client, test_config): + """Test Case 3: Single tool call""" + try: + response = your_integration_client.chat.create( + model=get_model("your_integration", "tools"), + messages=SINGLE_TOOL_CALL_MESSAGES, + tools=convert_to_your_integration_tools([WEATHER_TOOL]), + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_your_integration_tool_calls(response) + assert tool_calls[0]["name"] == "get_weather" + assert "location" in tool_calls[0]["arguments"] + + except Exception as e: + pytest.skip(f"Tool calling not supported or failed: {e}") +``` + +### πŸ” Testing Checklist + +Before submitting your integration implementation: + +- [ ] **Configuration**: Integration added to `config.yml` with all required sections +- [ ] **Environment**: API key environment variable documented and tested +- [ ] **All 11 Tests**: Every test scenario implemented and passing +- [ ] **Tool Extraction**: Integration-specific tool call extraction function +- [ ] **Message Conversion**: Proper handling of multimodal messages +- [ ] **Error Handling**: Graceful handling of unsupported features +- [ ] **Documentation**: Integration added to README with capabilities +- [ ] **Bifrost Integration**: Base URL properly configured and tested + +### 🚨 Common Pitfalls + +1. **Incorrect Response Parsing**: Each integration has different response formats +2. **Tool Schema Differences**: Tool calling schemas vary significantly +3. **Image Format Handling**: Base64 vs URL handling differs per integration +4. **Missing Error Handling**: Some integrations don't support all features +5. **Configuration Errors**: Forgetting to add integration to all config sections + +## πŸ”§ Troubleshooting + +### Common Issues + +#### 1. Configuration Problems + +```bash +# Error: Configuration file not found +FileNotFoundError: Configuration file not found: config.yml + +# Solution: Ensure config.yml exists in project root +ls -la config.yml +``` + +#### 2. Integration Connection Issues + +```bash +# Error: Connection refused to Bifrost +ConnectionError: Connection refused to localhost:8080 + +# Solutions: +# 1. Check if Bifrost is running +curl http://localhost:8080/health + +# 2. Ensure BIFROST_BASE_URL is set correctly +echo $BIFROST_BASE_URL +``` + +#### 3. API Key Issues + +```bash +# Error: API key not set +ValueError: OPENAI_API_KEY environment variable not set + +# Solution: Set required environment variables +export OPENAI_API_KEY="sk-..." +export ANTHROPIC_API_KEY="sk-ant-..." +export GOOGLE_API_KEY="AIza..." +``` + +#### 4. Model Configuration Errors + +```bash +# Error: Unknown model type +ValueError: Unknown model type 'vision' for integration 'your_integration' + +# Solution: Check config.yml has all model types defined +python tests/utils/config_loader.py +``` + +#### 5. Test Failures + +```bash +# Error: Tool calls not found +AssertionError: Response should contain tool calls + +# Debug steps: +# 1. Check if integration supports tool calling +# 2. Verify tool extraction function +# 3. Check integration-specific tool format +pytest tests/integrations/test_openai.py::TestOpenAIIntegration::test_03_single_tool_call -v -s +``` + +### Debug Mode + +Enable comprehensive debugging: + +```bash +# Full verbose output with debugging +pytest tests/integrations/test_openai.py -v -s --tb=long --log-cli-level=DEBUG + +# Test configuration system +python tests/utils/config_loader.py + +# Check specific integration URL +python -c " +from tests.utils.config_loader import get_integration_url, get_model +print('OpenAI URL:', get_integration_url('openai')) +print('OpenAI Chat Model:', get_model('openai', 'chat')) +" +``` + +## πŸ“š Additional Resources + +### Configuration Examples + +- See `config.yml` for complete configuration reference +- Check `tests/utils/config_loader.py` for usage examples +- Review integration test files for implementation patterns + +### Contributing + +1. Fork the repository +2. Create feature branch: `git checkout -b feature/new-integration` +3. Follow the integration implementation guide above +4. Add comprehensive tests and documentation +5. Submit pull request with test results + +## πŸ†˜ Support + +For issues and questions: + +- Create GitHub issues for bugs and feature requests +- Check existing issues for solutions +- Review integration-specific documentation +- Test configuration with `python tests/utils/config_loader.py` + +--- + +**Note**: This test suite is designed for testing AI integrations through Bifrost proxy. Ensure your Bifrost instance is properly configured and running before executing tests. The configuration system provides Bifrost routing for maximum flexibility. diff --git a/tests/integrations/config.yml b/tests/integrations/config.yml new file mode 100644 index 000000000..5ed543de3 --- /dev/null +++ b/tests/integrations/config.yml @@ -0,0 +1,342 @@ +# Bifrost Integration Tests Configuration +# This file centralizes all configuration for AI integration clients and test settings + +# Bifrost Gateway Configuration +# All integrations route through Bifrost as a proxy/gateway +bifrost: + base_url: "${BIFROST_BASE_URL:-http://localhost:8080}" + + # Integration-specific endpoints (suffixes appended to base_url) + endpoints: + openai: "openai" + anthropic: "anthropic" + google: "genai" + litellm: "litellm" + langchain: "langchain" + + # Full URLs constructed as: {base_url.rstrip('/')}/{endpoints[integration]} + # Examples: + # - OpenAI: http://localhost:8080/openai + # - Anthropic: http://localhost:8080/anthropic + # - Google: http://localhost:8080/genai + # - LiteLLM: http://localhost:8080/litellm + # - LangChain: http://localhost:8080/langchain + +# API Configuration +api: + timeout: 30 # seconds + max_retries: 3 + retry_delay: 1 # seconds + +# Model configurations for each integration +models: + openai: + chat: "gpt-3.5-turbo" + vision: "gpt-4o" + tools: "gpt-3.5-turbo" + speech: "tts-1" + transcription: "whisper-1" + embeddings: "text-embedding-3-small" + alternatives: + - "gpt-4" + - "gpt-4-turbo-preview" + - "gpt-4o" + - "gpt-4o-mini" + speech_alternatives: + - "tts-1-hd" + transcription_alternatives: + - "whisper-1" + embeddings_alternatives: + - "text-embedding-3-large" + - "text-embedding-ada-002" + + anthropic: + chat: "claude-3-haiku-20240307" + vision: "claude-3-haiku-20240307" + tools: "claude-3-haiku-20240307" + speech: null # Anthropic doesn't support speech synthesis + transcription: null # Anthropic doesn't support transcription + alternatives: + - "claude-3-sonnet-20240229" + - "claude-3-opus-20240229" + - "claude-3-5-sonnet-20241022" + + google: + chat: "gemini-2.0-flash-001" + vision: "gemini-2.0-flash-001" + tools: "gemini-2.0-flash-001" + speech: null # Google doesn't expose speech synthesis through Gemini API + transcription: null # Google doesn't expose transcription through Gemini API + alternatives: + - "gemini-1.5-pro" + - "gemini-1.5-flash" + - "gemini-1.0-pro" + + litellm: + chat: "gpt-3.5-turbo" # Uses OpenAI by default + vision: "gpt-4o" # Uses OpenAI vision + tools: "gpt-3.5-turbo" # Uses OpenAI for tools + speech: "tts-1" # Uses OpenAI TTS through LiteLLM + transcription: "whisper-1" # Uses OpenAI Whisper through LiteLLM + embeddings: "text-embedding-3-small" # Uses OpenAI embeddings through LiteLLM + alternatives: + - "claude-3-haiku-20240307" # Anthropic via LiteLLM + - "gemini-2.0-flash-001" # Google via LiteLLM + - "gpt-4" # OpenAI GPT-4 + - "mistral-7b-instruct" # Mistral via LiteLLM + - "command-r-plus" # Cohere via LiteLLM + + langchain: + chat: "gpt-3.5-turbo" # OpenAI models via LangChain + vision: "gpt-4o" # OpenAI vision via LangChain + tools: "gpt-3.5-turbo" # Function calling via LangChain + speech: "tts-1" # OpenAI TTS via LangChain + transcription: "whisper-1" # OpenAI Whisper via LangChain + embeddings: "text-embedding-3-small" # OpenAI embeddings via LangChain + alternatives: + - "claude-3-haiku-20240307" # Anthropic via LangChain + - "gemini-2.0-flash-001" # Google via LangChain + - "gpt-4" # OpenAI GPT-4 via LangChain + +# Model capabilities matrix +model_capabilities: + # OpenAI Models + "gpt-3.5-turbo": + chat: true + tools: true + vision: false + streaming: true + max_tokens: 4096 + context_window: 4096 + + "gpt-4": + chat: true + tools: true + vision: false + streaming: true + max_tokens: 8192 + context_window: 8192 + + "gpt-4o": + chat: true + tools: true + vision: true + streaming: true + max_tokens: 4096 + context_window: 128000 + + "gpt-4o-mini": + chat: true + tools: true + vision: true + streaming: true + speech: false + transcription: false + max_tokens: 4096 + context_window: 128000 + + # OpenAI Speech Models + "tts-1": + chat: false + tools: false + vision: false + streaming: false + speech: true + transcription: false + max_tokens: null + context_window: null + + "tts-1-hd": + chat: false + tools: false + vision: false + streaming: false + speech: true + transcription: false + max_tokens: null + context_window: null + + # OpenAI Transcription Models + "whisper-1": + chat: false + tools: false + vision: false + streaming: false + speech: false + transcription: true + embeddings: false + max_tokens: null + context_window: null + + # OpenAI Embedding Models + "text-embedding-3-small": + chat: false + tools: false + vision: false + streaming: false + speech: false + transcription: false + embeddings: true + max_tokens: null + context_window: 8191 + dimensions: 1536 + + "text-embedding-3-large": + chat: false + tools: false + vision: false + streaming: false + speech: false + transcription: false + embeddings: true + max_tokens: null + context_window: 8191 + dimensions: 3072 + + "text-embedding-ada-002": + chat: false + tools: false + vision: false + streaming: false + speech: false + transcription: false + embeddings: true + max_tokens: null + context_window: 8191 + dimensions: 1536 + + # Anthropic Models + "claude-3-haiku-20240307": + chat: true + tools: true + vision: true + streaming: true + max_tokens: 4096 + context_window: 200000 + + "claude-3-sonnet-20240229": + chat: true + tools: true + vision: true + streaming: true + max_tokens: 4096 + context_window: 200000 + + "claude-3-opus-20240229": + chat: true + tools: true + vision: true + streaming: true + max_tokens: 4096 + context_window: 200000 + + # Google Models + "gemini-pro": + chat: true + tools: true + vision: false + streaming: true + max_tokens: 8192 + context_window: 32768 + + "gemini-2.0-flash-001": + chat: true + tools: true + vision: true + streaming: true + max_tokens: 8192 + context_window: 32768 + + "gemini-1.5-pro": + chat: true + tools: true + vision: true + streaming: true + max_tokens: 8192 + context_window: 1000000 + + # Mistral Models + "mistral-7b-instruct": + chat: true + tools: false + vision: false + streaming: true + max_tokens: 4096 + context_window: 32768 + + "mistral-8x7b-instruct": + chat: true + tools: true + vision: false + streaming: true + max_tokens: 4096 + context_window: 32768 + +# Test configuration +test_settings: + # Maximum tokens for test responses + max_tokens: + chat: 100 + vision: 200 + tools: 100 + complex: 300 + speech: null # Speech doesn't use token limits + transcription: null # Transcription doesn't use token limits + embeddings: null # Embeddings don't use token limits (text is the input) + + # Timeout settings for tests + timeouts: + simple: 30 # seconds + complex: 60 # seconds + + # Retry settings for flaky tests + retries: + max_attempts: 3 + delay: 2 # seconds + +# Integration-specific settings +integration_settings: + openai: + organization: "${OPENAI_ORG_ID:-}" + project: "${OPENAI_PROJECT_ID:-}" + + anthropic: + version: "2023-06-01" + + google: + project_id: "${GOOGLE_PROJECT_ID:-}" + location: "${GOOGLE_LOCATION:-us-central1}" + + litellm: + drop_params: true + debug: false + + langchain: + debug: false + streaming: true + +# Environment-specific overrides +environments: + development: + api: + timeout: 60 + max_retries: 5 + test_settings: + timeouts: + simple: 60 + complex: 120 + + production: + api: + timeout: 15 + max_retries: 2 + test_settings: + timeouts: + simple: 20 + complex: 40 + +# Logging configuration +logging: + level: "INFO" + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + file: "tests.log" diff --git a/tests/integrations/pytest.ini b/tests/integrations/pytest.ini new file mode 100644 index 000000000..6c53a50ea --- /dev/null +++ b/tests/integrations/pytest.ini @@ -0,0 +1,27 @@ +[pytest] +# Test discovery +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Output formatting +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --color=yes + +# Timeout settings (3 minutes per test) +timeout = 180 + +# Markers for test categorization +markers = + integration: marks tests as integration tests + slow: marks tests as slow running + e2e: marks tests as end-to-end tests + tool_calling: marks tests as tool calling tests + +# Minimum version +minversion = 7.0 \ No newline at end of file diff --git a/tests/integrations/requirements.txt b/tests/integrations/requirements.txt new file mode 100644 index 000000000..32adb34b5 --- /dev/null +++ b/tests/integrations/requirements.txt @@ -0,0 +1,43 @@ +# Core testing framework +pytest>=7.0.0 +pytest-asyncio>=0.21.0 + +# Environment and configuration +python-dotenv>=1.0.0 +PyYAML>=6.0 + +# Image processing +Pillow>=9.0.0 + +# HTTP requests for debugging +requests>=2.28.0 + +# Type hints +typing-extensions>=4.0.0 + +# Optional: For better test reporting +pytest-html>=3.1.0 +pytest-cov>=4.0.0 + +# AI/ML SDK dependencies +openai>=1.30.0 +anthropic>=0.25.0 +litellm>=1.35.0 +langchain-openai>=0.1.0 +langchain-core>=0.2.0 +langchain-anthropic>=0.1.0 +langchain-google-genai>=1.0.0 +langchain-mistralai>=0.1.0 +langgraph>=0.1.0 +mistralai>=0.4.0 +google-genai>=1.0.0 + +# Optional testing utilities +httpx>=0.25.0 +pytest-timeout>=2.1.0 +pytest-mock>=3.11.0 + +# Development dependencies (optional) +black>=23.0.0 # Code formatting +flake8>=6.0.0 # Linting +mypy>=1.5.0 # Type checking \ No newline at end of file diff --git a/tests/integrations/run_all_tests.py b/tests/integrations/run_all_tests.py new file mode 100755 index 000000000..953fff318 --- /dev/null +++ b/tests/integrations/run_all_tests.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +""" +Bifrost Integration End-to-End Test Runner + +This script runs all integration end-to-end tests for Bifrost. +It can run tests individually or all together, providing comprehensive +reporting and flexible execution options. + +Usage: + python run_all_tests.py # Run all tests + python run_all_tests.py --integration openai # Run specific integration + python run_all_tests.py --list # List available integrations + python run_all_tests.py --parallel # Run tests in parallel + python run_all_tests.py --verbose # Verbose output +""" + +import argparse +import subprocess +import sys +import time +import os +from pathlib import Path +from typing import List, Dict, Optional +import concurrent.futures +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + + +class BifrostTestRunner: + """Main test runner for Bifrost integration tests""" + + def __init__(self): + self.test_dir = Path(__file__).parent + self.integrations = { + "openai": { + "file": "tests/integrations/test_openai.py", + "description": "OpenAI Python SDK integration tests", + "env_vars": ["OPENAI_API_KEY"], + }, + "anthropic": { + "file": "tests/integrations/test_anthropic.py", + "description": "Anthropic Python SDK integration tests", + "env_vars": ["ANTHROPIC_API_KEY"], + }, + "litellm": { + "file": "tests/integrations/test_litellm.py", + "description": "LiteLLM integration tests", + "env_vars": ["OPENAI_API_KEY"], # LiteLLM can use OpenAI key + }, + "langchain": { + "file": "tests/integrations/test_langchain.py", + "description": "LangChain integration tests", + "env_vars": [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + ], # LangChain uses multiple providers + }, + "google": { + "file": "tests/integrations/test_google.py", + "description": "Google GenAI integration tests", + "env_vars": ["GOOGLE_API_KEY"], + }, + } + + self.results = {} + + def check_environment(self, integration: str) -> bool: + """Check if required environment variables are set for an integration""" + config = self.integrations[integration] + missing_vars = [] + + for var in config["env_vars"]: + if not os.getenv(var): + missing_vars.append(var) + + if missing_vars: + print( + f"⚠ Skipping {integration}: Missing environment variables: {', '.join(missing_vars)}" + ) + return False + + return True + + def run_integration_test(self, integration: str, verbose: bool = False) -> Dict: + """Run tests for a specific integration""" + if integration not in self.integrations: + return {"success": False, "error": f"Unknown integration: {integration}"} + + config = self.integrations[integration] + test_file = self.test_dir / config["file"] + + if not test_file.exists(): + return {"success": False, "error": f"Test file not found: {test_file}"} + + # Check environment variables + if not self.check_environment(integration): + return { + "success": False, + "error": "Missing required environment variables", + "skipped": True, + } + + print(f"\n{'='*60}") + print(f"Running {integration.upper()} Integration Tests") + print(f"{'='*60}") + print(f"Description: {config['description']}") + print(f"Test file: {config['file']}") + + start_time = time.time() + + try: + # Run the test with pytest + cmd = [sys.executable, "-m", "pytest", str(test_file)] + + # Add pytest flags for better output + if verbose: + cmd.extend(["-v", "-s"]) # verbose and don't capture output + else: + cmd.append("-q") # quiet mode + + if verbose: + result = subprocess.run( + cmd, cwd=self.test_dir, text=True, capture_output=False, timeout=300 + ) + else: + result = subprocess.run( + cmd, cwd=self.test_dir, text=True, capture_output=True, timeout=300 + ) + + elapsed_time = time.time() - start_time + + success = result.returncode == 0 + + return { + "success": success, + "return_code": result.returncode, + "stdout": result.stdout if not verbose else "", + "stderr": result.stderr if not verbose else "", + "elapsed_time": elapsed_time, + } + + except subprocess.TimeoutExpired: + return { + "success": False, + "error": "Test timed out (5 minutes)", + "elapsed_time": 300, + } + except Exception as e: + return { + "success": False, + "error": str(e), + "elapsed_time": time.time() - start_time, + } + + def run_all_tests(self, parallel: bool = False, verbose: bool = False) -> None: + """Run all integration tests""" + print("Bifrost Integration End-to-End Test Suite") + print("=" * 50) + print(f"Running tests for {len(self.integrations)} integrations") + print(f"Parallel execution: {'Enabled' if parallel else 'Disabled'}") + print(f"Verbose output: {'Enabled' if verbose else 'Disabled'}") + + # Check Bifrost availability + bifrost_url = os.getenv("BIFROST_BASE_URL", "http://localhost:8080") + print(f"Bifrost URL: {bifrost_url}") + + start_time = time.time() + + if parallel: + self._run_parallel(verbose) + else: + self._run_sequential(verbose) + + total_time = time.time() - start_time + self._print_summary(total_time) + + def _run_sequential(self, verbose: bool) -> None: + """Run tests sequentially""" + for integration in self.integrations: + self.results[integration] = self.run_integration_test(integration, verbose) + + def _run_parallel(self, verbose: bool) -> None: + """Run tests in parallel""" + print("\nRunning tests in parallel...") + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + # Submit all tests + future_to_integration = { + executor.submit( + self.run_integration_test, integration, verbose + ): integration + for integration in self.integrations + } + + # Collect results + for future in concurrent.futures.as_completed(future_to_integration): + integration = future_to_integration[future] + try: + self.results[integration] = future.result() + except Exception as e: + self.results[integration] = {"success": False, "error": str(e)} + + def _print_summary(self, total_time: float) -> None: + """Print test summary""" + print(f"\n{'='*60}") + print("TEST SUMMARY") + print(f"{'='*60}") + + passed = 0 + failed = 0 + skipped = 0 + + for integration, result in self.results.items(): + status = ( + "SKIPPED" + if result.get("skipped") + else ("PASSED" if result["success"] else "FAILED") + ) + elapsed = result.get("elapsed_time", 0) + + if result.get("skipped"): + skipped += 1 + print( + f"⚠ {integration:12} {status:8} - {result.get('error', 'Unknown error')}" + ) + elif result["success"]: + passed += 1 + print(f"βœ“ {integration:12} {status:8} - {elapsed:.2f}s") + else: + failed += 1 + error_msg = result.get("error", "Unknown error") + print(f"βœ— {integration:12} {status:8} - {error_msg}") + + # Print stderr if available + if "stderr" in result and result["stderr"]: + print(f" Error output: {result['stderr'][:200]}...") + + print(f"\n{'='*60}") + print( + f"Total: {len(self.integrations)} | Passed: {passed} | Failed: {failed} | Skipped: {skipped}" + ) + print(f"Total time: {total_time:.2f} seconds") + print(f"{'='*60}") + + # Exit with appropriate code + if failed > 0: + sys.exit(1) + else: + print("All tests completed successfully!") + + def list_integrations(self) -> None: + """List available integrations""" + print("Available Integrations:") + print("=" * 30) + + for integration, config in self.integrations.items(): + env_status = "βœ“" if self.check_environment(integration) else "βœ—" + print(f"{env_status} {integration:12} - {config['description']}") + print(f" Required env vars: {', '.join(config['env_vars'])}") + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Run Bifrost integration end-to-end tests", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python run_all_tests.py # Run all tests + python run_all_tests.py --integration openai # Run OpenAI tests only + python run_all_tests.py --parallel --verbose # Run all tests in parallel with verbose output + python run_all_tests.py --list # List available integrations + """, + ) + + parser.add_argument( + "--integration", "-i", help="Run tests for specific integration only" + ) + + parser.add_argument( + "--list", + "-l", + action="store_true", + help="List available integrations and their status", + ) + + parser.add_argument( + "--parallel", + "-p", + action="store_true", + help="Run tests in parallel (faster but less readable output)", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose output (shows test output in real-time)", + ) + + args = parser.parse_args() + + runner = BifrostTestRunner() + + if args.list: + runner.list_integrations() + return + + if args.integration: + if args.integration not in runner.integrations: + print(f"Error: Unknown integration '{args.integration}'") + print(f"Available integrations: {', '.join(runner.integrations.keys())}") + sys.exit(1) + + result = runner.run_integration_test(args.integration, args.verbose) + if result["success"]: + print(f"\nβœ“ {args.integration} tests passed!") + else: + error_msg = result.get("error", "Unknown error") + print(f"\nβœ— {args.integration} tests failed: {error_msg}") + + # Show stdout/stderr if available + if result.get("stdout"): + print("\n--- Test Output ---") + print(result["stdout"]) + if result.get("stderr"): + print("\n--- Error Output ---") + print(result["stderr"]) + + sys.exit(1) + else: + runner.run_all_tests(args.parallel, args.verbose) + + +if __name__ == "__main__": + main() diff --git a/tests/integrations/run_integration_tests.py b/tests/integrations/run_integration_tests.py new file mode 100755 index 000000000..169e7f0f2 --- /dev/null +++ b/tests/integrations/run_integration_tests.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Integration-specific test runner for Bifrost integration tests. + +This script runs tests for each integration independently using their native SDKs. +No more complex gateway conversions - just direct testing! +""" + +import os +import sys +import argparse +import subprocess +from pathlib import Path +from typing import List, Optional + + +def check_api_keys(): + """Check which API keys are available""" + keys = { + "openai": os.getenv("OPENAI_API_KEY"), + "anthropic": os.getenv("ANTHROPIC_API_KEY"), + "google": os.getenv("GOOGLE_API_KEY"), + "litellm": os.getenv("LITELLM_API_KEY"), + } + + available = [integration for integration, key in keys.items() if key] + missing = [integration for integration, key in keys.items() if not key] + + return available, missing + + +def run_integration_tests( + integrations: List[str], test_pattern: Optional[str] = None, verbose: bool = False +): + """Run tests for specified integrations""" + + results = {} + + for integration in integrations: + print(f"\n{'='*60}") + print(f"πŸ§ͺ TESTING {integration.upper()} INTEGRATION") + print(f"{'='*60}") + + # Build pytest command with absolute path relative to script location + script_dir = Path(__file__).parent + test_file = script_dir / "tests" / "integrations" / f"test_{integration}.py" + + # Check if test file exists + if not test_file.exists(): + print(f"❌ Test file not found: {test_file}") + results[integration] = {"error": f"Test file not found: {test_file}"} + continue + + cmd = ["python", "-m", "pytest", str(test_file)] + + if test_pattern: + cmd.extend(["-k", test_pattern]) + + if verbose: + cmd.append("-v") + else: + cmd.append("-q") + + # Remove integration-specific marker (not needed for file-based selection) + # cmd.extend(["-m", integration]) + + # Run the tests + try: + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=True, + ) + results[integration] = { + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": "", # stderr is now captured in stdout + } + + # Print results + print(f"βœ… {integration.upper()} tests PASSED") + + if verbose: + print(result.stdout) + + except subprocess.CalledProcessError as e: + print(f"❌ {integration.upper()} tests FAILED") + results[integration] = { + "returncode": e.returncode, + "stdout": e.stdout, + "stderr": "", # stderr is captured in stdout + } + + # Always print output on failure to show what went wrong + if e.stdout: + print(e.stdout) + + except Exception as e: + print(f"❌ Error running {integration} tests: {e}") + results[integration] = {"error": str(e)} + + return results + + +def print_summary( + results: dict, available_integrations: List[str], missing_integrations: List[str] +): + """Print final summary""" + print(f"\n{'='*80}") + print("🎯 FINAL SUMMARY") + print(f"{'='*80}") + + # API Key Status + print(f"\nπŸ”‘ API Key Status:") + for integration in available_integrations: + print(f" βœ… {integration.upper()}: Available") + + for integration in missing_integrations: + print(f" ❌ {integration.upper()}: Missing API key") + + # Test Results + print(f"\nπŸ“Š Test Results:") + passed_integrations = [] + failed_integrations = [] + + for integration, result in results.items(): + if "error" in result: + print(f" πŸ’₯ {integration.upper()}: Error - {result['error']}") + failed_integrations.append(integration) + elif result["returncode"] == 0: + print(f" βœ… {integration.upper()}: All tests passed") + passed_integrations.append(integration) + else: + print(f" ❌ {integration.upper()}: Some tests failed") + failed_integrations.append(integration) + + # Overall Status + total_tested = len(results) + total_passed = len(passed_integrations) + + print(f"\nπŸ† Overall Results:") + print(f" Integrations tested: {total_tested}") + print(f" Integrations passed: {total_passed}") + print( + f" Success rate: {(total_passed/total_tested)*100:.1f}%" + if total_tested > 0 + else " Success rate: N/A" + ) + + if failed_integrations: + print(f"\n⚠️ Failed integrations: {', '.join(failed_integrations)}") + print(" Check the detailed output above for specific test failures.") + + +def main(): + parser = argparse.ArgumentParser( + description="Run integration-specific integration tests" + ) + parser.add_argument( + "--integrations", + nargs="+", + choices=["openai", "anthropic", "google", "litellm", "all"], + default=["all"], + help="Integrations to test (default: all available)", + ) + parser.add_argument( + "--test", help="Run specific test pattern (e.g., 'test_01_simple_chat')" + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument( + "--check-keys", action="store_true", help="Only check API key availability" + ) + parser.add_argument( + "--show-models", + action="store_true", + help="Show model configuration for all integrations", + ) + + args = parser.parse_args() + + # Check API keys + available_integrations, missing_integrations = check_api_keys() + + if args.check_keys: + print("πŸ”‘ API Key Status:") + for integration in available_integrations: + print(f" βœ… {integration.upper()}: Available") + for integration in missing_integrations: + print(f" ❌ {integration.upper()}: Missing") + return + + if args.show_models: + # Import and show model configuration using absolute path + script_dir = Path(__file__).parent + models_path = script_dir / "tests" / "utils" / "models.py" + + if not models_path.exists(): + print(f"❌ Models file not found: {models_path}") + sys.exit(1) + + # Add the parent directory to sys.path to enable the import + models_parent_dir = str(script_dir) + if models_parent_dir not in sys.path: + sys.path.insert(0, models_parent_dir) + + try: + from tests.utils.models import print_model_summary + + print_model_summary() + except ImportError as e: + print(f"❌ Could not import print_model_summary: {e}") + print(f"Tried to import from: {models_path}") + sys.exit(1) + return + + # Determine which integrations to test + if "all" in args.integrations: + integrations_to_test = available_integrations + requested_integrations = [ + "openai", + "anthropic", + "google", + "litellm", + ] # all possible integrations + else: + integrations_to_test = [ + p for p in args.integrations if p in available_integrations + ] + requested_integrations = args.integrations + + if not integrations_to_test: + print("❌ No integrations available for testing. Please set API keys.") + print("\nRequired environment variables for requested integrations:") + for integration in requested_integrations: + if integration != "all": # Skip the "all" keyword + api_key_name = f"{integration.upper()}_API_KEY" + print(f" - {api_key_name}") + sys.exit(1) + + # Calculate which requested integrations are missing API keys + requested_missing_integrations = [ + integration + for integration in requested_integrations + if integration in missing_integrations + ] + + # Show what we're about to test + print("πŸš€ Starting integration tests...") + print(f"πŸ“‹ Testing integrations: {', '.join(integrations_to_test)}") + if requested_missing_integrations: + print( + f"⏭️ Skipping integrations (no API key): {', '.join(requested_missing_integrations)}" + ) + + # Run tests + results = run_integration_tests(integrations_to_test, args.test, args.verbose) + + # Print summary + print_summary(results, available_integrations, requested_missing_integrations) + + # Exit with appropriate code + failed_count = sum( + 1 for r in results.values() if r.get("returncode", 1) != 0 or "error" in r + ) + sys.exit(failed_count) + + +if __name__ == "__main__": + main() diff --git a/tests/integrations/test_audio.py b/tests/integrations/test_audio.py new file mode 100755 index 000000000..e52299897 --- /dev/null +++ b/tests/integrations/test_audio.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Dedicated test runner for Speech and Transcription functionality. +This script runs only the speech and transcription tests for easier development and debugging. + +Usage: + python test_audio.py + python test_audio.py --verbose + python test_audio.py --help +""" + +import sys +import os +import argparse +import subprocess +from pathlib import Path + +# Add the tests directory to Python path +tests_dir = Path(__file__).parent +sys.path.insert(0, str(tests_dir)) + + +def run_speech_transcription_tests(verbose=False, specific_test=None): + """Run speech and transcription tests""" + + # Change to the tests directory + os.chdir(tests_dir) + + # Build pytest command + cmd = ["python", "-m", "pytest"] + + if verbose: + cmd.append("-v") + else: + cmd.append("-q") + + # Add specific test pattern for speech/transcription tests + if specific_test: + test_pattern = f"tests/integrations/test_openai.py::{specific_test}" + else: + # Run all speech and transcription related tests + test_pattern = "tests/integrations/test_openai.py::TestOpenAIIntegration::test_14_speech_synthesis" + cmd.extend( + [ + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_14_speech_synthesis", + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_15_transcription_audio", + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_16_transcription_streaming", + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_17_speech_transcription_round_trip", + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_18_speech_error_handling", + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_19_transcription_error_handling", + "tests/integrations/test_openai.py::TestOpenAIIntegration::test_20_speech_different_voices_and_formats", + ] + ) + + if not specific_test: + # Add some useful pytest options + cmd.extend( + [ + "--tb=short", # Shorter traceback format + "--maxfail=3", # Stop after 3 failures + "-x", # Stop on first failure + ] + ) + else: + cmd.append(test_pattern) + + # Add environment info + print("🎡 SPEECH & TRANSCRIPTION INTEGRATION TESTS") + print("=" * 60) + print(f"πŸ”§ Running from: {tests_dir}") + print(f"πŸ“‹ Environment variables needed:") + print(" - OPENAI_API_KEY (required)") + print(" - BIFROST_BASE_URL (optional, defaults to http://localhost:8080)") + print() + + # Check for required environment variables + if not os.getenv("OPENAI_API_KEY"): + print("❌ ERROR: OPENAI_API_KEY environment variable is required") + print(" Set it with: export OPENAI_API_KEY=your_key_here") + return 1 + + bifrost_url = os.getenv("BIFROST_BASE_URL", "http://localhost:8080") + print(f"πŸŒ‰ Bifrost URL: {bifrost_url}") + print(f"πŸ€– Testing OpenAI integration through Bifrost proxy") + print() + + # Run the tests + print("πŸš€ Starting Speech & Transcription Tests...") + print("-" * 60) + + try: + result = subprocess.run(cmd, cwd=tests_dir) + return result.returncode + except KeyboardInterrupt: + print("\n❌ Tests interrupted by user") + return 1 + except Exception as e: + print(f"\n❌ Error running tests: {e}") + return 1 + + +def list_available_tests(): + """List all available speech and transcription tests""" + tests = [ + "test_14_speech_synthesis", + "test_15_transcription_audio", + "test_16_transcription_streaming", + "test_17_speech_transcription_round_trip", + "test_18_speech_error_handling", + "test_19_transcription_error_handling", + "test_20_speech_different_voices_and_formats", + ] + + print("🎡 Available Speech & Transcription Tests:") + print("=" * 50) + for i, test in enumerate(tests, 1): + print(f"{i:2d}. {test}") + print() + print("Run specific test with: python test_audio.py --test ") + + +def main(): + parser = argparse.ArgumentParser( + description="Run Speech and Transcription integration tests", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python test_audio.py # Run all speech/transcription tests + python test_audio.py --verbose # Run with verbose output + python test_audio.py --list # List available tests + python test_audio.py --test test_14_speech_synthesis # Run specific test + """, + ) + + parser.add_argument( + "--verbose", "-v", action="store_true", help="Enable verbose output" + ) + + parser.add_argument("--test", "-t", type=str, help="Run a specific test by name") + + parser.add_argument( + "--list", "-l", action="store_true", help="List available tests" + ) + + args = parser.parse_args() + + if args.list: + list_available_tests() + return 0 + + return run_speech_transcription_tests(verbose=args.verbose, specific_test=args.test) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/integrations/tests/__init__.py b/tests/integrations/tests/__init__.py new file mode 100644 index 000000000..92e4c036e --- /dev/null +++ b/tests/integrations/tests/__init__.py @@ -0,0 +1,8 @@ +""" +Bifrost Integration Tests + +Production-ready test suite for testing various AI integrations through Bifrost proxy. +Supports multiple integrations with uniform test interface. +""" + +__version__ = "1.0.0" diff --git a/tests/integrations/tests/conftest.py b/tests/integrations/tests/conftest.py new file mode 100644 index 000000000..bf8dc16a0 --- /dev/null +++ b/tests/integrations/tests/conftest.py @@ -0,0 +1,162 @@ +""" +Pytest configuration for integration-specific tests. +""" + +import pytest +import os + + +def pytest_configure(config): + """Configure pytest with custom markers""" + config.addinivalue_line("markers", "openai: mark test as requiring OpenAI API key") + config.addinivalue_line( + "markers", "anthropic: mark test as requiring Anthropic API key" + ) + config.addinivalue_line("markers", "google: mark test as requiring Google API key") + config.addinivalue_line("markers", "litellm: mark test as requiring LiteLLM setup") + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to add markers based on test file names""" + for item in items: + # Add markers based on test file location + if "test_openai" in item.nodeid: + item.add_marker(pytest.mark.openai) + elif "test_anthropic" in item.nodeid: + item.add_marker(pytest.mark.anthropic) + elif "test_google" in item.nodeid: + item.add_marker(pytest.mark.google) + elif "test_litellm" in item.nodeid: + item.add_marker(pytest.mark.litellm) + + +@pytest.fixture(scope="session") +def api_keys(): + """Collect all available API keys""" + return { + "openai": os.getenv("OPENAI_API_KEY"), + "anthropic": os.getenv("ANTHROPIC_API_KEY"), + "google": os.getenv("GOOGLE_API_KEY"), + "litellm": os.getenv("LITELLM_API_KEY"), + } + + +@pytest.fixture(scope="session") +def available_integrations(api_keys): + """Determine which integrations are available based on API keys""" + available = [] + + if api_keys["openai"]: + available.append("openai") + if api_keys["anthropic"]: + available.append("anthropic") + if api_keys["google"]: + available.append("google") + if api_keys["litellm"]: + available.append("litellm") + + return available + + +@pytest.fixture +def test_summary(): + """Fixture to collect test results for summary reporting""" + results = {"passed": [], "failed": [], "skipped": []} + return results + + +def pytest_runtest_makereport(item, call): + """Hook to capture test results""" + # Only record results during the "call" phase to avoid double counting + if call.when == "call": + # Extract integration and test info + integration = None + if "test_openai" in item.nodeid: + integration = "openai" + elif "test_anthropic" in item.nodeid: + integration = "anthropic" + elif "test_google" in item.nodeid: + integration = "google" + elif "test_litellm" in item.nodeid: + integration = "litellm" + + test_name = item.name + + # Store result info + result_info = { + "integration": integration, + "test": test_name, + "nodeid": item.nodeid, + } + + if hasattr(item.session, "test_results"): + if call.excinfo is None: + item.session.test_results["passed"].append(result_info) + else: + result_info["error"] = str(call.excinfo.value) + item.session.test_results["failed"].append(result_info) + + +def pytest_sessionstart(session): + """Initialize test results collection""" + session.test_results = {"passed": [], "failed": [], "skipped": []} + + +def pytest_sessionfinish(session, exitstatus): + """Print test summary at the end""" + results = session.test_results + + print("\n" + "=" * 80) + print("INTEGRATION TEST SUMMARY") + print("=" * 80) + + # Group results by integration + integration_results = {} + + for result in results["passed"] + results["failed"] + results["skipped"]: + integration = result.get("integration", "unknown") + if integration and integration not in integration_results: + integration_results[integration] = {"passed": 0, "failed": 0, "skipped": 0} + + for result in results["passed"]: + integration = result.get("integration", "unknown") + if integration and integration in integration_results: + integration_results[integration]["passed"] += 1 + + for result in results["failed"]: + integration = result.get("integration", "unknown") + if integration and integration in integration_results: + integration_results[integration]["failed"] += 1 + + for result in results["skipped"]: + integration = result.get("integration", "unknown") + if integration and integration in integration_results: + integration_results[integration]["skipped"] += 1 + + # Print summary by integration + for integration, counts in integration_results.items(): + total = counts["passed"] + counts["failed"] + counts["skipped"] + if total > 0: + print(f"\n{integration.upper()} Integration:") + print(f" βœ… Passed: {counts['passed']}") + print(f" ❌ Failed: {counts['failed']}") + print(f" ⏭️ Skipped: {counts['skipped']}") + print(f" πŸ“Š Total: {total}") + + if counts["passed"] > 0: + success_rate = ( + (counts["passed"] / (counts["passed"] + counts["failed"])) * 100 + if (counts["passed"] + counts["failed"]) > 0 + else 0 + ) + print(f" 🎯 Success Rate: {success_rate:.1f}%") + + # Print failed tests details + if results["failed"]: + print(f"\n❌ FAILED TESTS ({len(results['failed'])}):") + for result in results["failed"]: + print(f" β€’ {result['integration']}: {result['test']}") + if "error" in result: + print(f" Error: {result['error']}") + + print("\n" + "=" * 80) diff --git a/tests/integrations/tests/integrations/__init__.py b/tests/integrations/tests/integrations/__init__.py new file mode 100644 index 000000000..ec4135e3b --- /dev/null +++ b/tests/integrations/tests/integrations/__init__.py @@ -0,0 +1 @@ +# Integration-specific test packages diff --git a/tests/integrations/tests/integrations/test_anthropic.py b/tests/integrations/tests/integrations/test_anthropic.py new file mode 100644 index 000000000..c6acbfdef --- /dev/null +++ b/tests/integrations/tests/integrations/test_anthropic.py @@ -0,0 +1,610 @@ +""" +Anthropic Integration Tests + +πŸ€– MODELS USED: +- Chat: claude-3-haiku-20240307 +- Vision: claude-3-haiku-20240307 +- Tools: claude-3-haiku-20240307 +- Alternatives: claude-3-sonnet-20240229, claude-3-opus-20240229, claude-3-5-sonnet-20241022 + +Tests all 11 core scenarios using Anthropic SDK directly: +1. Simple chat +2. Multi turn conversation +3. Tool calls +4. Multiple tool calls +5. End2End tool calling +6. Automatic function calling +7. Image (url) +8. Image (base64) +9. Multiple images +10. Complete end2end test with conversation history, tool calls, tool results and images +11. Integration specific tests +""" + +import pytest +import base64 +import requests +from anthropic import Anthropic +from typing import List, Dict, Any + +from ..utils.common import ( + Config, + SIMPLE_CHAT_MESSAGES, + MULTI_TURN_MESSAGES, + SINGLE_TOOL_CALL_MESSAGES, + MULTIPLE_TOOL_CALL_MESSAGES, + IMAGE_URL, + BASE64_IMAGE, + INVALID_ROLE_MESSAGES, + STREAMING_CHAT_MESSAGES, + STREAMING_TOOL_CALL_MESSAGES, + WEATHER_TOOL, + CALCULATOR_TOOL, + ALL_TOOLS, + mock_tool_response, + assert_valid_chat_response, + assert_has_tool_calls, + assert_valid_image_response, + assert_valid_error_response, + assert_error_propagation, + assert_valid_streaming_response, + collect_streaming_content, + extract_tool_calls, + get_api_key, + skip_if_no_api_key, + COMPARISON_KEYWORDS, + WEATHER_KEYWORDS, + LOCATION_KEYWORDS, +) +from ..utils.config_loader import get_model + + +@pytest.fixture +def anthropic_client(): + """Create Anthropic client for testing""" + from ..utils.config_loader import get_integration_url, get_config + + api_key = get_api_key("anthropic") + base_url = get_integration_url("anthropic") + + # Get additional integration settings + config = get_config() + integration_settings = config.get_integration_settings("anthropic") + api_config = config.get_api_config() + + client_kwargs = { + "api_key": api_key, + "base_url": base_url, + "timeout": api_config.get("timeout", 30), + "max_retries": api_config.get("max_retries", 3), + } + + # Add Anthropic-specific settings + if integration_settings.get("version"): + client_kwargs["default_headers"] = { + "anthropic-version": integration_settings["version"] + } + + return Anthropic(**client_kwargs) + + +@pytest.fixture +def test_config(): + """Test configuration""" + return Config() + + +def convert_to_anthropic_messages( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Convert common message format to Anthropic format""" + anthropic_messages = [] + + for msg in messages: + if msg["role"] == "system": + continue # System messages handled separately in Anthropic + + # Handle image messages + if isinstance(msg.get("content"), list): + content = [] + for item in msg["content"]: + if item["type"] == "text": + content.append({"type": "text", "text": item["text"]}) + elif item["type"] == "image_url": + url = item["image_url"]["url"] + if url.startswith("data:image"): + # Base64 image + media_type, data = url.split(",", 1) + content.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data, + }, + } + ) + else: + # URL image - send URL directly to Anthropic + content.append( + { + "type": "image", + "source": { + "type": "url", + "url": url, + }, + } + ) + + anthropic_messages.append({"role": msg["role"], "content": content}) + else: + anthropic_messages.append({"role": msg["role"], "content": msg["content"]}) + + return anthropic_messages + + +def convert_to_anthropic_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert common tool format to Anthropic format""" + anthropic_tools = [] + + for tool in tools: + anthropic_tools.append( + { + "name": tool["name"], + "description": tool["description"], + "input_schema": tool["parameters"], + } + ) + + return anthropic_tools + + +class TestAnthropicIntegration: + """Test suite for Anthropic integration covering all 11 core scenarios""" + + @skip_if_no_api_key("anthropic") + def test_01_simple_chat(self, anthropic_client, test_config): + """Test Case 1: Simple chat interaction""" + messages = convert_to_anthropic_messages(SIMPLE_CHAT_MESSAGES) + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=100 + ) + + assert_valid_chat_response(response) + assert len(response.content) > 0 + assert response.content[0].type == "text" + assert len(response.content[0].text) > 0 + + @skip_if_no_api_key("anthropic") + def test_02_multi_turn_conversation(self, anthropic_client, test_config): + """Test Case 2: Multi-turn conversation""" + messages = convert_to_anthropic_messages(MULTI_TURN_MESSAGES) + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=150 + ) + + assert_valid_chat_response(response) + content = response.content[0].text.lower() + # Should mention population or numbers since we asked about Paris population + assert any( + word in content + for word in ["population", "million", "people", "inhabitants"] + ) + + @skip_if_no_api_key("anthropic") + def test_03_single_tool_call(self, anthropic_client, test_config): + """Test Case 3: Single tool call""" + messages = convert_to_anthropic_messages(SINGLE_TOOL_CALL_MESSAGES) + tools = convert_to_anthropic_tools([WEATHER_TOOL]) + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=messages, + tools=tools, + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_tool_calls(response) + assert tool_calls[0]["name"] == "get_weather" + assert "location" in tool_calls[0]["arguments"] + + @skip_if_no_api_key("anthropic") + def test_04_multiple_tool_calls(self, anthropic_client, test_config): + """Test Case 4: Multiple tool calls in one response""" + messages = convert_to_anthropic_messages(MULTIPLE_TOOL_CALL_MESSAGES) + tools = convert_to_anthropic_tools([WEATHER_TOOL, CALCULATOR_TOOL]) + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=messages, + tools=tools, + max_tokens=200, + ) + + # Anthropic might be more conservative with multiple tool calls + # Let's check if it made at least one tool call and prefer multiple if possible + assert_has_tool_calls(response) # At least 1 tool call + tool_calls = extract_anthropic_tool_calls(response) + tool_names = [tc["name"] for tc in tool_calls] + + # Should make relevant tool calls - either weather, calculate, or both + expected_tools = ["get_weather", "calculate"] + made_relevant_calls = any(name in expected_tools for name in tool_names) + assert ( + made_relevant_calls + ), f"Expected tool calls from {expected_tools}, got {tool_names}" + + @skip_if_no_api_key("anthropic") + def test_05_end2end_tool_calling(self, anthropic_client, test_config): + """Test Case 5: Complete tool calling flow with responses""" + messages = [{"role": "user", "content": "What's the weather in Boston?"}] + tools = convert_to_anthropic_tools([WEATHER_TOOL]) + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=messages, + tools=tools, + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + + # Add assistant's response to conversation + messages.append({"role": "assistant", "content": response.content}) + + # Add tool response + tool_calls = extract_anthropic_tool_calls(response) + tool_response = mock_tool_response( + tool_calls[0]["name"], tool_calls[0]["arguments"] + ) + + # Find the tool use block to get its ID + tool_use_id = None + for content in response.content: + if content.type == "tool_use": + tool_use_id = content.id + break + + messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": tool_response, + } + ], + } + ) + + # Get final response + final_response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=150 + ) + + # Anthropic might return empty content if tool result is sufficient + assert final_response is not None + if len(final_response.content) > 0: + assert_valid_chat_response(final_response) + content = final_response.content[0].text.lower() + weather_location_keywords = WEATHER_KEYWORDS + LOCATION_KEYWORDS + assert any(word in content for word in weather_location_keywords) + else: + # If no content, that's ok - tool result was sufficient + print("Model returned empty content - tool result was sufficient") + + @skip_if_no_api_key("anthropic") + def test_06_automatic_function_calling(self, anthropic_client, test_config): + """Test Case 6: Automatic function calling""" + messages = [{"role": "user", "content": "Calculate 25 * 4 for me"}] + tools = convert_to_anthropic_tools([CALCULATOR_TOOL]) + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=messages, + tools=tools, + max_tokens=100, + ) + + # Should automatically choose to use the calculator + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_tool_calls(response) + assert tool_calls[0]["name"] == "calculate" + + @skip_if_no_api_key("anthropic") + def test_07_image_url(self, anthropic_client, test_config): + """Test Case 7: Image analysis from URL""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What do you see in this image?"}, + { + "type": "image", + "source": { + "type": "url", + "url": IMAGE_URL, + }, + }, + ], + } + ] + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=200 + ) + + assert_valid_image_response(response) + + @skip_if_no_api_key("anthropic") + def test_08_image_base64(self, anthropic_client, test_config): + """Test Case 8: Image analysis from base64""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": BASE64_IMAGE, + }, + }, + ], + } + ] + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=200 + ) + + assert_valid_image_response(response) + + @skip_if_no_api_key("anthropic") + def test_09_multiple_images(self, anthropic_client, test_config): + """Test Case 9: Multiple image analysis""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Compare these two images"}, + { + "type": "image", + "source": { + "type": "url", + "url": IMAGE_URL, + }, + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": BASE64_IMAGE, + }, + }, + ], + } + ] + + response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=300 + ) + + assert_valid_image_response(response) + content = response.content[0].text.lower() + # Should mention comparison or differences + assert any( + word in content for word in COMPARISON_KEYWORDS + ), f"Response should contain comparison keywords. Got content: {content}" + + @skip_if_no_api_key("anthropic") + def test_10_complex_end2end(self, anthropic_client, test_config): + """Test Case 10: Complex end-to-end with conversation, images, and tools""" + messages = [ + {"role": "user", "content": "Hello! I need help with some tasks."}, + { + "role": "assistant", + "content": "Hello! I'd be happy to help you with your tasks. What do you need assistance with?", + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "First, can you tell me what's in this image and then get the weather for the location shown?", + }, + { + "type": "image", + "source": { + "type": "url", + "url": IMAGE_URL, + }, + }, + ], + }, + ] + + tools = convert_to_anthropic_tools([WEATHER_TOOL]) + + response1 = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=messages, + tools=tools, + max_tokens=300, + ) + + # Should either describe image or call weather tool (or both) + assert len(response1.content) > 0 + + # Add response to conversation + messages.append({"role": "assistant", "content": response1.content}) + + # If there were tool calls, handle them + tool_calls = extract_anthropic_tool_calls(response1) + if tool_calls: + for i, tool_call in enumerate(tool_calls): + tool_response = mock_tool_response( + tool_call["name"], tool_call["arguments"] + ) + + # Find the corresponding tool use ID + tool_use_id = None + for content in response1.content: + if content.type == "tool_use" and content.name == tool_call["name"]: + tool_use_id = content.id + break + + messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": tool_response, + } + ], + } + ) + + # Get final response after tool calls + final_response = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), messages=messages, max_tokens=200 + ) + + # Anthropic might return empty content if tool result is sufficient + # This is valid behavior - just check that we got a response + assert final_response is not None + if len(final_response.content) > 0: + # If there is content, validate it + assert_valid_chat_response(final_response) + else: + # If no content, that's ok too - tool result was sufficient + print("Model returned empty content - tool result was sufficient") + + @skip_if_no_api_key("anthropic") + def test_11_integration_specific_features(self, anthropic_client, test_config): + """Test Case 11: Anthropic-specific features""" + + # Test 1: System message + response1 = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + system="You are a helpful assistant that always responds in exactly 5 words.", + messages=[{"role": "user", "content": "Hello, how are you?"}], + max_tokens=50, + ) + + assert_valid_chat_response(response1) + # Check if response is approximately 5 words (allow some flexibility) + word_count = len(response1.content[0].text.split()) + assert 3 <= word_count <= 7, f"Expected ~5 words, got {word_count}" + + # Test 2: Temperature parameter + response2 = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=[ + {"role": "user", "content": "Tell me a creative story in one sentence."} + ], + temperature=0.9, + max_tokens=100, + ) + + assert_valid_chat_response(response2) + + # Test 3: Tool choice (any tool) + tools = convert_to_anthropic_tools([CALCULATOR_TOOL, WEATHER_TOOL]) + response3 = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=[{"role": "user", "content": "What's 15 + 27?"}], + tools=tools, + tool_choice={"type": "any"}, # Force tool use + max_tokens=100, + ) + + assert_has_tool_calls(response3) + tool_calls = extract_anthropic_tool_calls(response3) + # Should prefer calculator for math question + assert tool_calls[0]["name"] == "calculate" + + @skip_if_no_api_key("anthropic") + def test_12_error_handling_invalid_roles(self, anthropic_client, test_config): + """Test Case 12: Error handling for invalid roles""" + with pytest.raises(Exception) as exc_info: + anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=INVALID_ROLE_MESSAGES, + max_tokens=100, + ) + + # Verify the error is properly caught and contains role-related information + error = exc_info.value + assert_valid_error_response(error, "tester") + assert_error_propagation(error, "anthropic") + + @skip_if_no_api_key("anthropic") + def test_13_streaming(self, anthropic_client, test_config): + """Test Case 13: Streaming chat completion""" + # Test basic streaming + stream = anthropic_client.messages.create( + model=get_model("anthropic", "chat"), + messages=STREAMING_CHAT_MESSAGES, + max_tokens=200, + stream=True, + ) + + content, chunk_count, tool_calls_detected = collect_streaming_content( + stream, "anthropic", timeout=30 + ) + + # Validate streaming results + assert chunk_count > 0, "Should receive at least one chunk" + assert len(content) > 10, "Should receive substantial content" + assert not tool_calls_detected, "Basic streaming shouldn't have tool calls" + + # Test streaming with tool calls + stream_with_tools = anthropic_client.messages.create( + model=get_model("anthropic", "tools"), + messages=STREAMING_TOOL_CALL_MESSAGES, + max_tokens=150, + tools=convert_to_anthropic_tools([WEATHER_TOOL]), + stream=True, + ) + + content_tools, chunk_count_tools, tool_calls_detected_tools = ( + collect_streaming_content(stream_with_tools, "anthropic", timeout=30) + ) + + # Validate tool streaming results + assert chunk_count_tools > 0, "Should receive at least one chunk with tools" + assert tool_calls_detected_tools, "Should receive at least one chunk with tools" + + +# Additional helper functions specific to Anthropic +def extract_anthropic_tool_calls(response: Any) -> List[Dict[str, Any]]: + """Extract tool calls from Anthropic response format with proper type checking""" + tool_calls = [] + + # Type check for Anthropic Message response + if not hasattr(response, "content") or not response.content: + return tool_calls + + for content in response.content: + if hasattr(content, "type") and content.type == "tool_use": + if hasattr(content, "name") and hasattr(content, "input"): + try: + tool_calls.append( + {"name": content.name, "arguments": content.input} + ) + except AttributeError as e: + print(f"Warning: Failed to extract tool call from content: {e}") + continue + + return tool_calls diff --git a/tests/integrations/tests/integrations/test_google.py b/tests/integrations/tests/integrations/test_google.py new file mode 100644 index 000000000..fea509222 --- /dev/null +++ b/tests/integrations/tests/integrations/test_google.py @@ -0,0 +1,528 @@ +""" +Google GenAI Integration Tests + +Tests all 11 core scenarios using Google GenAI SDK directly: +1. Simple chat +2. Multi turn conversation +3. Tool calls +4. Multiple tool calls +5. End2End tool calling +6. Automatic function calling +7. Image (url) +8. Image (base64) +9. Multiple images +10. Complete end2end test with conversation history, tool calls, tool results and images +11. Integration specific tests +""" + +import pytest +import base64 +import requests +from PIL import Image +import io +from google import genai +from google.genai.types import HttpOptions +from google.genai import types +from typing import List, Dict, Any + +from ..utils.common import ( + Config, + SIMPLE_CHAT_MESSAGES, + SINGLE_TOOL_CALL_MESSAGES, + MULTIPLE_TOOL_CALL_MESSAGES, + IMAGE_URL, + BASE64_IMAGE, + INVALID_ROLE_MESSAGES, + STREAMING_CHAT_MESSAGES, + STREAMING_TOOL_CALL_MESSAGES, + WEATHER_TOOL, + CALCULATOR_TOOL, + assert_valid_chat_response, + assert_valid_embedding_response, + assert_valid_image_response, + assert_valid_error_response, + assert_error_propagation, + assert_valid_streaming_response, + collect_streaming_content, + get_api_key, + skip_if_no_api_key, + COMPARISON_KEYWORDS, + WEATHER_KEYWORDS, + LOCATION_KEYWORDS, + GENAI_INVALID_ROLE_CONTENT, + EMBEDDINGS_SINGLE_TEXT, +) +from ..utils.config_loader import get_model + + +@pytest.fixture +def google_client(): + """Configure Google GenAI client for testing""" + from ..utils.config_loader import get_integration_url + + api_key = get_api_key("google") + base_url = get_integration_url("google") + + client_kwargs = { + "api_key": api_key, + } + + # Add base URL support and timeout through HttpOptions + http_options_kwargs = {} + if base_url: + http_options_kwargs["base_url"] = base_url + + if http_options_kwargs: + client_kwargs["http_options"] = HttpOptions(**http_options_kwargs) + + return genai.Client(**client_kwargs) + + +@pytest.fixture +def test_config(): + """Test configuration""" + return Config() + + +def convert_to_google_messages(messages: List[Dict[str, Any]]) -> str: + """Convert common message format to Google GenAI format""" + # Google GenAI uses a simpler format - just extract the first user message + for msg in messages: + if msg["role"] == "user": + if isinstance(msg["content"], str): + return msg["content"] + elif isinstance(msg["content"], list): + # Handle multimodal content + text_parts = [ + item["text"] for item in msg["content"] if item["type"] == "text" + ] + if text_parts: + return text_parts[0] + return "Hello" + + +def convert_to_google_tools(tools: List[Dict[str, Any]]) -> List[Any]: + """Convert common tool format to Google GenAI format using FunctionDeclaration""" + from google.genai import types + + google_tools = [] + + for tool in tools: + # Create a FunctionDeclaration for each tool + function_declaration = types.FunctionDeclaration( + name=tool["name"], + description=tool["description"], + parameters=types.Schema( + type=tool["parameters"]["type"].upper(), + properties={ + name: types.Schema( + type=prop["type"].upper(), + description=prop.get("description", ""), + ) + for name, prop in tool["parameters"]["properties"].items() + }, + required=tool["parameters"].get("required", []), + ), + ) + + # Create a Tool object containing the function declaration + google_tool = types.Tool(function_declarations=[function_declaration]) + google_tools.append(google_tool) + + return google_tools + + +def load_image_from_url(url: str): + """Load image from URL for Google GenAI""" + from google.genai import types + import io + import base64 + + if url.startswith("data:image"): + # Base64 image - extract the base64 data part + header, data = url.split(",", 1) + img_data = base64.b64decode(data) + image = Image.open(io.BytesIO(img_data)) + else: + # URL image + response = requests.get(url) + image = Image.open(io.BytesIO(response.content)) + + # Resize image to reduce payload size (max width/height of 512px) + max_size = 512 + if image.width > max_size or image.height > max_size: + image.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) + + # Convert to RGB if necessary (for JPEG compatibility) + if image.mode in ("RGBA", "LA", "P"): + # Create a white background + background = Image.new("RGB", image.size, (255, 255, 255)) + if image.mode == "P": + image = image.convert("RGBA") + background.paste( + image, mask=image.split()[-1] if image.mode in ("RGBA", "LA") else None + ) + image = background + + # Convert PIL Image to compressed JPEG bytes + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format="JPEG", quality=85, optimize=True) + img_byte_arr = img_byte_arr.getvalue() + + # Use the correct Part.from_bytes method as per Google GenAI documentation + return types.Part.from_bytes(data=img_byte_arr, mime_type="image/jpeg") + + +class TestGoogleIntegration: + """Test suite for Google GenAI integration covering all 11 core scenarios""" + + @skip_if_no_api_key("google") + def test_01_simple_chat(self, google_client, test_config): + """Test Case 1: Simple chat interaction""" + message = convert_to_google_messages(SIMPLE_CHAT_MESSAGES) + + response = google_client.models.generate_content( + model=get_model("google", "chat"), contents=message + ) + + assert_valid_chat_response(response) + assert response.text is not None + assert len(response.text) > 0 + + @skip_if_no_api_key("google") + def test_02_multi_turn_conversation(self, google_client, test_config): + """Test Case 2: Multi-turn conversation""" + # Start a chat session for multi-turn + chat = google_client.chats.create(model=get_model("google", "chat")) + + # Send first message + response1 = chat.send_message("What's the capital of France?") + assert_valid_chat_response(response1) + + # Send follow-up message + response2 = chat.send_message("What's the population of that city?") + assert_valid_chat_response(response2) + + content = response2.text.lower() + # Should mention population or numbers since we asked about Paris population + assert any( + word in content + for word in ["population", "million", "people", "inhabitants"] + ) + + @skip_if_no_api_key("google") + def test_03_single_tool_call(self, google_client, test_config): + """Test Case 3: Single tool call""" + from google.genai import types + + tools = convert_to_google_tools([WEATHER_TOOL]) + message = convert_to_google_messages(SINGLE_TOOL_CALL_MESSAGES) + + response = google_client.models.generate_content( + model=get_model("google", "tools"), + contents=message, + config=types.GenerateContentConfig(tools=tools), + ) + + # Check for function calls in response + assert response.candidates is not None + assert len(response.candidates) > 0 + + # Check if function call was made (Google GenAI might return function calls) + if hasattr(response, "function_calls") and response.function_calls: + assert len(response.function_calls) >= 1 + assert response.function_calls[0].name == "get_weather" + + @skip_if_no_api_key("google") + def test_04_multiple_tool_calls(self, google_client, test_config): + """Test Case 4: Multiple tool calls in one response""" + from google.genai import types + + tools = convert_to_google_tools([WEATHER_TOOL, CALCULATOR_TOOL]) + message = convert_to_google_messages(MULTIPLE_TOOL_CALL_MESSAGES) + + response = google_client.models.generate_content( + model=get_model("google", "tools"), + contents=message, + config=types.GenerateContentConfig(tools=tools), + ) + + # Check for function calls + assert response.candidates is not None + + # Check if function calls were made + if hasattr(response, "function_calls") and response.function_calls: + # Should have multiple function calls + assert len(response.function_calls) >= 1 + function_names = [fc.name for fc in response.function_calls] + # At least one of the expected tools should be called + assert any(name in ["get_weather", "calculate"] for name in function_names) + + @skip_if_no_api_key("google") + def test_05_end2end_tool_calling(self, google_client, test_config): + """Test Case 5: Complete tool calling flow with responses""" + from google.genai import types + + tools = convert_to_google_tools([WEATHER_TOOL]) + + # Start chat for tool calling flow + chat = google_client.chats.create(model=get_model("google", "tools")) + + response1 = chat.send_message( + "What's the weather in Boston?", + config=types.GenerateContentConfig(tools=tools), + ) + + # Check if function call was made + if hasattr(response1, "function_calls") and response1.function_calls: + # Simulate function execution and send result back + for fc in response1.function_calls: + if fc.name == "get_weather": + # Mock function result and send back + response2 = chat.send_message( + types.Part.from_function_response( + name=fc.name, + response={ + "result": "The weather in Boston is 72Β°F and sunny." + }, + ) + ) + assert_valid_chat_response(response2) + + content = response2.text.lower() + weather_location_keywords = WEATHER_KEYWORDS + LOCATION_KEYWORDS + assert any(word in content for word in weather_location_keywords) + + @skip_if_no_api_key("google") + def test_06_automatic_function_calling(self, google_client, test_config): + """Test Case 6: Automatic function calling""" + from google.genai import types + + tools = convert_to_google_tools([CALCULATOR_TOOL]) + + response = google_client.models.generate_content( + model=get_model("google", "tools"), + contents="Calculate 25 * 4 for me", + config=types.GenerateContentConfig(tools=tools), + ) + + # Should automatically choose to use the calculator + assert response.candidates is not None + + # Check if function calls were made + if hasattr(response, "function_calls") and response.function_calls: + assert response.function_calls[0].name == "calculate" + + @skip_if_no_api_key("google") + def test_07_image_url(self, google_client, test_config): + """Test Case 7: Image analysis from URL""" + image = load_image_from_url(IMAGE_URL) + + response = google_client.models.generate_content( + model=get_model("google", "vision"), + contents=["What do you see in this image?", image], + ) + + assert_valid_image_response(response) + + @skip_if_no_api_key("google") + def test_08_image_base64(self, google_client, test_config): + """Test Case 8: Image analysis from base64""" + image = load_image_from_url(f"data:image/png;base64,{BASE64_IMAGE}") + + response = google_client.models.generate_content( + model=get_model("google", "vision"), contents=["Describe this image", image] + ) + + assert_valid_image_response(response) + + @skip_if_no_api_key("google") + def test_09_multiple_images(self, google_client, test_config): + """Test Case 9: Multiple image analysis""" + image1 = load_image_from_url(IMAGE_URL) + image2 = load_image_from_url(f"data:image/png;base64,{BASE64_IMAGE}") + + response = google_client.models.generate_content( + model=get_model("google", "vision"), + contents=["Compare these two images", image1, image2], + ) + + assert_valid_image_response(response) + content = response.text.lower() + # Should mention comparison or differences + assert any( + word in content for word in COMPARISON_KEYWORDS + ), f"Response should contain comparison keywords. Got content: {content}" + + @skip_if_no_api_key("google") + def test_10_complex_end2end(self, google_client, test_config): + """Test Case 10: Complex end-to-end with conversation, images, and tools""" + from google.genai import types + + tools = convert_to_google_tools([WEATHER_TOOL]) + + image = load_image_from_url(IMAGE_URL) + + # Start complex conversation + chat = google_client.chats.create(model=get_model("google", "vision")) + + response1 = chat.send_message( + [ + "First, can you tell me what's in this image and then get the weather for the location shown?", + image, + ], + config=types.GenerateContentConfig(tools=tools), + ) + + # Should either describe image or call weather tool (or both) + assert response1.candidates is not None + + # Check for function calls and handle them + if hasattr(response1, "function_calls") and response1.function_calls: + for fc in response1.function_calls: + if fc.name == "get_weather": + # Send function result back + final_response = chat.send_message( + types.Part.from_function_response( + name=fc.name, + response={"result": "The weather is 72Β°F and sunny."}, + ) + ) + assert_valid_chat_response(final_response) + + @skip_if_no_api_key("google") + def test_11_integration_specific_features(self, google_client, test_config): + """Test Case 11: Google GenAI-specific features""" + + # Test 1: Generation config with temperature + from google.genai import types + + response1 = google_client.models.generate_content( + model=get_model("google", "chat"), + contents="Tell me a creative story in one sentence.", + config=types.GenerateContentConfig(temperature=0.9, max_output_tokens=100), + ) + + assert_valid_chat_response(response1) + + # Test 2: Safety settings + response2 = google_client.models.generate_content( + model=get_model("google", "chat"), + contents="Hello, how are you?", + config=types.GenerateContentConfig( + safety_settings=[ + types.SafetySetting( + category="HARM_CATEGORY_HARASSMENT", + threshold="BLOCK_MEDIUM_AND_ABOVE", + ) + ] + ), + ) + + assert_valid_chat_response(response2) + + # Test 3: System instruction + response3 = google_client.models.generate_content( + model=get_model("google", "chat"), + contents="high", + config=types.GenerateContentConfig( + system_instruction="I say high, you say low", + max_output_tokens=10, + ), + ) + + assert_valid_chat_response(response3) + + @skip_if_no_api_key("google") + def test_12_error_handling_invalid_roles(self, google_client, test_config): + """Test Case 12: Error handling for invalid roles""" + with pytest.raises(Exception) as exc_info: + google_client.models.generate_content( + model=get_model("google", "chat"), contents=GENAI_INVALID_ROLE_CONTENT + ) + + # Verify the error is properly caught and contains role-related information + error = exc_info.value + assert_valid_error_response(error, "tester") + assert_error_propagation(error, "google") + + @skip_if_no_api_key("google") + def test_13_streaming(self, google_client, test_config): + """Test Case 13: Streaming chat completion using Google GenAI SDK""" + + # Use the correct Google GenAI SDK streaming method + stream = google_client.models.generate_content_stream( + model=get_model("google", "chat"), + contents="Tell me a short story about a robot", + ) + + content = "" + chunk_count = 0 + + # Collect streaming content + for chunk in stream: + chunk_count += 1 + if chunk.text: + content += chunk.text + + # Validate streaming results + assert chunk_count > 0, "Should receive at least one chunk" + assert len(content) > 10, "Should receive substantial content" + + # Check for robot-related terms (the story might not use the exact word "robot") + robot_terms = [ + "robot", + "metallic", + "programmed", + "unit", + "custodian", + "mechanical", + "android", + "machine", + ] + has_robot_content = any(term in content.lower() for term in robot_terms) + assert ( + has_robot_content + ), f"Content should relate to robots. Found content: {content[:200]}..." + + print( + f"βœ… Streaming test passed: {chunk_count} chunks, {len(content)} characters" + ) + + @skip_if_no_api_key("google") + def test_14_single_text_embedding(self, google_client, test_config): + """Test Case 21: Single text embedding generation""" + response = google_client.models.embed_content( + model="gemini-embedding-001", contents=EMBEDDINGS_SINGLE_TEXT, + config=types.EmbedContentConfig(output_dimensionality=1536) + ) + + assert_valid_embedding_response(response, expected_dimensions=1536) + + # Verify response structure + assert len(response.embeddings) == 1, "Should have exactly one embedding" + + +# Additional helper functions specific to Google GenAI +def extract_google_function_calls(response: Any) -> List[Dict[str, Any]]: + """Extract function calls from Google GenAI response format with proper type checking""" + function_calls = [] + + # Type check for Google GenAI response + if not hasattr(response, "function_calls") or not response.function_calls: + return function_calls + + for fc in response.function_calls: + if hasattr(fc, "name") and hasattr(fc, "args"): + try: + function_calls.append( + { + "name": fc.name, + "arguments": dict(fc.args) if fc.args else {}, + } + ) + except (AttributeError, TypeError) as e: + print(f"Warning: Failed to extract Google function call: {e}") + continue + + return function_calls diff --git a/tests/integrations/tests/integrations/test_langchain.py b/tests/integrations/tests/integrations/test_langchain.py new file mode 100644 index 000000000..dbbff9cc8 --- /dev/null +++ b/tests/integrations/tests/integrations/test_langchain.py @@ -0,0 +1,924 @@ +""" +LangChain Integration Tests + +🦜 LANGCHAIN COMPONENTS TESTED: +- Chat Models: OpenAI ChatOpenAI, Anthropic ChatAnthropic, Google ChatVertexAI +- Provider-Specific: Google ChatGoogleGenerativeAI, Mistral ChatMistralAI +- Embeddings: OpenAI OpenAIEmbeddings, Google VertexAIEmbeddings +- Tools: Function calling and tool integration +- Chains: LLMChain, ConversationChain, SequentialChain +- Memory: ConversationBufferMemory, ConversationSummaryMemory +- Agents: OpenAI Functions Agent, ReAct Agent +- Streaming: Real-time response streaming +- Vector Stores: Integration with embeddings and retrieval + +Tests LangChain standard interface compliance and Bifrost integration: +1. Chat model standard tests (via LangChain test suite) +2. Embeddings standard tests (via LangChain test suite) +3. Tool integration and function calling +4. Chain composition and execution +5. Memory management and conversation history +6. Agent reasoning and tool usage +7. Streaming responses and async operations +8. Vector store operations +9. Multi-provider compatibility +10. Error handling and fallbacks +11. LangChain Expression Language (LCEL) +12. Google Gemini integration via langchain-google-genai +13. Mistral AI integration via langchain-mistralai +14. Provider-specific streaming capabilities +15. Cross-provider response comparison +""" + +import pytest +import asyncio +import os +from typing import List, Dict, Any, Type, Optional +from unittest.mock import patch + +# LangChain core imports +from langchain_core.messages import HumanMessage, AIMessage, SystemMessage +from langchain_core.tools import BaseTool +from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate +from langchain_core.output_parsers import StrOutputParser +from langchain_core.runnables import RunnablePassthrough + +# LangChain provider imports +from langchain_openai import ChatOpenAI, OpenAIEmbeddings +from langchain_anthropic import ChatAnthropic + +# Optional imports for providers that may not be available +try: + from langchain_google_vertexai import ChatVertexAI, VertexAIEmbeddings + + GOOGLE_VERTEXAI_AVAILABLE = True +except ImportError: + GOOGLE_VERTEXAI_AVAILABLE = False + ChatVertexAI = None + VertexAIEmbeddings = None + +# Google Gemini specific imports +try: + from langchain_google_genai import ChatGoogleGenerativeAI + + GOOGLE_GENAI_AVAILABLE = True +except ImportError: + GOOGLE_GENAI_AVAILABLE = False + ChatGoogleGenerativeAI = None + +# Mistral specific imports +try: + from langchain_mistralai import ChatMistralAI + + MISTRAL_AI_AVAILABLE = True +except ImportError: + MISTRAL_AI_AVAILABLE = False + ChatMistralAI = None + +# Optional imports for legacy LangChain (chains, memory, agents) +try: + from langchain.chains import LLMChain, ConversationChain, SequentialChain + from langchain.memory import ConversationBufferMemory, ConversationSummaryMemory + from langchain.agents import ( + AgentExecutor, + create_openai_functions_agent, + create_react_agent, + ) + from langchain.agents.tools import Tool + + LEGACY_LANGCHAIN_AVAILABLE = True +except ImportError: + LEGACY_LANGCHAIN_AVAILABLE = False + LLMChain = ConversationChain = SequentialChain = None + ConversationBufferMemory = ConversationSummaryMemory = None + AgentExecutor = create_openai_functions_agent = create_react_agent = Tool = None + +# LangChain standard tests (if available) +try: + from langchain_tests.integration_tests import ChatModelIntegrationTests + from langchain_tests.integration_tests import EmbeddingsIntegrationTests + + LANGCHAIN_TESTS_AVAILABLE = True +except ImportError: + # Fallback for environments without langchain-tests + LANGCHAIN_TESTS_AVAILABLE = False + + class ChatModelIntegrationTests: + pass + + class EmbeddingsIntegrationTests: + pass + + +from ..utils.common import ( + Config, + SIMPLE_CHAT_MESSAGES, + MULTI_TURN_MESSAGES, + WEATHER_TOOL, + CALCULATOR_TOOL, + EMBEDDINGS_SINGLE_TEXT, + EMBEDDINGS_MULTIPLE_TEXTS, + EMBEDDINGS_SIMILAR_TEXTS, + mock_tool_response, + assert_valid_chat_response, + assert_valid_embedding_response, + assert_valid_embeddings_batch_response, + calculate_cosine_similarity, + get_api_key, + skip_if_no_api_key, + WEATHER_KEYWORDS, + LOCATION_KEYWORDS, +) +from ..utils.config_loader import get_model, get_integration_url, get_config + + +@pytest.fixture +def test_config(): + """Test configuration""" + return Config() + + +@pytest.fixture(autouse=True) +def setup_langchain(): + """Setup LangChain with Bifrost configuration and dummy credentials""" + # Set dummy credentials since Bifrost handles actual authentication + os.environ["OPENAI_API_KEY"] = "dummy-openai-key-bifrost-handles-auth" + os.environ["ANTHROPIC_API_KEY"] = "dummy-anthropic-key-bifrost-handles-auth" + os.environ["GOOGLE_API_KEY"] = "dummy-google-api-key-bifrost-handles-auth" + os.environ["VERTEX_PROJECT"] = "dummy-vertex-project" + os.environ["VERTEX_LOCATION"] = "us-central1" + + # Get Bifrost URL for LangChain + base_url = get_integration_url("langchain") + config = get_config() + integration_settings = config.get_integration_settings("langchain") + + # Store original base URLs and set Bifrost URLs + original_openai_base = os.environ.get("OPENAI_BASE_URL") + original_anthropic_base = os.environ.get("ANTHROPIC_BASE_URL") + + if base_url: + # Configure provider base URLs to route through Bifrost + os.environ["OPENAI_BASE_URL"] = f"{base_url}/v1" + os.environ["ANTHROPIC_BASE_URL"] = f"{base_url}/v1" + + yield + + # Cleanup: restore original URLs + if original_openai_base: + os.environ["OPENAI_BASE_URL"] = original_openai_base + else: + os.environ.pop("OPENAI_BASE_URL", None) + + if original_anthropic_base: + os.environ["ANTHROPIC_BASE_URL"] = original_anthropic_base + else: + os.environ.pop("ANTHROPIC_BASE_URL", None) + + +def create_langchain_tool_from_dict(tool_dict: Dict[str, Any]): + """Convert common tool format to LangChain Tool""" + if not LEGACY_LANGCHAIN_AVAILABLE: + return None + + def tool_func(**kwargs): + return mock_tool_response(tool_dict["name"], kwargs) + + return Tool( + name=tool_dict["name"], + description=tool_dict["description"], + func=tool_func, + ) + + +class TestLangChainChatOpenAI(ChatModelIntegrationTests): + """Standard LangChain tests for ChatOpenAI through Bifrost""" + + @property + def chat_model_class(self) -> Type[ChatOpenAI]: + return ChatOpenAI + + @property + def chat_model_params(self) -> dict: + return { + "model": get_model("langchain", "chat"), + "temperature": 0.7, + "max_tokens": 100, + "base_url": ( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + } + + +class TestLangChainOpenAIEmbeddings(EmbeddingsIntegrationTests): + """Standard LangChain tests for OpenAI Embeddings through Bifrost""" + + @property + def embeddings_class(self) -> Type[OpenAIEmbeddings]: + return OpenAIEmbeddings + + @property + def embeddings_params(self) -> dict: + return { + "model": get_model("langchain", "embeddings"), + "base_url": ( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + } + + +class TestLangChainIntegration: + """Comprehensive LangChain integration tests through Bifrost""" + + def test_01_chat_openai_basic(self, test_config): + """Test Case 1: Basic ChatOpenAI functionality""" + try: + chat = ChatOpenAI( + model=get_model("langchain", "chat"), + temperature=0.7, + max_tokens=100, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + messages = [HumanMessage(content="Hello! How are you today?")] + response = chat.invoke(messages) + + assert isinstance(response, AIMessage) + assert response.content is not None + assert len(response.content) > 0 + + except Exception as e: + pytest.skip(f"ChatOpenAI through LangChain not available: {e}") + + def test_02_chat_anthropic_basic(self, test_config): + """Test Case 2: Basic ChatAnthropic functionality""" + try: + chat = ChatAnthropic( + model="claude-3-haiku-20240307", + temperature=0.7, + max_tokens=100, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + messages = [ + HumanMessage(content="Explain machine learning in one sentence.") + ] + response = chat.invoke(messages) + + assert isinstance(response, AIMessage) + assert response.content is not None + assert any( + word in response.content.lower() + for word in ["machine", "learning", "data", "algorithm"] + ) + + except Exception as e: + pytest.skip(f"ChatAnthropic through LangChain not available: {e}") + + def test_03_openai_embeddings_basic(self, test_config): + """Test Case 3: Basic OpenAI embeddings functionality""" + try: + embeddings = OpenAIEmbeddings( + model=get_model("langchain", "embeddings"), + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + # Test single embedding + result = embeddings.embed_query(EMBEDDINGS_SINGLE_TEXT) + + assert isinstance(result, list) + assert len(result) > 0 + assert all(isinstance(x, float) for x in result) + + # Test batch embeddings + batch_result = embeddings.embed_documents(EMBEDDINGS_MULTIPLE_TEXTS) + + assert isinstance(batch_result, list) + assert len(batch_result) == len(EMBEDDINGS_MULTIPLE_TEXTS) + assert all(isinstance(embedding, list) for embedding in batch_result) + + except Exception as e: + pytest.skip(f"OpenAI embeddings through LangChain not available: {e}") + + @pytest.mark.skipif( + not LEGACY_LANGCHAIN_AVAILABLE, reason="Legacy LangChain package not available" + ) + def test_04_function_calling_tools(self, test_config): + """Test Case 4: Function calling with tools""" + try: + chat = ChatOpenAI( + model=get_model("langchain", "tools"), + temperature=0, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + # Create tools + weather_tool = create_langchain_tool_from_dict(WEATHER_TOOL) + calculator_tool = create_langchain_tool_from_dict(CALCULATOR_TOOL) + tools = [weather_tool, calculator_tool] + + # Bind tools to the model + chat_with_tools = chat.bind_tools(tools) + + # Test tool calling + response = chat_with_tools.invoke( + [HumanMessage(content="What's the weather in Boston?")] + ) + + assert isinstance(response, AIMessage) + # Should either have tool calls or mention the location + has_tool_calls = hasattr(response, "tool_calls") and response.tool_calls + mentions_location = any( + word in response.content.lower() + for word in LOCATION_KEYWORDS + WEATHER_KEYWORDS + ) + + assert ( + has_tool_calls or mentions_location + ), "Should use tools or mention weather/location" + + except Exception as e: + pytest.skip(f"Function calling through LangChain not available: {e}") + + def test_05_llm_chain_basic(self, test_config): + """Test Case 5: Basic LLM Chain functionality""" + try: + llm = ChatOpenAI( + model=get_model("langchain", "chat"), + temperature=0.7, + max_tokens=100, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are a helpful assistant that explains concepts clearly.", + ), + ("human", "Explain {topic} in simple terms."), + ] + ) + + chain = prompt | llm | StrOutputParser() + + result = chain.invoke({"topic": "machine learning"}) + + assert isinstance(result, str) + assert len(result) > 0 + assert any( + word in result.lower() for word in ["machine", "learning", "data"] + ) + + except Exception as e: + pytest.skip(f"LLM Chain through LangChain not available: {e}") + + @pytest.mark.skipif( + not LEGACY_LANGCHAIN_AVAILABLE, reason="Legacy LangChain package not available" + ) + def test_06_conversation_memory(self, test_config): + """Test Case 6: Conversation memory functionality""" + try: + llm = ChatOpenAI( + model=get_model("langchain", "chat"), + temperature=0.7, + max_tokens=150, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + memory = ConversationBufferMemory() + conversation = ConversationChain(llm=llm, memory=memory, verbose=False) + + # First interaction + response1 = conversation.predict( + input="My name is Alice. What's the capital of France?" + ) + assert "Paris" in response1 or "paris" in response1.lower() + + # Second interaction - should remember the name + response2 = conversation.predict(input="What's my name?") + assert "Alice" in response2 or "alice" in response2.lower() + + except Exception as e: + pytest.skip(f"Conversation memory through LangChain not available: {e}") + + def test_07_streaming_responses(self, test_config): + """Test Case 7: Streaming response functionality""" + try: + chat = ChatOpenAI( + model=get_model("langchain", "chat"), + temperature=0.7, + max_tokens=100, + streaming=True, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + messages = [HumanMessage(content="Tell me a short story about a robot.")] + + # Collect streaming chunks + chunks = [] + for chunk in chat.stream(messages): + chunks.append(chunk) + + assert len(chunks) > 0, "Should receive streaming chunks" + + # Combine chunks to get full response + full_content = "".join(chunk.content for chunk in chunks if chunk.content) + assert len(full_content) > 0, "Should have content from streaming" + assert any(word in full_content.lower() for word in ["robot", "story"]) + + except Exception as e: + pytest.skip(f"Streaming through LangChain not available: {e}") + + def test_08_multi_provider_chain(self, test_config): + """Test Case 8: Chain with multiple provider models""" + try: + # Create different provider models + openai_chat = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.5, + max_tokens=50, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + anthropic_chat = ChatAnthropic( + model="claude-3-haiku-20240307", + temperature=0.5, + max_tokens=50, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + # Test both models work + message = [HumanMessage(content="What is AI? Answer in one sentence.")] + + openai_response = openai_chat.invoke(message) + anthropic_response = anthropic_chat.invoke(message) + + assert isinstance(openai_response, AIMessage) + assert isinstance(anthropic_response, AIMessage) + assert ( + openai_response.content != anthropic_response.content + ) # Should be different responses + + except Exception as e: + pytest.skip(f"Multi-provider chains through LangChain not available: {e}") + + def test_09_embeddings_similarity(self, test_config): + """Test Case 9: Embeddings similarity analysis""" + try: + embeddings = OpenAIEmbeddings( + model=get_model("langchain", "embeddings"), + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + # Get embeddings for similar texts + similar_embeddings = embeddings.embed_documents(EMBEDDINGS_SIMILAR_TEXTS) + + # Calculate similarities + similarity_1_2 = calculate_cosine_similarity( + similar_embeddings[0], similar_embeddings[1] + ) + similarity_1_3 = calculate_cosine_similarity( + similar_embeddings[0], similar_embeddings[2] + ) + + # Similar texts should have high similarity + assert ( + similarity_1_2 > 0.7 + ), f"Similar texts should have high similarity, got {similarity_1_2:.4f}" + assert ( + similarity_1_3 > 0.7 + ), f"Similar texts should have high similarity, got {similarity_1_3:.4f}" + + except Exception as e: + pytest.skip(f"Embeddings similarity through LangChain not available: {e}") + + def test_10_async_operations(self, test_config): + """Test Case 10: Async operation support""" + + async def async_test(): + try: + chat = ChatOpenAI( + model=get_model("langchain", "chat"), + temperature=0.7, + max_tokens=100, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + messages = [HumanMessage(content="Hello from async!")] + response = await chat.ainvoke(messages) + + assert isinstance(response, AIMessage) + assert response.content is not None + assert len(response.content) > 0 + + return True + + except Exception as e: + pytest.skip(f"Async operations through LangChain not available: {e}") + return False + + # Run async test + result = asyncio.run(async_test()) + if result is not False: # Skip if not explicitly skipped + assert result is True + + def test_11_error_handling(self, test_config): + """Test Case 11: Error handling and fallbacks""" + try: + # Test with invalid model name + chat = ChatOpenAI( + model="invalid-model-name-should-fail", + temperature=0.7, + max_tokens=100, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + messages = [HumanMessage(content="This should fail gracefully.")] + + with pytest.raises(Exception) as exc_info: + chat.invoke(messages) + + # Should get a meaningful error + error_message = str(exc_info.value).lower() + assert any( + word in error_message + for word in ["model", "error", "invalid", "not found"] + ) + + except Exception as e: + pytest.skip(f"Error handling test through LangChain not available: {e}") + + def test_12_langchain_expression_language(self, test_config): + """Test Case 12: LangChain Expression Language (LCEL)""" + try: + llm = ChatOpenAI( + model=get_model("langchain", "chat"), + temperature=0.7, + max_tokens=100, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + prompt = ChatPromptTemplate.from_template("Tell me a joke about {topic}") + output_parser = StrOutputParser() + + # Create chain using LCEL + chain = prompt | llm | output_parser + + result = chain.invoke({"topic": "programming"}) + + assert isinstance(result, str) + assert len(result) > 0 + assert any( + word in result.lower() for word in ["programming", "code", "joke"] + ) + + except Exception as e: + pytest.skip(f"LCEL through LangChain not available: {e}") + + @pytest.mark.skipif( + not GOOGLE_GENAI_AVAILABLE, + reason="langchain-google-genai package not available", + ) + def test_13_gemini_chat_integration(self, test_config): + """Test Case 13: Google Gemini chat via LangChain""" + try: + # Use ChatGoogleGenerativeAI with Bifrost routing + chat = ChatGoogleGenerativeAI( + model="gemini-1.5-flash", + google_api_key="dummy-google-api-key-bifrost-handles-auth", + temperature=0.7, + max_tokens=100, + ) + + # Patch the base URL to route through Bifrost + base_url = get_integration_url("langchain") + if base_url: + # For Gemini through Bifrost, we need to route to the genai endpoint + with patch.object(chat, "_client") as mock_client: + # Set up mock to route to Bifrost + mock_client.base_url = f"{base_url}/v1beta" + + messages = [HumanMessage(content="Write a haiku about technology.")] + response = chat.invoke(messages) + + assert isinstance(response, AIMessage) + assert response.content is not None + assert len(response.content) > 0 + assert any( + word in response.content.lower() + for word in ["tech", "digital", "future", "machine"] + ) + else: + pytest.skip("Bifrost URL not configured for LangChain integration") + + except Exception as e: + pytest.skip(f"Gemini through LangChain not available: {e}") + + @pytest.mark.skipif( + not MISTRAL_AI_AVAILABLE, reason="langchain-mistralai package not available" + ) + def test_14_mistral_chat_integration(self, test_config): + """Test Case 14: Mistral AI chat via LangChain""" + try: + # Mistral is OpenAI-compatible, so it can route through Bifrost easily + base_url = get_integration_url("langchain") + if base_url: + chat = ChatMistralAI( + model="mistral-7b-instruct", + mistral_api_key="dummy-mistral-api-key-bifrost-handles-auth", + endpoint=f"{base_url}/v1", # Route through Bifrost + temperature=0.7, + max_tokens=100, + ) + + messages = [ + HumanMessage(content="Explain quantum computing in simple terms.") + ] + response = chat.invoke(messages) + + assert isinstance(response, AIMessage) + assert response.content is not None + assert len(response.content) > 0 + assert any( + word in response.content.lower() + for word in ["quantum", "computing", "bit", "science"] + ) + else: + pytest.skip("Bifrost URL not configured for LangChain integration") + + except Exception as e: + pytest.skip(f"Mistral through LangChain not available: {e}") + + @pytest.mark.skipif( + not GOOGLE_GENAI_AVAILABLE, + reason="langchain-google-genai package not available", + ) + def test_15_gemini_streaming(self, test_config): + """Test Case 15: Gemini streaming responses via LangChain""" + try: + chat = ChatGoogleGenerativeAI( + model="gemini-1.5-flash", + google_api_key="dummy-google-api-key-bifrost-handles-auth", + temperature=0.7, + max_tokens=100, + streaming=True, + ) + + base_url = get_integration_url("langchain") + if base_url: + with patch.object(chat, "_client") as mock_client: + mock_client.base_url = f"{base_url}/v1beta" + + messages = [ + HumanMessage(content="Tell me about artificial intelligence.") + ] + + # Collect streaming chunks + chunks = [] + for chunk in chat.stream(messages): + chunks.append(chunk) + + assert len(chunks) > 0, "Should receive streaming chunks" + + # Combine chunks to get full response + full_content = "".join( + chunk.content for chunk in chunks if chunk.content + ) + assert len(full_content) > 0, "Should have content from streaming" + assert any( + word in full_content.lower() + for word in ["artificial", "intelligence", "ai"] + ) + else: + pytest.skip("Bifrost URL not configured for LangChain integration") + + except Exception as e: + pytest.skip(f"Gemini streaming through LangChain not available: {e}") + + @pytest.mark.skipif( + not MISTRAL_AI_AVAILABLE, reason="langchain-mistralai package not available" + ) + def test_16_mistral_streaming(self, test_config): + """Test Case 16: Mistral streaming responses via LangChain""" + try: + base_url = get_integration_url("langchain") + if base_url: + chat = ChatMistralAI( + model="mistral-7b-instruct", + mistral_api_key="dummy-mistral-api-key-bifrost-handles-auth", + endpoint=f"{base_url}/v1", + temperature=0.7, + max_tokens=100, + streaming=True, + ) + + messages = [ + HumanMessage(content="Describe machine learning algorithms.") + ] + + # Collect streaming chunks + chunks = [] + for chunk in chat.stream(messages): + chunks.append(chunk) + + assert len(chunks) > 0, "Should receive streaming chunks" + + # Combine chunks to get full response + full_content = "".join( + chunk.content for chunk in chunks if chunk.content + ) + assert len(full_content) > 0, "Should have content from streaming" + assert any( + word in full_content.lower() + for word in ["machine", "learning", "algorithm"] + ) + else: + pytest.skip("Bifrost URL not configured for LangChain integration") + + except Exception as e: + pytest.skip(f"Mistral streaming through LangChain not available: {e}") + + def test_17_multi_provider_langchain_comparison(self, test_config): + """Test Case 17: Compare responses across multiple LangChain providers""" + providers_tested = [] + responses = {} + + # Test OpenAI + try: + openai_chat = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.5, + max_tokens=50, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + message = [ + HumanMessage( + content="What is the future of AI? Answer in one sentence." + ) + ] + responses["openai"] = openai_chat.invoke(message) + providers_tested.append("OpenAI") + + except Exception: + pass + + # Test Anthropic + try: + anthropic_chat = ChatAnthropic( + model="claude-3-haiku-20240307", + temperature=0.5, + max_tokens=50, + base_url=( + get_integration_url("langchain") + if get_integration_url("langchain") + else None + ), + ) + + responses["anthropic"] = anthropic_chat.invoke(message) + providers_tested.append("Anthropic") + + except Exception: + pass + + # Test Gemini (if available) + if GOOGLE_GENAI_AVAILABLE: + try: + gemini_chat = ChatGoogleGenerativeAI( + model="gemini-1.5-flash", + google_api_key="dummy-google-api-key-bifrost-handles-auth", + temperature=0.5, + max_tokens=50, + ) + + base_url = get_integration_url("langchain") + if base_url: + with patch.object(gemini_chat, "_client") as mock_client: + mock_client.base_url = f"{base_url}/v1beta" + responses["gemini"] = gemini_chat.invoke(message) + providers_tested.append("Gemini") + + except Exception: + pass + + # Test Mistral (if available) + if MISTRAL_AI_AVAILABLE: + try: + base_url = get_integration_url("langchain") + if base_url: + mistral_chat = ChatMistralAI( + model="mistral-7b-instruct", + mistral_api_key="dummy-mistral-api-key-bifrost-handles-auth", + endpoint=f"{base_url}/v1", + temperature=0.5, + max_tokens=50, + ) + + responses["mistral"] = mistral_chat.invoke(message) + providers_tested.append("Mistral") + + except Exception: + pass + + # Verify we tested at least 2 providers + assert ( + len(providers_tested) >= 2 + ), f"Should test at least 2 providers, got: {providers_tested}" + + # Verify all responses are valid + for provider, response in responses.items(): + assert isinstance( + response, AIMessage + ), f"{provider} should return AIMessage" + assert response.content is not None, f"{provider} should have content" + assert ( + len(response.content) > 0 + ), f"{provider} should have non-empty content" + + # Verify responses are different (providers should give unique answers) + response_contents = [resp.content for resp in responses.values()] + unique_responses = set(response_contents) + assert ( + len(unique_responses) > 1 + ), "Different providers should give different responses" + + +# Skip standard tests if langchain-tests is not available +@pytest.mark.skipif( + not LANGCHAIN_TESTS_AVAILABLE, reason="langchain-tests package not available" +) +class TestLangChainStandardChatModel(TestLangChainChatOpenAI): + """Run LangChain's standard chat model tests""" + + pass + + +@pytest.mark.skipif( + not LANGCHAIN_TESTS_AVAILABLE, reason="langchain-tests package not available" +) +class TestLangChainStandardEmbeddings(TestLangChainOpenAIEmbeddings): + """Run LangChain's standard embeddings tests""" + + pass diff --git a/tests/integrations/tests/integrations/test_litellm.py b/tests/integrations/tests/integrations/test_litellm.py new file mode 100644 index 000000000..a0cdfd9f3 --- /dev/null +++ b/tests/integrations/tests/integrations/test_litellm.py @@ -0,0 +1,705 @@ +""" +LiteLLM Integration Tests + +πŸ€– MODELS USED: +- Chat: gpt-3.5-turbo (OpenAI via LiteLLM) +- Vision: gpt-4o (OpenAI via LiteLLM) +- Tools: gpt-3.5-turbo (OpenAI via LiteLLM) +- Speech: tts-1 (OpenAI via LiteLLM) +- Transcription: whisper-1 (OpenAI via LiteLLM) +- Embeddings: text-embedding-3-small (OpenAI via LiteLLM) +- Alternatives: claude-3-haiku-20240307, gemini-pro, mistral-7b-instruct, gpt-4, command-r-plus + +Tests all 19 core scenarios using LiteLLM SDK directly: +1. Simple chat +2. Multi turn conversation +3. Tool calls +4. Multiple tool calls +5. End2End tool calling +6. Automatic function calling +7. Image (url) +8. Image (base64) +9. Multiple images +10. Complete end2end test with conversation history, tool calls, tool results and images +11. Integration specific tests +12. Error handling +13. Streaming +14. Google Gemini integration +15. Mistral integration +16. OpenAI embeddings via LiteLLM +17. OpenAI speech synthesis via LiteLLM +18. OpenAI transcription via LiteLLM +19. Multi-provider comparison +""" + +import pytest +import json +import litellm +from typing import List, Dict, Any + +from ..utils.common import ( + Config, + SIMPLE_CHAT_MESSAGES, + MULTI_TURN_MESSAGES, + SINGLE_TOOL_CALL_MESSAGES, + MULTIPLE_TOOL_CALL_MESSAGES, + IMAGE_URL_MESSAGES, + IMAGE_BASE64_MESSAGES, + MULTIPLE_IMAGES_MESSAGES, + COMPLEX_E2E_MESSAGES, + INVALID_ROLE_MESSAGES, + STREAMING_CHAT_MESSAGES, + STREAMING_TOOL_CALL_MESSAGES, + WEATHER_TOOL, + CALCULATOR_TOOL, + mock_tool_response, + assert_valid_chat_response, + assert_has_tool_calls, + assert_valid_image_response, + assert_valid_error_response, + assert_error_propagation, + assert_valid_streaming_response, + collect_streaming_content, + extract_tool_calls, + get_api_key, + skip_if_no_api_key, + COMPARISON_KEYWORDS, + WEATHER_KEYWORDS, + LOCATION_KEYWORDS, + # Audio and embeddings test data + EMBEDDINGS_SINGLE_TEXT, + EMBEDDINGS_MULTIPLE_TEXTS, + EMBEDDINGS_SIMILAR_TEXTS, + SPEECH_TEST_INPUT, + generate_test_audio, + assert_valid_speech_response, + assert_valid_transcription_response, + assert_valid_embedding_response, + assert_valid_embeddings_batch_response, + calculate_cosine_similarity, + collect_streaming_transcription_content, +) +from ..utils.config_loader import get_model + + +@pytest.fixture +def test_config(): + """Test configuration""" + return Config() + + +@pytest.fixture(autouse=True) +def setup_litellm(): + """Setup LiteLLM with Bifrost configuration and dummy credentials""" + import os + from ..utils.config_loader import get_integration_url, get_config + + # Set dummy credentials since Bifrost handles actual authentication + os.environ["OPENAI_API_KEY"] = "dummy-openai-key-bifrost-handles-auth" + os.environ["ANTHROPIC_API_KEY"] = "dummy-anthropic-key-bifrost-handles-auth" + os.environ["MISTRAL_API_KEY"] = "dummy-mistral-key-bifrost-handles-auth" + + # For Google, set all possible API key environment variables + os.environ["GOOGLE_API_KEY"] = "dummy-google-api-key-bifrost-handles-auth" + os.environ["GEMINI_API_KEY"] = "dummy-gemini-api-key-bifrost-handles-auth" + os.environ["VERTEX_PROJECT"] = "dummy-vertex-project" + os.environ["VERTEX_LOCATION"] = "us-central1" + + # Get Bifrost URL for LiteLLM + base_url = get_integration_url("litellm") + config = get_config() + integration_settings = config.get_integration_settings("litellm") + api_config = config.get_api_config() + + # Configure LiteLLM globally + if base_url: + litellm.api_base = base_url + + # Set timeout and other settings + litellm.request_timeout = api_config.get("timeout", 30) + + # Apply integration-specific settings + if integration_settings.get("drop_params"): + litellm.drop_params = integration_settings["drop_params"] + if integration_settings.get("debug"): + litellm.set_verbose = integration_settings["debug"] + + +def convert_to_litellm_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert common tool format to LiteLLM format (OpenAI-compatible)""" + return [{"type": "function", "function": tool} for tool in tools] + + +class TestLiteLLMIntegration: + """Test suite for LiteLLM integration covering all 11 core scenarios""" + + def test_01_simple_chat(self, test_config): + """Test Case 1: Simple chat interaction""" + response = litellm.completion( + model=get_model("litellm", "chat"), + messages=SIMPLE_CHAT_MESSAGES, + max_tokens=100, + ) + + assert_valid_chat_response(response) + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + def test_02_multi_turn_conversation(self, test_config): + """Test Case 2: Multi-turn conversation""" + response = litellm.completion( + model=get_model("litellm", "chat"), + messages=MULTI_TURN_MESSAGES, + max_tokens=150, + ) + + assert_valid_chat_response(response) + content = response.choices[0].message.content.lower() + # Should mention population or numbers since we asked about Paris population + assert any( + word in content + for word in ["population", "million", "people", "inhabitants"] + ) + + def test_03_single_tool_call(self, test_config): + """Test Case 3: Single tool call""" + tools = convert_to_litellm_tools([WEATHER_TOOL]) + + response = litellm.completion( + model=get_model("litellm", "chat"), + messages=SINGLE_TOOL_CALL_MESSAGES, + tools=tools, + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_tool_calls(response) + assert tool_calls[0]["name"] == "get_weather" + assert "location" in tool_calls[0]["arguments"] + + def test_04_multiple_tool_calls(self, test_config): + """Test Case 4: Multiple tool calls in one response""" + tools = convert_to_litellm_tools([WEATHER_TOOL, CALCULATOR_TOOL]) + + response = litellm.completion( + model=get_model("litellm", "chat"), + messages=MULTIPLE_TOOL_CALL_MESSAGES, + tools=tools, + max_tokens=200, + ) + + assert_has_tool_calls(response, expected_count=2) + tool_calls = extract_tool_calls(response) + tool_names = [tc["name"] for tc in tool_calls] + assert "get_weather" in tool_names + assert "calculate" in tool_names + + def test_05_end2end_tool_calling(self, test_config): + """Test Case 5: Complete tool calling flow with responses""" + messages = [{"role": "user", "content": "What's the weather in Boston?"}] + tools = convert_to_litellm_tools([WEATHER_TOOL]) + + response = litellm.completion( + model=get_model("litellm", "chat"), + messages=messages, + tools=tools, + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + + # Add assistant's tool call to conversation + messages.append(response.choices[0].message) + + # Add tool response + tool_calls = extract_litellm_tool_calls(response) + tool_response = mock_tool_response( + tool_calls[0]["name"], tool_calls[0]["arguments"] + ) + + messages.append( + { + "role": "tool", + "tool_call_id": response.choices[0].message.tool_calls[0].id, + "content": tool_response, + } + ) + + # Get final response + final_response = litellm.completion( + model=get_model("litellm", "chat"), messages=messages, max_tokens=150 + ) + + assert_valid_chat_response(final_response) + content = final_response.choices[0].message.content.lower() + weather_location_keywords = WEATHER_KEYWORDS + LOCATION_KEYWORDS + assert any(word in content for word in weather_location_keywords) + + def test_06_automatic_function_calling(self, test_config): + """Test Case 6: Automatic function calling""" + tools = convert_to_litellm_tools([CALCULATOR_TOOL]) + + response = litellm.completion( + model=get_model("litellm", "chat"), + messages=[{"role": "user", "content": "Calculate 25 * 4 for me"}], + tools=tools, + tool_choice="auto", + max_tokens=100, + ) + + # Should automatically choose to use the calculator + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_litellm_tool_calls(response) + assert tool_calls[0]["name"] == "calculate" + + def test_07_image_url(self, test_config): + """Test Case 7: Image analysis from URL""" + response = litellm.completion( + model=get_model("litellm", "vision"), + messages=IMAGE_URL_MESSAGES, + max_tokens=200, + ) + + assert_valid_image_response(response) + + def test_08_image_base64(self, test_config): + """Test Case 8: Image analysis from base64""" + response = litellm.completion( + model=get_model("litellm", "vision"), + messages=IMAGE_BASE64_MESSAGES, + max_tokens=200, + ) + + assert_valid_image_response(response) + + def test_09_multiple_images(self, test_config): + """Test Case 9: Multiple image analysis""" + response = litellm.completion( + model=get_model("litellm", "vision"), + messages=MULTIPLE_IMAGES_MESSAGES, + max_tokens=300, + ) + + assert_valid_image_response(response) + content = response.choices[0].message.content.lower() + # Should mention comparison or differences + assert any( + word in content for word in COMPARISON_KEYWORDS + ), f"Response should contain comparison keywords. Got content: {content}" + + def test_10_complex_end2end(self, test_config): + """Test Case 10: Complex end-to-end with conversation, images, and tools""" + messages = COMPLEX_E2E_MESSAGES.copy() + tools = convert_to_litellm_tools([WEATHER_TOOL]) + + # First, analyze the image + response1 = litellm.completion( + model=get_model("litellm", "vision"), + messages=messages, + tools=tools, + max_tokens=300, + ) + + # Should either describe image or call weather tool (or both) + assert ( + response1.choices[0].message.content is not None + or response1.choices[0].message.tool_calls is not None + ) + + # Add response to conversation + messages.append(response1.choices[0].message) + + # If there were tool calls, handle them + if response1.choices[0].message.tool_calls: + for tool_call in response1.choices[0].message.tool_calls: + tool_name = tool_call.function.name + tool_args = json.loads(tool_call.function.arguments) + tool_response = mock_tool_response(tool_name, tool_args) + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": tool_response, + } + ) + + # Get final response after tool calls + final_response = litellm.completion( + model=get_model("litellm", "vision"), messages=messages, max_tokens=200 + ) + + assert_valid_chat_response(final_response) + + def test_11_integration_specific_features(self, test_config): + """Test Case 11: LiteLLM-specific features""" + + # Test 1: Multiple integrations through LiteLLM + integrations_to_test = [ + "gpt-3.5-turbo", # OpenAI + "claude-3-haiku-20240307", # Anthropic + "gemini-2.0-flash-001", # Google Gemini + "mistral-7b-instruct", # Mistral + ] + + for model in integrations_to_test: + try: + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": "Hello, how are you?"}], + max_tokens=50, + ) + + assert_valid_chat_response(response) + + except Exception as e: + # Some integrations might not be available, skip gracefully + pytest.skip(f"Integration {model} not available: {e}") + + # Test 2: Function calling with specific tool choice + tools = convert_to_litellm_tools([CALCULATOR_TOOL, WEATHER_TOOL]) + + response2 = litellm.completion( + model=get_model("litellm", "chat"), + messages=[{"role": "user", "content": "What's 15 + 27?"}], + tools=tools, + tool_choice={"type": "function", "function": {"name": "calculate"}}, + max_tokens=100, + ) + + assert_has_tool_calls(response2, expected_count=1) + tool_calls = extract_litellm_tool_calls(response2) + assert tool_calls[0]["name"] == "calculate" + + # Test 3: Temperature and other parameters + response3 = litellm.completion( + model=get_model("litellm", "chat"), + messages=[ + {"role": "user", "content": "Tell me a creative story in one sentence."} + ], + temperature=0.9, + top_p=0.9, + max_tokens=100, + ) + + assert_valid_chat_response(response3) + + def test_12_error_handling_invalid_roles(self, test_config): + """Test Case 12: Error handling for invalid roles""" + with pytest.raises(Exception) as exc_info: + litellm.completion( + model=get_model("litellm", "chat"), + messages=INVALID_ROLE_MESSAGES, + max_tokens=100, + ) + + # Verify the error is properly caught and contains role-related information + error = exc_info.value + assert_valid_error_response(error, "tester") + assert_error_propagation(error, "litellm") + + def test_13_streaming(self, test_config): + """Test Case 13: Streaming chat completion""" + # Test basic streaming + stream = litellm.completion( + model=get_model("litellm", "chat"), + messages=STREAMING_CHAT_MESSAGES, + max_tokens=200, + stream=True, + ) + + content, chunk_count, tool_calls_detected = collect_streaming_content( + stream, "openai", timeout=30 # LiteLLM uses OpenAI format + ) + + # Validate streaming results + assert chunk_count > 0, "Should receive at least one chunk" + assert len(content) > 10, "Should receive substantial content" + assert not tool_calls_detected, "Basic streaming shouldn't have tool calls" + + # Test streaming with tool calls + stream_with_tools = litellm.completion( + model=get_model("litellm", "tools"), + messages=STREAMING_TOOL_CALL_MESSAGES, + max_tokens=150, + tools=convert_to_litellm_tools([WEATHER_TOOL]), + stream=True, + ) + + content_tools, chunk_count_tools, tool_calls_detected_tools = ( + collect_streaming_content( + stream_with_tools, "openai", timeout=30 # LiteLLM uses OpenAI format + ) + ) + + # Validate tool streaming results + assert chunk_count_tools > 0, "Should receive at least one chunk with tools" + assert ( + tool_calls_detected_tools + ), "Should detect tool calls in streaming response" + + def test_14_gemini_integration(self, test_config): + """Test Case 14: Google Gemini integration through LiteLLM""" + try: + # Test basic chat with Gemini + response = litellm.completion( + model="vertex_ai/gemini-2.0-flash-001", + messages=[ + { + "role": "user", + "content": "What is machine learning? Answer in one sentence.", + } + ], + max_tokens=100, + ) + + assert_valid_chat_response(response) + content = response.choices[0].message.content.lower() + assert any( + word in content for word in ["machine", "learning", "data", "algorithm"] + ), f"Response should mention ML concepts. Got: {content}" + + # Test with tool calling if supported + tools = convert_to_litellm_tools([CALCULATOR_TOOL]) + response_tools = litellm.completion( + model="vertex_ai/gemini-2.0-flash-001", + messages=[{"role": "user", "content": "Calculate 42 * 17"}], + tools=tools, + max_tokens=100, + ) + + # Gemini should either use tools or provide calculation + if response_tools.choices[0].message.tool_calls: + assert_has_tool_calls(response_tools, expected_count=1) + else: + # Should at least provide the calculation result + content = response_tools.choices[0].message.content + assert ( + "714" in content or "42" in content + ), "Should provide calculation result" + + except Exception as e: + pytest.skip(f"Gemini integration not available: {e}") + + def test_15_mistral_integration(self, test_config): + """Test Case 15: Mistral integration through LiteLLM""" + try: + # Test basic chat with Mistral + response = litellm.completion( + model="mistral/mistral-7b-instruct", + messages=[ + { + "role": "user", + "content": "Explain recursion in programming briefly.", + } + ], + max_tokens=150, + ) + + assert_valid_chat_response(response) + content = response.choices[0].message.content.lower() + assert any( + word in content for word in ["recursion", "function", "itself", "call"] + ), f"Response should explain recursion. Got: {content}" + + # Test with different temperature + response_creative = litellm.completion( + model="mistral/mistral-7b-instruct", + messages=[{"role": "user", "content": "Write a haiku about code."}], + temperature=0.8, + max_tokens=100, + ) + + assert_valid_chat_response(response_creative) + + except Exception as e: + pytest.skip(f"Mistral integration not available: {e}") + + def test_16_openai_embeddings_via_litellm(self, test_config): + """Test Case 16: OpenAI embeddings through LiteLLM""" + try: + # Test single text embedding + response = litellm.embedding( + model=get_model("litellm", "embeddings") or "text-embedding-3-small", + input=EMBEDDINGS_SINGLE_TEXT, + ) + + assert_valid_embedding_response(response, expected_dimensions=1536) + + # Test batch embeddings + batch_response = litellm.embedding( + model=get_model("litellm", "embeddings") or "text-embedding-3-small", + input=EMBEDDINGS_MULTIPLE_TEXTS, + ) + + assert_valid_embeddings_batch_response( + batch_response, len(EMBEDDINGS_MULTIPLE_TEXTS), expected_dimensions=1536 + ) + + # Test similarity analysis + similar_response = litellm.embedding( + model=get_model("litellm", "embeddings") or "text-embedding-3-small", + input=EMBEDDINGS_SIMILAR_TEXTS, + ) + + embeddings = [ + item["embedding"] if isinstance(item, dict) else item.embedding + for item in ( + similar_response["data"] + if isinstance(similar_response, dict) + else similar_response.data + ) + ] + + # Calculate similarity between similar texts + similarity = calculate_cosine_similarity(embeddings[0], embeddings[1]) + assert ( + similarity > 0.7 + ), f"Similar texts should have high similarity, got {similarity:.4f}" + + except Exception as e: + pytest.skip(f"OpenAI embeddings through LiteLLM not available: {e}") + + def test_17_openai_speech_via_litellm(self, test_config): + """Test Case 17: OpenAI speech synthesis through LiteLLM""" + try: + # Test basic speech synthesis + response = litellm.speech( + model=get_model("litellm", "speech") or "tts-1", + voice="alloy", + input=SPEECH_TEST_INPUT, + ) + + # LiteLLM might return different response format + if hasattr(response, "content"): + audio_content = response.content + elif isinstance(response, bytes): + audio_content = response + else: + audio_content = response + + assert_valid_speech_response(audio_content) + + # Test with different voice + response2 = litellm.speech( + model=get_model("litellm", "speech") or "tts-1", + voice="nova", + input="Short test message for voice comparison.", + response_format="mp3", + ) + + if hasattr(response2, "content"): + audio_content2 = response2.content + elif isinstance(response2, bytes): + audio_content2 = response2 + else: + audio_content2 = response2 + + assert_valid_speech_response(audio_content2, expected_audio_size_min=500) + + # Different voices should produce different audio + assert ( + audio_content != audio_content2 + ), "Different voices should produce different audio" + + except Exception as e: + pytest.skip(f"OpenAI speech through LiteLLM not available: {e}") + + def test_18_openai_transcription_via_litellm(self, test_config): + """Test Case 18: OpenAI transcription through LiteLLM""" + try: + # Generate test audio for transcription + test_audio = generate_test_audio() + + # Test basic transcription + response = litellm.transcription( + model=get_model("litellm", "transcription") or "whisper-1", + file=("test_audio.wav", test_audio, "audio/wav"), + ) + + assert_valid_transcription_response(response) + + # Test with additional parameters + response2 = litellm.transcription( + model=get_model("litellm", "transcription") or "whisper-1", + file=("test_audio.wav", test_audio, "audio/wav"), + language="en", + temperature=0.0, + ) + + assert_valid_transcription_response(response2) + + except Exception as e: + pytest.skip(f"OpenAI transcription through LiteLLM not available: {e}") + + def test_19_multi_provider_comparison(self, test_config): + """Test Case 19: Compare responses across different providers through LiteLLM""" + test_prompt = "What is the capital of Japan? Answer in one word." + models_to_test = [ + "gpt-3.5-turbo", # OpenAI + "claude-3-haiku-20240307", # Anthropic + "vertex_ai/gemini-2.0-flash-001", # Google + ] + + responses = {} + + for model in models_to_test: + try: + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": test_prompt}], + max_tokens=50, + ) + + assert_valid_chat_response(response) + responses[model] = response.choices[0].message.content.lower() + + except Exception as e: + print(f"Model {model} not available: {e}") + continue + + # Verify that we got at least one response + assert len(responses) > 0, "Should get at least one successful response" + + # All responses should mention Tokyo or Japan + for model, content in responses.items(): + assert any( + word in content for word in ["tokyo", "japan"] + ), f"Model {model} should mention Tokyo. Got: {content}" + + +# Additional helper functions specific to LiteLLM +def extract_litellm_tool_calls(response: Any) -> List[Dict[str, Any]]: + """Extract tool calls from LiteLLM response format (OpenAI-compatible) with proper type checking""" + tool_calls = [] + + # Type check for LiteLLM response (OpenAI-compatible format) + if not hasattr(response, "choices") or not response.choices: + return tool_calls + + choice = response.choices[0] + if not hasattr(choice, "message") or not hasattr(choice.message, "tool_calls"): + return tool_calls + + if not choice.message.tool_calls: + return tool_calls + + for tool_call in choice.message.tool_calls: + if hasattr(tool_call, "function") and hasattr(tool_call.function, "name"): + try: + arguments = ( + json.loads(tool_call.function.arguments) + if isinstance(tool_call.function.arguments, str) + else tool_call.function.arguments + ) + tool_calls.append( + { + "name": tool_call.function.name, + "arguments": arguments, + } + ) + except (json.JSONDecodeError, AttributeError) as e: + print(f"Warning: Failed to parse LiteLLM tool call arguments: {e}") + continue + + return tool_calls diff --git a/tests/integrations/tests/integrations/test_openai.py b/tests/integrations/tests/integrations/test_openai.py new file mode 100644 index 000000000..4a9a61ea0 --- /dev/null +++ b/tests/integrations/tests/integrations/test_openai.py @@ -0,0 +1,1056 @@ +""" +OpenAI Integration Tests + +πŸ€– MODELS USED: +- Chat: gpt-3.5-turbo +- Vision: gpt-4o +- Tools: gpt-3.5-turbo +- Speech: tts-1 +- Transcription: whisper-1 +- Embeddings: text-embedding-3-small +- Alternatives: gpt-4, gpt-4-turbo-preview, gpt-4o, gpt-4o-mini + +Tests all core scenarios using OpenAI SDK directly: +1. Simple chat +2. Multi turn conversation +3. Tool calls +4. Multiple tool calls +5. End2End tool calling +6. Automatic function calling +7. Image (url) +8. Image (base64) +9. Multiple images +10. Complete end2end test with conversation history, tool calls, tool results and images +11. Integration specific tests +12. Error handling +13. Streaming chat +14. Speech synthesis +15. Audio transcription +16. Transcription streaming +17. Speech-transcription round trip +18. Speech error handling +19. Transcription error handling +20. Different voices and audio formats +21. Single text embedding +22. Batch text embeddings +23. Embedding similarity analysis +24. Embedding dissimilarity analysis +25. Different embedding models +26. Long text embedding +27. Embedding error handling +28. Embedding dimensionality reduction +29. Embedding encoding formats +30. Embedding usage tracking +""" + +import pytest +import json +from openai import OpenAI +from typing import List, Dict, Any + +from ..utils.common import ( + Config, + SIMPLE_CHAT_MESSAGES, + MULTI_TURN_MESSAGES, + SINGLE_TOOL_CALL_MESSAGES, + MULTIPLE_TOOL_CALL_MESSAGES, + IMAGE_URL_MESSAGES, + IMAGE_BASE64_MESSAGES, + MULTIPLE_IMAGES_MESSAGES, + COMPLEX_E2E_MESSAGES, + INVALID_ROLE_MESSAGES, + STREAMING_CHAT_MESSAGES, + STREAMING_TOOL_CALL_MESSAGES, + WEATHER_TOOL, + CALCULATOR_TOOL, + mock_tool_response, + assert_valid_chat_response, + assert_has_tool_calls, + assert_valid_image_response, + assert_valid_error_response, + assert_error_propagation, + assert_valid_streaming_response, + collect_streaming_content, + extract_tool_calls, + get_api_key, + skip_if_no_api_key, + COMPARISON_KEYWORDS, + WEATHER_KEYWORDS, + LOCATION_KEYWORDS, + # Speech and Transcription utilities + SPEECH_TEST_INPUT, + SPEECH_TEST_VOICES, + TRANSCRIPTION_TEST_INPUTS, + generate_test_audio, + TEST_AUDIO_DATA, + assert_valid_speech_response, + assert_valid_transcription_response, + assert_valid_streaming_speech_response, + assert_valid_streaming_transcription_response, + collect_streaming_speech_content, + collect_streaming_transcription_content, + # Embeddings utilities + EMBEDDINGS_SINGLE_TEXT, + EMBEDDINGS_MULTIPLE_TEXTS, + EMBEDDINGS_SIMILAR_TEXTS, + EMBEDDINGS_DIFFERENT_TEXTS, + EMBEDDINGS_EMPTY_TEXTS, + EMBEDDINGS_LONG_TEXT, + assert_valid_embedding_response, + assert_valid_embeddings_batch_response, + calculate_cosine_similarity, + assert_embeddings_similarity, + assert_embeddings_dissimilarity, +) +from ..utils.config_loader import get_model + + +# Helper functions (defined early for use in test methods) +def extract_openai_tool_calls(response: Any) -> List[Dict[str, Any]]: + """Extract tool calls from OpenAI response format with proper type checking""" + tool_calls = [] + + # Type check for OpenAI ChatCompletion response + if not hasattr(response, "choices") or not response.choices: + return tool_calls + + choice = response.choices[0] + if not hasattr(choice, "message") or not hasattr(choice.message, "tool_calls"): + return tool_calls + + if not choice.message.tool_calls: + return tool_calls + + for tool_call in choice.message.tool_calls: + if hasattr(tool_call, "function") and hasattr(tool_call.function, "name"): + try: + arguments = ( + json.loads(tool_call.function.arguments) + if isinstance(tool_call.function.arguments, str) + else tool_call.function.arguments + ) + tool_calls.append( + { + "name": tool_call.function.name, + "arguments": arguments, + } + ) + except (json.JSONDecodeError, AttributeError) as e: + print(f"Warning: Failed to parse tool call arguments: {e}") + continue + + return tool_calls + + +def convert_to_openai_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert common tool format to OpenAI format""" + return [{"type": "function", "function": tool} for tool in tools] + + +@pytest.fixture +def openai_client(): + """Create OpenAI client for testing""" + from ..utils.config_loader import get_integration_url, get_config + + api_key = get_api_key("openai") + base_url = get_integration_url("openai") + + # Get additional integration settings + config = get_config() + integration_settings = config.get_integration_settings("openai") + api_config = config.get_api_config() + + client_kwargs = { + "api_key": api_key, + "base_url": base_url, + "timeout": api_config.get("timeout", 30), + "max_retries": api_config.get("max_retries", 3), + } + + # Add optional OpenAI-specific settings + if integration_settings.get("organization"): + client_kwargs["organization"] = integration_settings["organization"] + if integration_settings.get("project"): + client_kwargs["project"] = integration_settings["project"] + + return OpenAI(**client_kwargs) + + +@pytest.fixture +def test_config(): + """Test configuration""" + return Config() + + +class TestOpenAIIntegration: + """Test suite for OpenAI integration covering all 11 core scenarios""" + + @skip_if_no_api_key("openai") + def test_01_simple_chat(self, openai_client, test_config): + """Test Case 1: Simple chat interaction""" + response = openai_client.chat.completions.create( + model=get_model("openai", "chat"), + messages=SIMPLE_CHAT_MESSAGES, + max_tokens=100, + ) + + assert_valid_chat_response(response) + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + @skip_if_no_api_key("openai") + def test_02_multi_turn_conversation(self, openai_client, test_config): + """Test Case 2: Multi-turn conversation""" + response = openai_client.chat.completions.create( + model=get_model("openai", "chat"), + messages=MULTI_TURN_MESSAGES, + max_tokens=150, + ) + + assert_valid_chat_response(response) + content = response.choices[0].message.content.lower() + # Should mention population or numbers since we asked about Paris population + assert any( + word in content + for word in ["population", "million", "people", "inhabitants"] + ) + + @skip_if_no_api_key("openai") + def test_03_single_tool_call(self, openai_client, test_config): + """Test Case 3: Single tool call""" + response = openai_client.chat.completions.create( + model=get_model("openai", "tools"), + messages=SINGLE_TOOL_CALL_MESSAGES, + tools=[{"type": "function", "function": WEATHER_TOOL}], + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_tool_calls(response) + assert tool_calls[0]["name"] == "get_weather" + assert "location" in tool_calls[0]["arguments"] + + @skip_if_no_api_key("openai") + def test_04_multiple_tool_calls(self, openai_client, test_config): + """Test Case 4: Multiple tool calls in one response""" + response = openai_client.chat.completions.create( + model=get_model("openai", "tools"), + messages=MULTIPLE_TOOL_CALL_MESSAGES, + tools=[ + {"type": "function", "function": WEATHER_TOOL}, + {"type": "function", "function": CALCULATOR_TOOL}, + ], + max_tokens=200, + ) + + assert_has_tool_calls(response, expected_count=2) + tool_calls = extract_openai_tool_calls(response) + tool_names = [tc["name"] for tc in tool_calls] + assert "get_weather" in tool_names + assert "calculate" in tool_names + + @skip_if_no_api_key("openai") + def test_05_end2end_tool_calling(self, openai_client, test_config): + """Test Case 5: Complete tool calling flow with responses""" + # Initial request + messages = [{"role": "user", "content": "What's the weather in Boston?"}] + + response = openai_client.chat.completions.create( + model=get_model("openai", "tools"), + messages=messages, + tools=[{"type": "function", "function": WEATHER_TOOL}], + max_tokens=100, + ) + + assert_has_tool_calls(response, expected_count=1) + + # Add assistant's tool call to conversation + messages.append(response.choices[0].message) + + # Add tool response + tool_calls = extract_openai_tool_calls(response) + tool_response = mock_tool_response( + tool_calls[0]["name"], tool_calls[0]["arguments"] + ) + + messages.append( + { + "role": "tool", + "tool_call_id": response.choices[0].message.tool_calls[0].id, + "content": tool_response, + } + ) + + # Get final response + final_response = openai_client.chat.completions.create( + model=get_model("openai", "tools"), messages=messages, max_tokens=150 + ) + + assert_valid_chat_response(final_response) + content = final_response.choices[0].message.content.lower() + weather_location_keywords = WEATHER_KEYWORDS + LOCATION_KEYWORDS + assert any(word in content for word in weather_location_keywords) + + @skip_if_no_api_key("openai") + def test_06_automatic_function_calling(self, openai_client, test_config): + """Test Case 6: Automatic function calling (tool_choice='auto')""" + response = openai_client.chat.completions.create( + model=get_model("openai", "tools"), + messages=[{"role": "user", "content": "Calculate 25 * 4 for me"}], + tools=[{"type": "function", "function": CALCULATOR_TOOL}], + tool_choice="auto", # Let model decide + max_tokens=100, + ) + + # Should automatically choose to use the calculator + assert_has_tool_calls(response, expected_count=1) + tool_calls = extract_openai_tool_calls(response) + assert tool_calls[0]["name"] == "calculate" + + @skip_if_no_api_key("openai") + def test_07_image_url(self, openai_client, test_config): + """Test Case 7: Image analysis from URL""" + response = openai_client.chat.completions.create( + model=get_model("openai", "vision"), + messages=IMAGE_URL_MESSAGES, + max_tokens=200, + ) + + assert_valid_image_response(response) + + @skip_if_no_api_key("openai") + def test_08_image_base64(self, openai_client, test_config): + """Test Case 8: Image analysis from base64""" + response = openai_client.chat.completions.create( + model=get_model("openai", "vision"), + messages=IMAGE_BASE64_MESSAGES, + max_tokens=200, + ) + + assert_valid_image_response(response) + + @skip_if_no_api_key("openai") + def test_09_multiple_images(self, openai_client, test_config): + """Test Case 9: Multiple image analysis""" + response = openai_client.chat.completions.create( + model=get_model("openai", "vision"), + messages=MULTIPLE_IMAGES_MESSAGES, + max_tokens=300, + ) + + assert_valid_image_response(response) + content = response.choices[0].message.content.lower() + # Should mention comparison or differences (flexible matching) + assert any( + word in content for word in COMPARISON_KEYWORDS + ), f"Response should contain comparison keywords. Got content: {content}" + + @skip_if_no_api_key("openai") + def test_10_complex_end2end(self, openai_client, test_config): + """Test Case 10: Complex end-to-end with conversation, images, and tools""" + messages = COMPLEX_E2E_MESSAGES.copy() + + # First, analyze the image + response1 = openai_client.chat.completions.create( + model=get_model("openai", "vision"), + messages=messages, + tools=[{"type": "function", "function": WEATHER_TOOL}], + max_tokens=300, + ) + + # Should either describe image or call weather tool (or both) + assert ( + response1.choices[0].message.content is not None + or response1.choices[0].message.tool_calls is not None + ) + + # Add response to conversation + messages.append(response1.choices[0].message) + + # If there were tool calls, handle them + if response1.choices[0].message.tool_calls: + for tool_call in response1.choices[0].message.tool_calls: + tool_name = tool_call.function.name + tool_args = json.loads(tool_call.function.arguments) + tool_response = mock_tool_response(tool_name, tool_args) + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": tool_response, + } + ) + + # Get final response after tool calls + final_response = openai_client.chat.completions.create( + model=get_model("openai", "vision"), messages=messages, max_tokens=200 + ) + + assert_valid_chat_response(final_response) + + @skip_if_no_api_key("openai") + def test_11_integration_specific_features(self, openai_client, test_config): + """Test Case 11: OpenAI-specific features""" + + # Test 1: Function calling with specific tool choice + response1 = openai_client.chat.completions.create( + model=get_model("openai", "tools"), + messages=[{"role": "user", "content": "What's 15 + 27?"}], + tools=[ + {"type": "function", "function": CALCULATOR_TOOL}, + {"type": "function", "function": WEATHER_TOOL}, + ], + tool_choice={ + "type": "function", + "function": {"name": "calculate"}, + }, # Force specific tool + max_tokens=100, + ) + + assert_has_tool_calls(response1, expected_count=1) + tool_calls = extract_openai_tool_calls(response1) + assert tool_calls[0]["name"] == "calculate" + + # Test 2: System message + response2 = openai_client.chat.completions.create( + model=get_model("openai", "chat"), + messages=[ + { + "role": "system", + "content": "You are a helpful assistant that always responds in exactly 5 words.", + }, + {"role": "user", "content": "Hello, how are you?"}, + ], + max_tokens=50, + ) + + assert_valid_chat_response(response2) + # Check if response is approximately 5 words (allow some flexibility) + word_count = len(response2.choices[0].message.content.split()) + assert 3 <= word_count <= 7, f"Expected ~5 words, got {word_count}" + + # Test 3: Temperature and top_p parameters + response3 = openai_client.chat.completions.create( + model=get_model("openai", "chat"), + messages=[ + {"role": "user", "content": "Tell me a creative story in one sentence."} + ], + temperature=0.9, + top_p=0.9, + max_tokens=100, + ) + + assert_valid_chat_response(response3) + + @skip_if_no_api_key("openai") + def test_12_error_handling_invalid_roles(self, openai_client, test_config): + """Test Case 12: Error handling for invalid roles""" + with pytest.raises(Exception) as exc_info: + openai_client.chat.completions.create( + model=get_model("openai", "chat"), + messages=INVALID_ROLE_MESSAGES, + max_tokens=100, + ) + + # Verify the error is properly caught and contains role-related information + error = exc_info.value + assert_valid_error_response(error, "tester") + assert_error_propagation(error, "openai") + + @skip_if_no_api_key("openai") + def test_13_streaming(self, openai_client, test_config): + """Test Case 13: Streaming chat completion""" + # Test basic streaming + stream = openai_client.chat.completions.create( + model=get_model("openai", "chat"), + messages=STREAMING_CHAT_MESSAGES, + max_tokens=200, + stream=True, + ) + + content, chunk_count, tool_calls_detected = collect_streaming_content( + stream, "openai", timeout=30 + ) + + # Validate streaming results + assert chunk_count > 0, "Should receive at least one chunk" + assert len(content) > 10, "Should receive substantial content" + assert not tool_calls_detected, "Basic streaming shouldn't have tool calls" + + # Test streaming with tool calls + stream_with_tools = openai_client.chat.completions.create( + model=get_model("openai", "tools"), + messages=STREAMING_TOOL_CALL_MESSAGES, + max_tokens=150, + tools=convert_to_openai_tools([WEATHER_TOOL]), + stream=True, + ) + + content_tools, chunk_count_tools, tool_calls_detected_tools = ( + collect_streaming_content(stream_with_tools, "openai", timeout=30) + ) + + # Validate tool streaming results + assert chunk_count_tools > 0, "Should receive at least one chunk with tools" + assert ( + tool_calls_detected_tools + ), "Should detect tool calls in streaming response" + + @skip_if_no_api_key("openai") + def test_14_speech_synthesis(self, openai_client, test_config): + """Test Case 14: Speech synthesis (text-to-speech)""" + # Basic speech synthesis test + response = openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice="alloy", + input=SPEECH_TEST_INPUT, + ) + + # Read the audio content + audio_content = response.content + assert_valid_speech_response(audio_content) + + # Test with different voice + response2 = openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice="nova", + input="Short test message.", + response_format="mp3", + ) + + audio_content2 = response2.content + assert_valid_speech_response(audio_content2, expected_audio_size_min=500) + + # Verify that different voices produce different audio + assert ( + audio_content != audio_content2 + ), "Different voices should produce different audio" + + @skip_if_no_api_key("openai") + def test_15_transcription_audio(self, openai_client, test_config): + """Test Case 16: Audio transcription (speech-to-text)""" + # Generate test audio for transcription + test_audio = generate_test_audio() + + # Basic transcription test + response = openai_client.audio.transcriptions.create( + model=get_model("openai", "transcription"), + file=("test_audio.wav", test_audio, "audio/wav"), + ) + + assert_valid_transcription_response(response) + # Since we're using a generated sine wave, we don't expect specific text, + # but the API should return some transcription attempt + + # Test with additional parameters + response2 = openai_client.audio.transcriptions.create( + model=get_model("openai", "transcription"), + file=("test_audio.wav", test_audio, "audio/wav"), + language="en", + temperature=0.0, + ) + + assert_valid_transcription_response(response2) + + @skip_if_no_api_key("openai") + def test_16_transcription_streaming(self, openai_client, test_config): + """Test Case 17: Audio transcription streaming""" + # Generate test audio for streaming transcription + test_audio = generate_test_audio() + + try: + # Try to create streaming transcription + response = openai_client.audio.transcriptions.create( + model=get_model("openai", "transcription"), + file=("test_audio.wav", test_audio, "audio/wav"), + stream=True, + ) + + # If streaming is supported, collect the text chunks + if hasattr(response, "__iter__"): + text_content, chunk_count = collect_streaming_transcription_content( + response, "openai", timeout=60 + ) + assert chunk_count > 0, "Should receive at least one text chunk" + assert_valid_transcription_response( + text_content, min_text_length=0 + ) # Sine wave might not produce much text + else: + # If not streaming, should still be valid transcription + assert_valid_transcription_response(response) + + except Exception as e: + # If streaming is not supported, ensure it's a proper error message + error_message = str(e).lower() + streaming_not_supported = any( + phrase in error_message + for phrase in ["streaming", "not supported", "invalid", "stream"] + ) + if not streaming_not_supported: + # Re-raise if it's not a streaming support issue + raise + + @skip_if_no_api_key("openai") + def test_17_speech_transcription_round_trip(self, openai_client, test_config): + """Test Case 18: Complete round-trip - text to speech to text""" + original_text = "The quick brown fox jumps over the lazy dog." + + # Step 1: Convert text to speech + speech_response = openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice="alloy", + input=original_text, + response_format="wav", # Use WAV for better transcription compatibility + ) + + audio_content = speech_response.content + assert_valid_speech_response(audio_content) + + # Step 2: Convert speech back to text + transcription_response = openai_client.audio.transcriptions.create( + model=get_model("openai", "transcription"), + file=("generated_speech.wav", audio_content, "audio/wav"), + ) + + assert_valid_transcription_response(transcription_response) + transcribed_text = transcription_response.text + + # Step 3: Verify similarity (allowing for some variation in transcription) + # Check for key words from the original text + original_words = original_text.lower().split() + transcribed_words = transcribed_text.lower().split() + + # At least 50% of the original words should be present in the transcription + matching_words = sum(1 for word in original_words if word in transcribed_words) + match_percentage = matching_words / len(original_words) + + assert match_percentage >= 0.3, ( + f"Round-trip transcription should preserve at least 30% of original words. " + f"Original: '{original_text}', Transcribed: '{transcribed_text}', " + f"Match percentage: {match_percentage:.2%}" + ) + + @skip_if_no_api_key("openai") + def test_18_speech_error_handling(self, openai_client, test_config): + """Test Case 19: Speech synthesis error handling""" + # Test with invalid voice + with pytest.raises(Exception) as exc_info: + openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice="invalid_voice_name", + input="This should fail.", + ) + + error = exc_info.value + assert_valid_error_response(error, "invalid_voice_name") + + # Test with empty input + with pytest.raises(Exception) as exc_info: + openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice="alloy", + input="", + ) + + error = exc_info.value + # Should get an error for empty input + + # Test with invalid model + with pytest.raises(Exception) as exc_info: + openai_client.audio.speech.create( + model="invalid-speech-model", + voice="alloy", + input="This should fail due to invalid model.", + ) + + error = exc_info.value + # Should get an error for invalid model + + @skip_if_no_api_key("openai") + def test_19_transcription_error_handling(self, openai_client, test_config): + """Test Case 20: Transcription error handling""" + # Test with invalid audio data + invalid_audio = b"This is not audio data" + + with pytest.raises(Exception) as exc_info: + openai_client.audio.transcriptions.create( + model=get_model("openai", "transcription"), + file=("invalid.wav", invalid_audio, "audio/wav"), + ) + + error = exc_info.value + # Should get an error for invalid audio format + + # Test with invalid model + valid_audio = generate_test_audio() + + with pytest.raises(Exception) as exc_info: + openai_client.audio.transcriptions.create( + model="invalid-transcription-model", + file=("test.wav", valid_audio, "audio/wav"), + ) + + error = exc_info.value + # Should get an error for invalid model + + # Test with unsupported file format (if applicable) + with pytest.raises(Exception) as exc_info: + openai_client.audio.transcriptions.create( + model=get_model("openai", "transcription"), + file=("test.txt", b"text file content", "text/plain"), + ) + + error = exc_info.value + # Should get an error for unsupported file type + + @skip_if_no_api_key("openai") + def test_20_speech_different_voices_and_formats(self, openai_client, test_config): + """Test Case 21: Test different voices and response formats""" + test_text = "Testing different voices and audio formats." + + # Test multiple voices + voices_tested = [] + for voice in SPEECH_TEST_VOICES[ + :3 + ]: # Test first 3 voices to avoid too many API calls + response = openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice=voice, + input=test_text, + response_format="mp3", + ) + + audio_content = response.content + assert_valid_speech_response(audio_content) + voices_tested.append((voice, len(audio_content))) + + # Verify that different voices produce different sized outputs (generally) + sizes = [size for _, size in voices_tested] + assert len(set(sizes)) > 1 or all( + s > 1000 for s in sizes + ), "Different voices should produce varying audio outputs" + + # Test different response formats + formats_to_test = ["mp3", "wav", "opus"] + format_results = [] + + for format_type in formats_to_test: + try: + response = openai_client.audio.speech.create( + model=get_model("openai", "speech"), + voice="alloy", + input="Testing audio format: " + format_type, + response_format=format_type, + ) + + audio_content = response.content + assert_valid_speech_response(audio_content, expected_audio_size_min=500) + format_results.append(format_type) + + except Exception as e: + # Some formats might not be supported + print(f"Format {format_type} not supported or failed: {e}") + + # At least MP3 should be supported + assert "mp3" in format_results, "MP3 format should be supported" + + @skip_if_no_api_key("openai") + def test_21_single_text_embedding(self, openai_client, test_config): + """Test Case 21: Single text embedding generation""" + response = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_SINGLE_TEXT + ) + + assert_valid_embedding_response(response, expected_dimensions=1536) + + # Verify response structure + assert len(response.data) == 1, "Should have exactly one embedding" + assert response.data[0].index == 0, "First embedding should have index 0" + assert ( + response.data[0].object == "embedding" + ), "Object type should be 'embedding'" + + # Verify model in response + assert response.model is not None, "Response should include model name" + assert "text-embedding" in response.model, "Model should be an embedding model" + + @skip_if_no_api_key("openai") + def test_22_batch_text_embeddings(self, openai_client, test_config): + """Test Case 22: Batch text embedding generation""" + response = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_MULTIPLE_TEXTS + ) + + expected_count = len(EMBEDDINGS_MULTIPLE_TEXTS) + assert_valid_embeddings_batch_response( + response, expected_count, expected_dimensions=1536 + ) + + # Verify each embedding has correct index + for i, embedding_obj in enumerate(response.data): + assert embedding_obj.index == i, f"Embedding {i} should have index {i}" + assert ( + embedding_obj.object == "embedding" + ), f"Embedding {i} should have object type 'embedding'" + + @skip_if_no_api_key("openai") + def test_23_embedding_similarity_analysis(self, openai_client, test_config): + """Test Case 23: Embedding similarity analysis with similar texts""" + response = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_SIMILAR_TEXTS + ) + + assert_valid_embeddings_batch_response( + response, len(EMBEDDINGS_SIMILAR_TEXTS), expected_dimensions=1536 + ) + + embeddings = [item.embedding for item in response.data] + + # Test similarity between the first two embeddings (similar weather texts) + similarity_1_2 = calculate_cosine_similarity(embeddings[0], embeddings[1]) + similarity_1_3 = calculate_cosine_similarity(embeddings[0], embeddings[2]) + similarity_2_3 = calculate_cosine_similarity(embeddings[1], embeddings[2]) + + # Similar texts should have high similarity (> 0.7) + assert ( + similarity_1_2 > 0.7 + ), f"Similar texts should have high similarity, got {similarity_1_2:.4f}" + assert ( + similarity_1_3 > 0.7 + ), f"Similar texts should have high similarity, got {similarity_1_3:.4f}" + assert ( + similarity_2_3 > 0.7 + ), f"Similar texts should have high similarity, got {similarity_2_3:.4f}" + + @skip_if_no_api_key("openai") + def test_24_embedding_dissimilarity_analysis(self, openai_client, test_config): + """Test Case 24: Embedding dissimilarity analysis with different texts""" + response = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_DIFFERENT_TEXTS + ) + + assert_valid_embeddings_batch_response( + response, len(EMBEDDINGS_DIFFERENT_TEXTS), expected_dimensions=1536 + ) + + embeddings = [item.embedding for item in response.data] + + # Test dissimilarity between different topic embeddings + # Weather vs Programming + weather_prog_similarity = calculate_cosine_similarity( + embeddings[0], embeddings[1] + ) + # Weather vs Stock Market + weather_stock_similarity = calculate_cosine_similarity( + embeddings[0], embeddings[2] + ) + # Programming vs Machine Learning (should be more similar) + prog_ml_similarity = calculate_cosine_similarity(embeddings[1], embeddings[3]) + + # Different topics should have lower similarity + assert ( + weather_prog_similarity < 0.8 + ), f"Different topics should have lower similarity, got {weather_prog_similarity:.4f}" + assert ( + weather_stock_similarity < 0.8 + ), f"Different topics should have lower similarity, got {weather_stock_similarity:.4f}" + + # Programming and ML should be more similar than completely different topics + assert ( + prog_ml_similarity > weather_prog_similarity + ), "Related tech topics should be more similar than unrelated topics" + + @skip_if_no_api_key("openai") + def test_25_embedding_different_models(self, openai_client, test_config): + """Test Case 25: Test different embedding models""" + test_text = EMBEDDINGS_SINGLE_TEXT + + # Test with text-embedding-3-small (default) + response_small = openai_client.embeddings.create( + model="text-embedding-3-small", input=test_text + ) + assert_valid_embedding_response(response_small, expected_dimensions=1536) + + # Test with text-embedding-3-large if available + try: + response_large = openai_client.embeddings.create( + model="text-embedding-3-large", input=test_text + ) + assert_valid_embedding_response(response_large, expected_dimensions=3072) + + # Verify different models produce different embeddings + embedding_small = response_small.data[0].embedding + embedding_large = response_large.data[0].embedding + + # They should have different dimensions + assert len(embedding_small) != len( + embedding_large + ), "Different models should produce different dimension embeddings" + + except Exception as e: + # If text-embedding-3-large is not available, just log it + print(f"text-embedding-3-large not available: {e}") + + @skip_if_no_api_key("openai") + def test_26_embedding_long_text(self, openai_client, test_config): + """Test Case 26: Embedding generation with longer text""" + response = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_LONG_TEXT + ) + + assert_valid_embedding_response(response, expected_dimensions=1536) + + # Verify token usage is reported for longer text + assert response.usage is not None, "Usage should be reported for longer text" + assert ( + response.usage.total_tokens > 20 + ), "Longer text should consume more tokens" + + @skip_if_no_api_key("openai") + def test_27_embedding_error_handling(self, openai_client, test_config): + """Test Case 27: Embedding error handling""" + + # Test with invalid model + with pytest.raises(Exception) as exc_info: + openai_client.embeddings.create( + model="invalid-embedding-model", input=EMBEDDINGS_SINGLE_TEXT + ) + + error = exc_info.value + assert_valid_error_response(error, "invalid-embedding-model") + + # Test with empty text (depending on implementation, might be handled) + try: + response = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input="" + ) + # If it doesn't throw an error, check that response is still valid + if response: + assert_valid_embedding_response(response) + + except Exception as e: + # Empty input might be rejected, which is acceptable + assert ( + "empty" in str(e).lower() or "invalid" in str(e).lower() + ), "Error should mention empty or invalid input" + + @skip_if_no_api_key("openai") + def test_28_embedding_dimensionality_reduction(self, openai_client, test_config): + """Test Case 28: Embedding with custom dimensions (if supported)""" + try: + # Test custom dimensions with text-embedding-3-small + custom_dimensions = 512 + response = openai_client.embeddings.create( + model="text-embedding-3-small", + input=EMBEDDINGS_SINGLE_TEXT, + dimensions=custom_dimensions, + ) + + assert_valid_embedding_response( + response, expected_dimensions=custom_dimensions + ) + + # Compare with default dimensions + response_default = openai_client.embeddings.create( + model="text-embedding-3-small", input=EMBEDDINGS_SINGLE_TEXT + ) + + embedding_custom = response.data[0].embedding + embedding_default = response_default.data[0].embedding + + assert ( + len(embedding_custom) == custom_dimensions + ), f"Custom dimensions should be {custom_dimensions}" + assert len(embedding_default) == 1536, "Default dimensions should be 1536" + assert len(embedding_custom) != len( + embedding_default + ), "Custom and default dimensions should be different" + + except Exception as e: + # Custom dimensions might not be supported by all models + print(f"Custom dimensions not supported: {e}") + + @skip_if_no_api_key("openai") + def test_29_embedding_encoding_format(self, openai_client, test_config): + """Test Case 29: Different encoding formats (if supported)""" + try: + # Test with float encoding (default) + response_float = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), + input=EMBEDDINGS_SINGLE_TEXT, + encoding_format="float", + ) + + assert_valid_embedding_response(response_float, expected_dimensions=1536) + embedding_float = response_float.data[0].embedding + assert all( + isinstance(x, float) for x in embedding_float + ), "Float encoding should return float values" + + # Test with base64 encoding if supported + try: + response_base64 = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), + input=EMBEDDINGS_SINGLE_TEXT, + encoding_format="base64", + ) + + # Base64 encoding returns string data + assert ( + response_base64.data[0].embedding is not None + ), "Base64 encoding should return data" + + except Exception as base64_error: + print(f"Base64 encoding not supported: {base64_error}") + + except Exception as e: + # Encoding format parameter might not be supported + print(f"Encoding format parameter not supported: {e}") + + @skip_if_no_api_key("openai") + def test_30_embedding_usage_tracking(self, openai_client, test_config): + """Test Case 30: Embedding usage tracking and token counting""" + # Single text embedding + response_single = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_SINGLE_TEXT + ) + + assert_valid_embedding_response(response_single) + assert ( + response_single.usage is not None + ), "Single embedding should have usage data" + assert ( + response_single.usage.total_tokens > 0 + ), "Single embedding should consume tokens" + single_tokens = response_single.usage.total_tokens + + # Batch embedding + response_batch = openai_client.embeddings.create( + model=get_model("openai", "embeddings"), input=EMBEDDINGS_MULTIPLE_TEXTS + ) + + assert_valid_embeddings_batch_response( + response_batch, len(EMBEDDINGS_MULTIPLE_TEXTS) + ) + assert ( + response_batch.usage is not None + ), "Batch embedding should have usage data" + assert ( + response_batch.usage.total_tokens > 0 + ), "Batch embedding should consume tokens" + batch_tokens = response_batch.usage.total_tokens + + # Batch should consume more tokens than single + assert ( + batch_tokens > single_tokens + ), f"Batch embedding ({batch_tokens} tokens) should consume more than single ({single_tokens} tokens)" + + # Verify proportional token usage + texts_ratio = len(EMBEDDINGS_MULTIPLE_TEXTS) + token_ratio = batch_tokens / single_tokens + + # Token ratio should be roughly proportional to text count (allowing for some variance) + assert ( + 0.5 * texts_ratio <= token_ratio <= 2.0 * texts_ratio + ), f"Token usage ratio ({token_ratio:.2f}) should be roughly proportional to text count ({texts_ratio})" diff --git a/tests/integrations/tests/utils/__init__.py b/tests/integrations/tests/utils/__init__.py new file mode 100644 index 000000000..d0ba24ae9 --- /dev/null +++ b/tests/integrations/tests/utils/__init__.py @@ -0,0 +1 @@ +# Utils package for shared test utilities diff --git a/tests/integrations/tests/utils/common.py b/tests/integrations/tests/utils/common.py new file mode 100644 index 000000000..a79e86f00 --- /dev/null +++ b/tests/integrations/tests/utils/common.py @@ -0,0 +1,1397 @@ +""" +Common utilities and test data for all integration tests. +This module contains shared functions, test data, and assertions +that can be used across all integration-specific test files. +""" + +import ast +import base64 +import json +import operator +import os +from typing import Dict, List, Any, Optional +from dataclasses import dataclass + + +# Test Configuration +@dataclass +class Config: + """Configuration for test execution""" + + timeout: int = 30 + max_retries: int = 3 + debug: bool = False + + +# Common Test Data +SIMPLE_CHAT_MESSAGES = [{"role": "user", "content": "Hello! How are you today?"}] + +MULTI_TURN_MESSAGES = [ + {"role": "user", "content": "What's the capital of France?"}, + {"role": "assistant", "content": "The capital of France is Paris."}, + {"role": "user", "content": "What's the population of that city?"}, +] + +# Tool Definitions +WEATHER_TOOL = { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit", + }, + }, + "required": ["location"], + }, +} + +CALCULATOR_TOOL = { + "name": "calculate", + "description": "Perform basic mathematical calculations", + "parameters": { + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "Mathematical expression to evaluate, e.g. '2 + 2'", + } + }, + "required": ["expression"], + }, +} + +SEARCH_TOOL = { + "name": "search_web", + "description": "Search the web for information", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string", "description": "Search query"}}, + "required": ["query"], + }, +} + +ALL_TOOLS = [WEATHER_TOOL, CALCULATOR_TOOL, SEARCH_TOOL] + +# Embeddings Test Data +EMBEDDINGS_SINGLE_TEXT = "The quick brown fox jumps over the lazy dog." + +EMBEDDINGS_MULTIPLE_TEXTS = [ + "Artificial intelligence is transforming our world.", + "Machine learning algorithms learn from data to make predictions.", + "Natural language processing helps computers understand human language.", + "Computer vision enables machines to interpret and analyze visual information.", + "Robotics combines AI with mechanical engineering to create autonomous systems.", +] + +EMBEDDINGS_SIMILAR_TEXTS = [ + "The weather is sunny and warm today.", + "Today has bright sunshine and pleasant temperatures.", + "It's a beautiful day with clear skies and warmth.", +] + +EMBEDDINGS_DIFFERENT_TEXTS = [ + "The weather is sunny and warm today.", + "Python is a popular programming language.", + "The stock market closed higher yesterday.", + "Machine learning requires large datasets.", +] + +EMBEDDINGS_EMPTY_TEXTS = ["", " ", "\n\t", ""] + +EMBEDDINGS_LONG_TEXT = """ +This is a longer text sample designed to test how embedding models handle +larger inputs. It contains multiple sentences with various topics including +technology, science, literature, and general knowledge. The purpose is to +ensure that the embedding generation works correctly with substantial text +inputs that might be closer to real-world usage scenarios where users +embed entire paragraphs or documents rather than just short phrases. +""".strip() + +# Tool Call Test Messages +SINGLE_TOOL_CALL_MESSAGES = [ + {"role": "user", "content": "What's the weather like in San Francisco?"} +] + +MULTIPLE_TOOL_CALL_MESSAGES = [ + {"role": "user", "content": "What's the weather in New York and calculate 15 * 23?"} +] + +# Streaming Test Messages +STREAMING_CHAT_MESSAGES = [ + { + "role": "user", + "content": "Tell me a short story about a robot learning to paint. Keep it under 200 words.", + } +] + +STREAMING_TOOL_CALL_MESSAGES = [ + { + "role": "user", + "content": "What's the weather like in San Francisco? Please use the get_weather function.", + } +] + +# Image Test Data +IMAGE_URL = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg" + +# Small test image as base64 (1x1 pixel red PNG) +BASE64_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + +IMAGE_URL_MESSAGES = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What do you see in this image?"}, + {"type": "image_url", "image_url": {"url": IMAGE_URL}}, + ], + } +] + +IMAGE_BASE64_MESSAGES = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{BASE64_IMAGE}"}, + }, + ], + } +] + +MULTIPLE_IMAGES_MESSAGES = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Compare these two images"}, + {"type": "image_url", "image_url": {"url": IMAGE_URL}}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{BASE64_IMAGE}"}, + }, + ], + } +] + +# Complex End-to-End Test Data +COMPLEX_E2E_MESSAGES = [ + {"role": "user", "content": "Hello! I need help with some tasks."}, + { + "role": "assistant", + "content": "Hello! I'd be happy to help you with your tasks. What do you need assistance with?", + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "First, can you tell me what's in this image and then get the weather for the location shown?", + }, + {"type": "image_url", "image_url": {"url": IMAGE_URL}}, + ], + }, +] + +# Common keyword arrays for flexible assertions +COMPARISON_KEYWORDS = [ + "compare", + "comparison", + "different", + "difference", + "differences", + "both", + "two", + "first", + "second", + "images", + "image", + "versus", + "vs", + "contrast", + "unlike", + "while", + "whereas", +] + +WEATHER_KEYWORDS = [ + "weather", + "temperature", + "sunny", + "cloudy", + "rain", + "snow", + "celsius", + "fahrenheit", + "degrees", + "hot", + "cold", + "warm", + "cool", +] + +LOCATION_KEYWORDS = ["boston", "san francisco", "new york", "city", "location", "place"] + +# Error test data for invalid role testing +INVALID_ROLE_MESSAGES = [ + {"role": "tester", "content": "Hello! This should fail due to invalid role."} +] + +# GenAI-specific invalid role content that passes SDK validation but fails at Bifrost +GENAI_INVALID_ROLE_CONTENT = [ + { + "role": "tester", # Invalid role that should be caught by Bifrost + "parts": [ + {"text": "Hello! This should fail due to invalid role in GenAI format."} + ], + } +] + +# Error keywords for validating error messages +ERROR_KEYWORDS = [ + "invalid", + "error", + "role", + "tester", + "unsupported", + "unknown", + "bad", + "incorrect", + "not allowed", + "not supported", + "forbidden", +] + + +# Helper Functions +def safe_eval_arithmetic(expression: str) -> float: + """ + Safely evaluate arithmetic expressions using AST parsing. + Only allows basic arithmetic operations: +, -, *, /, **, (), and numbers. + + Args: + expression: String containing arithmetic expression + + Returns: + Evaluated result as float + + Raises: + ValueError: If expression contains unsupported operations + SyntaxError: If expression has invalid syntax + ZeroDivisionError: If division by zero occurs + """ + # Allowed operations mapping + ALLOWED_OPS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Pow: operator.pow, + ast.USub: operator.neg, + ast.UAdd: operator.pos, + } + + def eval_node(node): + """Recursively evaluate AST nodes""" + if isinstance(node, ast.Constant): # Numbers + return node.value + elif isinstance(node, ast.Num): # Numbers (Python < 3.8 compatibility) + return node.n + elif isinstance(node, ast.UnaryOp): + if type(node.op) in ALLOWED_OPS: + return ALLOWED_OPS[type(node.op)](eval_node(node.operand)) + else: + raise ValueError( + f"Unsupported unary operation: {type(node.op).__name__}" + ) + elif isinstance(node, ast.BinOp): + if type(node.op) in ALLOWED_OPS: + left = eval_node(node.left) + right = eval_node(node.right) + return ALLOWED_OPS[type(node.op)](left, right) + else: + raise ValueError( + f"Unsupported binary operation: {type(node.op).__name__}" + ) + else: + raise ValueError(f"Unsupported expression type: {type(node).__name__}") + + try: + # Parse the expression into an AST + tree = ast.parse(expression, mode="eval") + # Evaluate the AST + return eval_node(tree.body) + except SyntaxError as e: + raise SyntaxError(f"Invalid syntax in expression '{expression}': {e}") + except ZeroDivisionError: + raise ZeroDivisionError(f"Division by zero in expression '{expression}'") + except Exception as e: + raise ValueError(f"Error evaluating expression '{expression}': {e}") + + +def mock_tool_response(tool_name: str, args: Dict[str, Any]) -> str: + """Generate mock responses for tool calls""" + if tool_name == "get_weather": + location = args.get("location", "Unknown") + unit = args.get("unit", "fahrenheit") + return f"The weather in {location} is 72Β°{'F' if unit == 'fahrenheit' else 'C'} and sunny." + + elif tool_name == "calculate": + expression = args.get("expression", "") + try: + # Clean the expression and safely evaluate it + cleaned_expression = expression.replace("x", "*").replace("Γ—", "*") + result = safe_eval_arithmetic(cleaned_expression) + return f"The result of {expression} is {result}" + except (ValueError, SyntaxError, ZeroDivisionError) as e: + return f"Could not calculate {expression}: {e}" + + elif tool_name == "search_web": + query = args.get("query", "") + return f"Here are the search results for '{query}': [Mock search results]" + + return f"Tool {tool_name} executed with args: {args}" + + +def validate_response_structure(response: Any, expected_fields: List[str]) -> bool: + """Validate that a response has the expected structure""" + if not hasattr(response, "__dict__") and not isinstance(response, dict): + return False + + response_dict = response.__dict__ if hasattr(response, "__dict__") else response + + for field in expected_fields: + if field not in response_dict: + return False + + return True + + +def extract_tool_calls(response: Any) -> List[Dict[str, Any]]: + """Extract tool calls from various response formats""" + tool_calls = [] + + # Handle OpenAI format: response.choices[0].message.tool_calls + if hasattr(response, "choices") and len(response.choices) > 0: + choice = response.choices[0] + if ( + hasattr(choice, "message") + and hasattr(choice.message, "tool_calls") + and choice.message.tool_calls + ): + for tool_call in choice.message.tool_calls: + if hasattr(tool_call, "function"): + tool_calls.append( + { + "name": tool_call.function.name, + "arguments": ( + json.loads(tool_call.function.arguments) + if isinstance(tool_call.function.arguments, str) + else tool_call.function.arguments + ), + } + ) + + # Handle direct tool_calls attribute (other formats) + elif hasattr(response, "tool_calls") and response.tool_calls: + for tool_call in response.tool_calls: + if hasattr(tool_call, "function"): + tool_calls.append( + { + "name": tool_call.function.name, + "arguments": ( + json.loads(tool_call.function.arguments) + if isinstance(tool_call.function.arguments, str) + else tool_call.function.arguments + ), + } + ) + + # Handle Anthropic format: response.content with tool_use blocks + elif hasattr(response, "content") and isinstance(response.content, list): + for content in response.content: + if hasattr(content, "type") and content.type == "tool_use": + tool_calls.append({"name": content.name, "arguments": content.input}) + + return tool_calls + + +def assert_valid_chat_response(response: Any, min_length: int = 1): + """Assert that a chat response is valid""" + assert response is not None, "Response should not be None" + + # Extract content from various response formats + content = "" + if hasattr(response, "text"): # Google GenAI + content = response.text + elif hasattr(response, "content"): # Anthropic + if isinstance(response.content, str): + content = response.content + elif isinstance(response.content, list) and len(response.content) > 0: + # Handle list content (like Anthropic) + text_content = [ + c for c in response.content if hasattr(c, "type") and c.type == "text" + ] + if text_content: + content = text_content[0].text + elif hasattr(response, "choices") and len(response.choices) > 0: # OpenAI + # Handle OpenAI format + choice = response.choices[0] + if hasattr(choice, "message") and hasattr(choice.message, "content"): + content = choice.message.content or "" + + assert ( + len(content) >= min_length + ), f"Response content should be at least {min_length} characters, got: {content}" + + +def assert_has_tool_calls(response: Any, expected_count: Optional[int] = None): + """Assert that a response contains tool calls""" + tool_calls = extract_tool_calls(response) + + assert len(tool_calls) > 0, "Response should contain tool calls" + + if expected_count is not None: + assert ( + len(tool_calls) == expected_count + ), f"Expected {expected_count} tool calls, got {len(tool_calls)}" + + # Validate tool call structure + for tool_call in tool_calls: + assert "name" in tool_call, "Tool call should have a name" + assert "arguments" in tool_call, "Tool call should have arguments" + + +def assert_valid_image_response(response: Any): + """Assert that an image analysis response is valid""" + assert_valid_chat_response(response, min_length=10) + + # Extract content for image-specific validation + content = "" + if hasattr(response, "text"): # Google GenAI + content = response.text.lower() + elif hasattr(response, "content"): # Anthropic + if isinstance(response.content, str): + content = response.content.lower() + elif isinstance(response.content, list): + text_content = [ + c for c in response.content if hasattr(c, "type") and c.type == "text" + ] + if text_content: + content = text_content[0].text.lower() + elif hasattr(response, "choices") and len(response.choices) > 0: # OpenAI + choice = response.choices[0] + if hasattr(choice, "message") and hasattr(choice.message, "content"): + content = (choice.message.content or "").lower() + + # Check for image-related keywords + image_keywords = [ + "image", + "picture", + "photo", + "see", + "visual", + "show", + "appear", + "color", + "scene", + ] + has_image_reference = any(keyword in content for keyword in image_keywords) + + assert ( + has_image_reference + ), f"Response should reference the image content. Got: {content}" + + +def assert_valid_error_response( + response_or_exception: Any, expected_invalid_role: str = "tester" +): + """ + Assert that an error response or exception properly indicates an invalid role error. + + Args: + response_or_exception: Either an HTTP error response or a raised exception + expected_invalid_role: The invalid role that should be mentioned in the error + """ + error_message = "" + error_type = "" + status_code = None + + # Handle different error response formats + if hasattr(response_or_exception, "response"): + # This is likely a requests.HTTPError or similar + try: + error_data = response_or_exception.response.json() + status_code = response_or_exception.response.status_code + + # Extract error message from various formats + if isinstance(error_data, dict): + if "error" in error_data: + if isinstance(error_data["error"], dict): + error_message = error_data["error"].get( + "message", str(error_data["error"]) + ) + error_type = error_data["error"].get("type", "") + else: + error_message = str(error_data["error"]) + else: + error_message = error_data.get("message", str(error_data)) + else: + error_message = str(error_data) + except: + error_message = str(response_or_exception) + + elif hasattr(response_or_exception, "message"): + # Direct error object + error_message = response_or_exception.message + + elif hasattr(response_or_exception, "args") and response_or_exception.args: + # Exception with args + error_message = str(response_or_exception.args[0]) + + else: + # Fallback to string representation + error_message = str(response_or_exception) + + # Convert to lowercase for case-insensitive matching + error_message_lower = error_message.lower() + error_type_lower = error_type.lower() + + # Validate that error message indicates role-related issue + role_error_indicators = [ + expected_invalid_role.lower(), + "role", + "invalid", + "unsupported", + "unknown", + "not allowed", + "not supported", + "bad request", + "invalid_request", + ] + + has_role_error = any( + indicator in error_message_lower or indicator in error_type_lower + for indicator in role_error_indicators + ) + + assert has_role_error, ( + f"Error message should indicate invalid role '{expected_invalid_role}'. " + f"Got error message: '{error_message}', error type: '{error_type}'" + ) + + # Validate status code if available (should be 4xx for client errors) + if status_code is not None: + assert ( + 400 <= status_code < 500 + ), f"Expected 4xx status code for invalid role error, got {status_code}" + + return True + + +def assert_error_propagation(error_response: Any, integration: str): + """ + Assert that error is properly propagated through Bifrost to the integration. + + Args: + error_response: The error response from the integration + integration: The integration name (openai, anthropic, etc.) + """ + # Check that we got an error response (not a success) + assert error_response is not None, "Should have received an error response" + + # Integration-specific error format validation + if integration.lower() == "openai": + # OpenAI format: should have top-level 'type', 'event_id' and 'error' field with nested structure + if hasattr(error_response, "response"): + error_data = error_response.response.json() + assert "error" in error_data, "OpenAI error should have 'error' field" + assert ( + "type" in error_data + ), "OpenAI error should have top-level 'type' field" + assert ( + "event_id" in error_data + ), "OpenAI error should have top-level 'event_id' field" + assert isinstance( + error_data["type"], str + ), "OpenAI error type should be a string" + assert isinstance( + error_data["event_id"], str + ), "OpenAI error event_id should be a string" + + # Check nested error structure + error_obj = error_data["error"] + assert ( + "message" in error_obj + ), "OpenAI error.error should have 'message' field" + assert "type" in error_obj, "OpenAI error.error should have 'type' field" + assert "code" in error_obj, "OpenAI error.error should have 'code' field" + assert ( + "event_id" in error_obj + ), "OpenAI error.error should have 'event_id' field" + + elif integration.lower() == "anthropic": + # Anthropic format: should have 'type' and 'error' with 'type' and 'message' + if hasattr(error_response, "response"): + error_data = error_response.response.json() + assert "type" in error_data, "Anthropic error should have 'type' field" + # Type field can be empty string if not set in original error + assert isinstance( + error_data["type"], str + ), "Anthropic error type should be a string" + assert "error" in error_data, "Anthropic error should have 'error' field" + assert ( + "type" in error_data["error"] + ), "Anthropic error.error should have 'type' field" + assert ( + "message" in error_data["error"] + ), "Anthropic error.error should have 'message' field" + + elif integration.lower() in ["google", "gemini", "genai"]: + # Gemini format: follows Google API design guidelines with error.code, error.message, error.status + if hasattr(error_response, "response"): + error_data = error_response.response.json() + assert "error" in error_data, "Gemini error should have 'error' field" + + # Check Google API standard error structure + error_obj = error_data["error"] + assert ( + "code" in error_obj + ), "Gemini error.error should have 'code' field (HTTP status code)" + assert isinstance( + error_obj["code"], int + ), "Gemini error.error.code should be an integer" + assert ( + "message" in error_obj + ), "Gemini error.error should have 'message' field" + assert isinstance( + error_obj["message"], str + ), "Gemini error.error.message should be a string" + assert ( + "status" in error_obj + ), "Gemini error.error should have 'status' field" + assert isinstance( + error_obj["status"], str + ), "Gemini error.error.status should be a string" + + return True + + +def assert_valid_streaming_response( + chunk: Any, integration: str, is_final: bool = False +): + """ + Assert that a streaming response chunk is valid for the given integration. + + Args: + chunk: Individual streaming response chunk + integration: The integration name (openai, anthropic, etc.) + is_final: Whether this is expected to be the final chunk + """ + assert chunk is not None, "Streaming chunk should not be None" + + if integration.lower() == "openai": + # OpenAI streaming format + assert hasattr(chunk, "choices"), "OpenAI streaming chunk should have choices" + assert ( + len(chunk.choices) > 0 + ), "OpenAI streaming chunk should have at least one choice" + + choice = chunk.choices[0] + assert hasattr(choice, "delta"), "OpenAI streaming choice should have delta" + + # Check for content or tool calls in delta + has_content = ( + hasattr(choice.delta, "content") and choice.delta.content is not None + ) + has_tool_calls = ( + hasattr(choice.delta, "tool_calls") and choice.delta.tool_calls is not None + ) + has_role = hasattr(choice.delta, "role") and choice.delta.role is not None + + # Allow empty deltas for final chunks (they just signal completion) + if not is_final: + assert ( + has_content or has_tool_calls or has_role + ), "OpenAI delta should have content, tool_calls, or role (except for final chunks)" + + if is_final: + assert hasattr( + choice, "finish_reason" + ), "Final chunk should have finish_reason" + assert ( + choice.finish_reason is not None + ), "Final chunk finish_reason should not be None" + + elif integration.lower() == "anthropic": + # Anthropic streaming format + assert hasattr(chunk, "type"), "Anthropic streaming chunk should have type" + + if chunk.type == "content_block_delta": + assert hasattr( + chunk, "delta" + ), "Content block delta should have delta field" + + # Validate based on delta type + if hasattr(chunk.delta, "type"): + if chunk.delta.type == "text_delta": + assert hasattr( + chunk.delta, "text" + ), "Text delta should have text field" + elif chunk.delta.type == "thinking_delta": + assert hasattr( + chunk.delta, "thinking" + ), "Thinking delta should have thinking field" + elif chunk.delta.type == "input_json_delta": + assert hasattr( + chunk.delta, "partial_json" + ), "Input JSON delta should have partial_json field" + else: + # Fallback: if no type specified, assume text_delta for backward compatibility + assert hasattr( + chunk.delta, "text" + ), "Content delta should have text field" + elif chunk.type == "message_delta" and is_final: + assert hasattr(chunk, "usage"), "Final message delta should have usage" + + elif integration.lower() in ["google", "gemini", "genai"]: + # Google streaming format + assert hasattr( + chunk, "candidates" + ), "Google streaming chunk should have candidates" + assert ( + len(chunk.candidates) > 0 + ), "Google streaming chunk should have at least one candidate" + + candidate = chunk.candidates[0] + assert hasattr(candidate, "content"), "Google candidate should have content" + + if is_final: + assert hasattr( + candidate, "finish_reason" + ), "Final chunk should have finish_reason" + + +def collect_streaming_content( + stream, integration: str, timeout: int = 30 +) -> tuple[str, int, bool]: + """ + Collect content from a streaming response and validate the stream. + + Args: + stream: The streaming response iterator + integration: The integration name (openai, anthropic, etc.) + timeout: Maximum time to wait for stream completion + + Returns: + tuple: (collected_content, chunk_count, tool_calls_detected) + """ + import time + + content_parts = [] + chunk_count = 0 + tool_calls_detected = False + start_time = time.time() + + for chunk in stream: + chunk_count += 1 + + # Check timeout + if time.time() - start_time > timeout: + raise TimeoutError(f"Streaming took longer than {timeout} seconds") + + # Validate chunk + is_final = False + if integration.lower() == "openai": + is_final = ( + hasattr(chunk, "choices") + and len(chunk.choices) > 0 + and hasattr(chunk.choices[0], "finish_reason") + and chunk.choices[0].finish_reason is not None + ) + + assert_valid_streaming_response(chunk, integration, is_final) + + # Extract content based on integration + if integration.lower() == "openai": + choice = chunk.choices[0] + if hasattr(choice.delta, "content") and choice.delta.content: + content_parts.append(choice.delta.content) + if hasattr(choice.delta, "tool_calls") and choice.delta.tool_calls: + tool_calls_detected = True + + elif integration.lower() == "anthropic": + if chunk.type == "content_block_delta": + if hasattr(chunk.delta, "text") and chunk.delta.text: + content_parts.append(chunk.delta.text) + elif hasattr(chunk.delta, "thinking") and chunk.delta.thinking: + content_parts.append(chunk.delta.thinking) + # Note: partial_json from input_json_delta is not user-visible content + elif chunk.type == "content_block_start": + # Check for tool use content blocks + if ( + hasattr(chunk, "content_block") + and hasattr(chunk.content_block, "type") + and chunk.content_block.type == "tool_use" + ): + tool_calls_detected = True + + elif integration.lower() in ["google", "gemini", "genai"]: + if hasattr(chunk, "candidates") and len(chunk.candidates) > 0: + candidate = chunk.candidates[0] + if ( + hasattr(candidate.content, "parts") + and len(candidate.content.parts) > 0 + ): + for part in candidate.content.parts: + if hasattr(part, "text") and part.text: + content_parts.append(part.text) + + # Safety check + if chunk_count > 500: + raise ValueError( + "Received too many streaming chunks, something might be wrong" + ) + + content = "".join(content_parts) + return content, chunk_count, tool_calls_detected + + +# Test Categories +class TestCategories: + """Constants for test categories""" + + SIMPLE_CHAT = "simple_chat" + MULTI_TURN = "multi_turn" + SINGLE_TOOL = "single_tool" + MULTIPLE_TOOLS = "multiple_tools" + E2E_TOOLS = "e2e_tools" + AUTO_FUNCTION = "auto_function" + IMAGE_URL = "image_url" + IMAGE_BASE64 = "image_base64" + STREAMING = "streaming" + MULTIPLE_IMAGES = "multiple_images" + COMPLEX_E2E = "complex_e2e" + INTEGRATION_SPECIFIC = "integration_specific" + ERROR_HANDLING = "error_handling" + + +# Speech and Transcription Test Data +SPEECH_TEST_INPUT = "Hello, this is a test of the speech synthesis functionality. The quick brown fox jumps over the lazy dog." + +SPEECH_TEST_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] + + +# Generate a simple test audio file (sine wave) for transcription testing +def generate_test_audio() -> bytes: + """Generate a simple sine wave audio file for testing transcription""" + import wave + import math + import struct + + # Audio parameters + sample_rate = 16000 # 16kHz sample rate + duration = 2 # 2 seconds + frequency = 440 # A4 note (440 Hz) + + # Generate sine wave samples + samples = [] + for i in range(int(sample_rate * duration)): + t = i / sample_rate + sample = int(32767 * math.sin(2 * math.pi * frequency * t)) + samples.append(struct.pack("= expected_audio_size_min + ), f"Audio data should be at least {expected_audio_size_min} bytes, got {len(audio_data)}" + + # Check for common audio file headers + # MP3 files start with 0xFF followed by 0xFB, 0xF3, 0xF2, or 0xF0 (MPEG frame sync) + # or with an ID3 tag + is_mp3 = ( + audio_data.startswith(b"\xff\xfb") # MPEG-1 Layer III + or audio_data.startswith(b"\xff\xf3") # MPEG-2 Layer III + or audio_data.startswith(b"\xff\xf2") # MPEG-2.5 Layer III + or audio_data.startswith(b"\xff\xf0") # MPEG-2 Layer I/II + or audio_data.startswith(b"ID3") # ID3 tag + ) + is_wav = audio_data.startswith(b"RIFF") and b"WAVE" in audio_data[:20] + is_opus = audio_data.startswith(b"OggS") + is_aac = audio_data.startswith(b"\xff\xf1") or audio_data.startswith(b"\xff\xf9") + is_flac = audio_data.startswith(b"fLaC") + + assert ( + is_mp3 or is_wav or is_opus or is_aac or is_flac + ), f"Audio data should be in a recognized format (MP3, WAV, Opus, AAC, or FLAC) but got {audio_data[:100]}" + + +def assert_valid_transcription_response(response: Any, min_text_length: int = 1): + """Assert that a transcription response is valid""" + assert response is not None, "Transcription response should not be None" + + # Extract transcribed text from various response formats + text_content = "" + + if hasattr(response, "text"): + # Direct text attribute + text_content = response.text + elif hasattr(response, "content"): + # JSON response with content + if isinstance(response.content, str): + text_content = response.content + elif isinstance(response.content, dict) and "text" in response.content: + text_content = response.content["text"] + elif isinstance(response, dict): + # Direct dictionary response + text_content = response.get("text", "") + elif isinstance(response, str): + # Direct string response + text_content = response + + assert text_content is not None, "Transcription response should contain text" + assert isinstance( + text_content, str + ), f"Transcribed text should be string, got {type(text_content)}" + assert ( + len(text_content.strip()) >= min_text_length + ), f"Transcribed text should be at least {min_text_length} characters, got: '{text_content}'" + + +def assert_valid_embedding_response( + response: Any, expected_dimensions: Optional[int] = None +) -> None: + """Assert that an embedding response is valid""" + assert response is not None, "Embedding response should not be None" + + # Check if it's an OpenAI-style response object + if hasattr(response, "data"): + assert ( + len(response.data) > 0 + ), "Embedding response should contain at least one embedding" + + embedding = response.data[0].embedding + assert isinstance( + embedding, list + ), f"Embedding should be a list, got {type(embedding)}" + assert len(embedding) > 0, "Embedding should not be empty" + assert all( + isinstance(x, (int, float)) for x in embedding + ), "All embedding values should be numeric" + + if expected_dimensions: + assert ( + len(embedding) == expected_dimensions + ), f"Expected {expected_dimensions} dimensions, got {len(embedding)}" + + # Check if usage information is present + if hasattr(response, "usage") and response.usage: + assert hasattr( + response.usage, "total_tokens" + ), "Usage should include total_tokens" + assert ( + response.usage.total_tokens > 0 + ), "Token usage should be greater than 0" + + elif hasattr(response, "embeddings"): + assert len(response.embeddings) > 0, "Embedding should not be empty" + embedding = response.embeddings[0].values + assert isinstance(embedding, list), "Embedding should be a list" + assert len(embedding) > 0, "Embedding should not be empty" + assert all( + isinstance(x, (int, float)) for x in embedding + ), "All embedding values should be numeric" + if expected_dimensions: + assert ( + len(embedding) == expected_dimensions + ), f"Expected {expected_dimensions} dimensions, got {len(embedding)}" + + # Check if it's a direct list (embedding vector) + elif isinstance(response, list): + assert len(response) > 0, "Embedding should not be empty" + assert all( + isinstance(x, (int, float)) for x in response + ), "All embedding values should be numeric" + + if expected_dimensions: + assert ( + len(response) == expected_dimensions + ), f"Expected {expected_dimensions} dimensions, got {len(response)}" + + else: + raise AssertionError(f"Invalid embedding response format: {type(response)}") + + +def assert_valid_embeddings_batch_response( + response: Any, expected_count: int, expected_dimensions: Optional[int] = None +) -> None: + """Assert that a batch embeddings response is valid""" + assert response is not None, "Embeddings batch response should not be None" + + # Check if it's an OpenAI-style response object + if hasattr(response, "data"): + assert ( + len(response.data) == expected_count + ), f"Expected {expected_count} embeddings, got {len(response.data)}" + + for i, embedding_obj in enumerate(response.data): + assert hasattr( + embedding_obj, "embedding" + ), f"Embedding object {i} should have 'embedding' attribute" + embedding = embedding_obj.embedding + + assert isinstance( + embedding, list + ), f"Embedding {i} should be a list, got {type(embedding)}" + assert len(embedding) > 0, f"Embedding {i} should not be empty" + assert all( + isinstance(x, (int, float)) for x in embedding + ), f"All values in embedding {i} should be numeric" + + if expected_dimensions: + assert ( + len(embedding) == expected_dimensions + ), f"Embedding {i}: expected {expected_dimensions} dimensions, got {len(embedding)}" + + # Check usage information + if hasattr(response, "usage") and response.usage: + assert hasattr( + response.usage, "total_tokens" + ), "Usage should include total_tokens" + assert ( + response.usage.total_tokens > 0 + ), "Token usage should be greater than 0" + + # Check if it's a direct list of embeddings + elif isinstance(response, list): + assert ( + len(response) == expected_count + ), f"Expected {expected_count} embeddings, got {len(response)}" + + for i, embedding in enumerate(response): + assert isinstance( + embedding, list + ), f"Embedding {i} should be a list, got {type(embedding)}" + assert len(embedding) > 0, f"Embedding {i} should not be empty" + assert all( + isinstance(x, (int, float)) for x in embedding + ), f"All values in embedding {i} should be numeric" + + if expected_dimensions: + assert ( + len(embedding) == expected_dimensions + ), f"Embedding {i}: expected {expected_dimensions} dimensions, got {len(embedding)}" + + else: + raise AssertionError( + f"Invalid embeddings batch response format: {type(response)}" + ) + + +def calculate_cosine_similarity( + embedding1: List[float], embedding2: List[float] +) -> float: + """Calculate cosine similarity between two embedding vectors""" + import math + + assert len(embedding1) == len(embedding2), "Embeddings must have the same dimension" + + # Calculate dot product + dot_product = sum(a * b for a, b in zip(embedding1, embedding2)) + + # Calculate magnitudes + magnitude1 = math.sqrt(sum(a * a for a in embedding1)) + magnitude2 = math.sqrt(sum(b * b for b in embedding2)) + + # Avoid division by zero + if magnitude1 == 0 or magnitude2 == 0: + return 0.0 + + return dot_product / (magnitude1 * magnitude2) + + +def assert_embeddings_similarity( + embedding1: List[float], + embedding2: List[float], + min_similarity: float = 0.8, + max_similarity: float = 1.0, +) -> None: + """Assert that two embeddings have expected similarity""" + similarity = calculate_cosine_similarity(embedding1, embedding2) + assert ( + min_similarity <= similarity <= max_similarity + ), f"Embedding similarity {similarity:.4f} should be between {min_similarity} and {max_similarity}" + + +def assert_embeddings_dissimilarity( + embedding1: List[float], embedding2: List[float], max_similarity: float = 0.5 +) -> None: + """Assert that two embeddings are sufficiently different""" + similarity = calculate_cosine_similarity(embedding1, embedding2) + assert ( + similarity <= max_similarity + ), f"Embedding similarity {similarity:.4f} should be at most {max_similarity} for dissimilar texts" + + +def assert_valid_streaming_speech_response(chunk: Any, integration: str): + """Assert that a streaming speech response chunk is valid""" + assert chunk is not None, "Streaming speech chunk should not be None" + + if integration.lower() == "openai": + # For OpenAI, speech streaming returns audio chunks + # The chunk might be direct bytes or wrapped in an object + if hasattr(chunk, "audio"): + audio_data = chunk.audio + elif hasattr(chunk, "data"): + audio_data = chunk.data + elif isinstance(chunk, bytes): + audio_data = chunk + else: + # Try to find audio data in the chunk + audio_data = None + for attr in ["content", "chunk", "audio_chunk"]: + if hasattr(chunk, attr): + audio_data = getattr(chunk, attr) + break + + if audio_data: + assert isinstance( + audio_data, bytes + ), f"Audio chunk should be bytes, got {type(audio_data)}" + assert len(audio_data) > 0, "Audio chunk should not be empty" + + +def assert_valid_streaming_transcription_response(chunk: Any, integration: str): + """Assert that a streaming transcription response chunk is valid""" + assert chunk is not None, "Streaming transcription chunk should not be None" + + if integration.lower() == "openai": + # For OpenAI, transcription streaming returns text chunks + if hasattr(chunk, "text"): + text_chunk = chunk.text + elif hasattr(chunk, "content"): + text_chunk = chunk.content + elif isinstance(chunk, str): + text_chunk = chunk + elif isinstance(chunk, dict) and "text" in chunk: + text_chunk = chunk["text"] + else: + # Try to find text data in the chunk + text_chunk = None + for attr in ["data", "chunk", "text_chunk"]: + if hasattr(chunk, attr): + text_chunk = getattr(chunk, attr) + break + + if text_chunk: + assert isinstance( + text_chunk, str + ), f"Text chunk should be string, got {type(text_chunk)}" + # Note: text chunks can be empty in streaming (e.g., just punctuation updates) + + +def collect_streaming_speech_content( + stream, integration: str, timeout: int = 60 +) -> tuple[bytes, int]: + """ + Collect audio content from a streaming speech response. + + Args: + stream: The streaming response iterator + integration: The integration name (openai, etc.) + timeout: Maximum time to wait for stream completion + + Returns: + tuple: (collected_audio_bytes, chunk_count) + """ + import time + + audio_chunks = [] + chunk_count = 0 + start_time = time.time() + + for chunk in stream: + chunk_count += 1 + + # Check timeout + if time.time() - start_time > timeout: + raise TimeoutError(f"Speech streaming took longer than {timeout} seconds") + + # Validate chunk + assert_valid_streaming_speech_response(chunk, integration) + + # Extract audio data + if integration.lower() == "openai": + if hasattr(chunk, "audio") and chunk.audio: + audio_chunks.append(chunk.audio) + elif hasattr(chunk, "data") and chunk.data: + audio_chunks.append(chunk.data) + elif isinstance(chunk, bytes): + audio_chunks.append(chunk) + + # Safety check + if chunk_count > 1000: + raise ValueError( + "Received too many speech streaming chunks, something might be wrong" + ) + + # Combine all audio chunks + complete_audio = b"".join(audio_chunks) + return complete_audio, chunk_count + + +def collect_streaming_transcription_content( + stream, integration: str, timeout: int = 60 +) -> tuple[str, int]: + """ + Collect text content from a streaming transcription response. + + Args: + stream: The streaming response iterator + integration: The integration name (openai, etc.) + timeout: Maximum time to wait for stream completion + + Returns: + tuple: (collected_text, chunk_count) + """ + import time + + text_chunks = [] + chunk_count = 0 + start_time = time.time() + + for chunk in stream: + chunk_count += 1 + + # Check timeout + if time.time() - start_time > timeout: + raise TimeoutError( + f"Transcription streaming took longer than {timeout} seconds" + ) + + # Validate chunk + assert_valid_streaming_transcription_response(chunk, integration) + + # Extract text data + if integration.lower() == "openai": + if hasattr(chunk, "text") and chunk.text: + text_chunks.append(chunk.text) + elif hasattr(chunk, "content") and chunk.content: + text_chunks.append(chunk.content) + elif isinstance(chunk, str): + text_chunks.append(chunk) + + # Safety check + if chunk_count > 1000: + raise ValueError( + "Received too many transcription streaming chunks, something might be wrong" + ) + + # Combine all text chunks + complete_text = "".join(text_chunks) + return complete_text, chunk_count + + +# Environment helpers +def get_api_key(integration: str) -> str: + """Get API key for a integration from environment variables""" + key_map = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "litellm": "LITELLM_API_KEY", + } + + env_var = key_map.get(integration.lower()) + if not env_var: + raise ValueError(f"Unknown integration: {integration}") + + api_key = os.getenv(env_var) + if not api_key: + raise ValueError(f"Missing environment variable: {env_var}") + + return api_key + + +def skip_if_no_api_key(integration: str): + """Decorator to skip tests if API key is not available""" + import pytest + + def decorator(func): + try: + get_api_key(integration) + return func + except ValueError: + return pytest.mark.skip(f"No API key available for {integration}")(func) + + return decorator diff --git a/tests/integrations/tests/utils/config_loader.py b/tests/integrations/tests/utils/config_loader.py new file mode 100644 index 000000000..ae683d6b0 --- /dev/null +++ b/tests/integrations/tests/utils/config_loader.py @@ -0,0 +1,299 @@ +""" +Configuration loader for Bifrost integration tests. + +This module loads configuration from config.yml and provides utilities +for constructing integration URLs through the Bifrost gateway. +""" + +import os +import yaml +from typing import Dict, Any, Optional +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class BifrostConfig: + """Bifrost gateway configuration""" + + base_url: str + endpoints: Dict[str, str] + + +@dataclass +class IntegrationModels: + """Model configuration for a integration""" + + chat: str + vision: str + tools: str + alternatives: list + + +@dataclass +class TestConfig: + """Complete test configuration""" + + bifrost: BifrostConfig + api: Dict[str, Any] + models: Dict[str, IntegrationModels] + model_capabilities: Dict[str, Dict[str, Any]] + test_settings: Dict[str, Any] + integration_settings: Dict[str, Any] + environments: Dict[str, Any] + logging: Dict[str, Any] + + +class ConfigLoader: + """Configuration loader for Bifrost integration tests""" + + def __init__(self, config_path: Optional[str] = None): + """Initialize configuration loader + + Args: + config_path: Path to config.yml file. If None, looks for config.yml in project root. + """ + if config_path is None: + # Look for config.yml in project root + project_root = Path(__file__).parent.parent.parent + config_path = project_root / "config.yml" + + self.config_path = Path(config_path) + self._config = None + self._load_config() + + def _load_config(self): + """Load configuration from YAML file""" + if not self.config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {self.config_path}") + + with open(self.config_path, "r") as f: + raw_config = yaml.safe_load(f) + + # Expand environment variables + self._config = self._expand_env_vars(raw_config) + + def _expand_env_vars(self, obj): + """Recursively expand environment variables in configuration""" + if isinstance(obj, dict): + return {k: self._expand_env_vars(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._expand_env_vars(item) for item in obj] + elif isinstance(obj, str): + # Handle ${VAR:-default} syntax + import re + + pattern = r"\$\{([^}]+)\}" + + def replace_var(match): + var_expr = match.group(1) + if ":-" in var_expr: + var_name, default_value = var_expr.split(":-", 1) + return os.getenv(var_name, default_value) + else: + return os.getenv(var_expr, "") + + return re.sub(pattern, replace_var, obj) + else: + return obj + + def get_integration_url(self, integration: str) -> str: + """Get the complete URL for a integration + + Args: + integration: Integration name (openai, anthropic, google, litellm) + + Returns: + Complete URL for the integration + + Examples: + get_integration_url("openai") -> "http://localhost:8080/openai" + """ + bifrost_config = self._config["bifrost"] + base_url = bifrost_config["base_url"] + endpoint = bifrost_config["endpoints"].get(integration, "") + + if not endpoint: + raise ValueError(f"No endpoint configured for integration: {integration}") + + return f"{base_url.rstrip('/')}/{endpoint}" + + def get_bifrost_config(self) -> BifrostConfig: + """Get Bifrost configuration""" + bifrost_data = self._config["bifrost"] + return BifrostConfig( + base_url=bifrost_data["base_url"], endpoints=bifrost_data["endpoints"] + ) + + def get_model(self, integration: str, model_type: str = "chat") -> str: + """Get model name for a integration and type""" + if integration not in self._config["models"]: + raise ValueError(f"Unknown integration: {integration}") + + integration_models = self._config["models"][integration] + + if model_type not in integration_models: + raise ValueError( + f"Unknown model type '{model_type}' for integration '{integration}'" + ) + + return integration_models[model_type] + + def get_model_alternatives(self, integration: str) -> list: + """Get alternative models for a integration""" + if integration not in self._config["models"]: + raise ValueError(f"Unknown integration: {integration}") + + return self._config["models"][integration].get("alternatives", []) + + def get_model_capabilities(self, model: str) -> Dict[str, Any]: + """Get capabilities for a specific model""" + return self._config["model_capabilities"].get( + model, + { + "chat": True, + "tools": False, + "vision": False, + "max_tokens": 4096, + "context_window": 4096, + }, + ) + + def supports_capability(self, model: str, capability: str) -> bool: + """Check if a model supports a specific capability""" + caps = self.get_model_capabilities(model) + return caps.get(capability, False) + + def get_api_config(self) -> Dict[str, Any]: + """Get API configuration (timeout, retries, etc.)""" + return self._config["api"] + + def get_test_settings(self) -> Dict[str, Any]: + """Get test configuration settings""" + return self._config["test_settings"] + + def get_integration_settings(self, integration: str) -> Dict[str, Any]: + """Get integration-specific settings""" + return self._config["integration_settings"].get(integration, {}) + + def get_environment_config(self, environment: str = None) -> Dict[str, Any]: + """Get environment-specific configuration + + Args: + environment: Environment name (development, production, etc.) + If None, uses TEST_ENV environment variable or 'development' + """ + if environment is None: + environment = os.getenv("TEST_ENV", "development") + + return self._config["environments"].get(environment, {}) + + def get_logging_config(self) -> Dict[str, Any]: + """Get logging configuration""" + return self._config["logging"] + + def list_integrations(self) -> list: + """List all configured integrations""" + return list(self._config["bifrost"]["endpoints"].keys()) + + def list_models(self, integration: str = None) -> Dict[str, Any]: + """List all models for a integration or all integrations""" + if integration: + if integration not in self._config["models"]: + raise ValueError(f"Unknown integration: {integration}") + return {integration: self._config["models"][integration]} + + return self._config["models"] + + def validate_config(self) -> bool: + """Validate configuration completeness""" + required_sections = ["bifrost", "models", "api", "test_settings"] + + for section in required_sections: + if section not in self._config: + raise ValueError(f"Missing required configuration section: {section}") + + # Validate Bifrost configuration + bifrost = self._config["bifrost"] + if "base_url" not in bifrost or "endpoints" not in bifrost: + raise ValueError("Bifrost configuration missing base_url or endpoints") + + # Validate that all integrations have model configurations + integrations = list(bifrost["endpoints"].keys()) + for integration in integrations: + if integration not in self._config["models"]: + raise ValueError( + f"No model configuration for integration: {integration}" + ) + + return True + + def print_config_summary(self): + """Print a summary of the configuration""" + print("πŸ”§ BIFROST INTEGRATION TEST CONFIGURATION") + print("=" * 80) + + # Bifrost configuration + bifrost = self.get_bifrost_config() + print(f"\nπŸŒ‰ BIFROST GATEWAY:") + print(f" Base URL: {bifrost.base_url}") + print(f" Endpoints:") + for integration, endpoint in bifrost.endpoints.items(): + full_url = f"{bifrost.base_url.rstrip('/')}/{endpoint}" + print(f" {integration}: {full_url}") + + # Model configurations + print(f"\nπŸ€– MODEL CONFIGURATIONS:") + for integration, models in self._config["models"].items(): + print(f" {integration.upper()}:") + print(f" Chat: {models['chat']}") + print(f" Vision: {models['vision']}") + print(f" Tools: {models['tools']}") + print(f" Alternatives: {len(models['alternatives'])} models") + + # API settings + api_config = self.get_api_config() + print(f"\nβš™οΈ API SETTINGS:") + print(f" Timeout: {api_config['timeout']}s") + print(f" Max Retries: {api_config['max_retries']}") + print(f" Retry Delay: {api_config['retry_delay']}s") + + print(f"\nβœ… Configuration loaded successfully from: {self.config_path}") + + +# Global configuration instance +_config_loader = None + + +def get_config() -> ConfigLoader: + """Get global configuration instance""" + global _config_loader + if _config_loader is None: + _config_loader = ConfigLoader() + return _config_loader + + +def get_integration_url(integration: str) -> str: + return get_config().get_integration_url(integration) + + +def get_model(integration: str, model_type: str = "chat") -> str: + """Convenience function to get model name""" + return get_config().get_model(integration, model_type) + + +def get_model_capabilities(model: str) -> Dict[str, Any]: + """Convenience function to get model capabilities""" + return get_config().get_model_capabilities(model) + + +def supports_capability(model: str, capability: str) -> bool: + """Convenience function to check model capability""" + return get_config().supports_capability(model, capability) + + +if __name__ == "__main__": + # Print configuration summary when run directly + config = get_config() + config.validate_config() + config.print_config_summary() diff --git a/tests/integrations/tests/utils/models.py b/tests/integrations/tests/utils/models.py new file mode 100644 index 000000000..315e5410c --- /dev/null +++ b/tests/integrations/tests/utils/models.py @@ -0,0 +1,66 @@ +""" +Model configurations for each integration. + +This file now acts as a compatibility layer and convenience wrapper +around the new configuration system in config.yml and config_loader.py. + +All model data is now centralized in config.yml for easier maintenance. +""" + +from typing import Dict, List +from dataclasses import dataclass +from .config_loader import get_config + + +@dataclass +class IntegrationModels: + """Model configuration for a integration""" + + chat: str # Primary chat model + vision: str # Vision/multimodal model + tools: str # Function calling model + alternatives: List[str] # Alternative models for testing + + +def get_integration_models() -> Dict[str, IntegrationModels]: + """Get all integration model configurations from config.yml""" + config = get_config() + integration_models = {} + + for integration in config.list_integrations(): + models_config = config.list_models(integration) + integration_models[integration] = IntegrationModels( + chat=models_config["chat"], + vision=models_config["vision"], + tools=models_config["tools"], + alternatives=models_config["alternatives"], + ) + + return integration_models + + +# Backward compatibility - load from config +INTEGRATION_MODELS = get_integration_models() + + +def get_alternatives(integration: str) -> List[str]: + """Get alternative models for a integration""" + config = get_config() + return config.get_model_alternatives(integration) + + +def list_all_models() -> Dict[str, Dict[str, str]]: + """List all models by integration and type""" + config = get_config() + return config.list_models() + + +# Print model summary for documentation +def print_model_summary(): + """Print a summary of all models and their capabilities""" + config = get_config() + config.print_config_summary() + + +if __name__ == "__main__": + print_model_summary() diff --git a/transports/.env.sample b/transports/.env.sample deleted file mode 100644 index 30e582a35..000000000 --- a/transports/.env.sample +++ /dev/null @@ -1,10 +0,0 @@ -OPENAI_API_KEY = YOUR_OPENAI_API_KEY -ANTHROPIC_API_KEY = YOUR_ANTHROPIC_API_KEY -BEDROCK_API_KEY = YOUR_BEDROCK_API_KEY -BEDROCK_ACCESS_KEY = YOUR_BEDROCK_ACCESS_KEY -COHERE_API_KEY = YOUR_COHERE_API_KEY -AZURE_API_KEY = YOUR_AZURE_API_KEY -AZURE_ENDPOINT = YOUR_AZURE_ENDPOINT - -MAXIM_API_KEY = YOUR_MAXIM_API_KEY -MAXIM_LOGGER_ID = YOUR_MAXIM_LOGGER_ID \ No newline at end of file diff --git a/transports/Dockerfile b/transports/Dockerfile index df9ac9901..f4bf4b55e 100644 --- a/transports/Dockerfile +++ b/transports/Dockerfile @@ -1,61 +1,93 @@ -# --- First Stage: Builder image --- -FROM golang:1.24 AS builder +# --- UI Build Stage: Build the Next.js frontend --- +FROM node:24-alpine3.22 AS ui-builder WORKDIR /app -# Set environment for static build -ENV CGO_ENABLED=0 -ENV GOOS=linux -ENV GOARCH=amd64 +# Copy UI package files and install dependencies +COPY ui/package*.json ./ +RUN npm ci -# Define build-time variable for transport type -ARG TRANSPORT_TYPE=http +# Copy UI source code +COPY ui/ ./ -# Initialize Go module and fetch the bifrost transport package -RUN go mod init bifrost-transports && \ - go get github.com/maximhq/bifrost/transports/${TRANSPORT_TYPE}@latest +# Build UI (skip the copy-build step) +RUN npx next build +RUN node scripts/fix-paths.js +# Skip the copy-build step since we'll copy the files in the Go build stage -# Build the binary from the fetched package with static linking -RUN go build -ldflags="-w -s" -o /app/main github.com/maximhq/bifrost/transports/${TRANSPORT_TYPE} && \ - test -f /app/main || (echo "Build failed: /app/main not found" && exit 1) && \ - ls -lh /app/main +# --- Go Build Stage: Compile the Go binary --- +FROM golang:1.24-alpine3.22 AS builder +WORKDIR /app + +# Install dependencies including gcc for CGO and sqlite +RUN apk add --no-cache upx gcc musl-dev sqlite-dev binutils binutils-gold + +# Set environment for CGO-enabled build (required for go-sqlite3) +ENV CGO_ENABLED=1 GOOS=linux + +COPY transports/go.mod transports/go.sum ./ +RUN ls +RUN cat go.mod +RUN go mod download + +# Copy source code and dependencies +COPY transports/ ./ + +COPY --from=ui-builder /app/out ./bifrost-http/ui + +# Build the binary with CGO enabled and static SQLite linking +ENV GOWORK=off +ARG VERSION=unknown +RUN go build \ + -ldflags="-w -s -extldflags '-static' -X main.Version=v${VERSION}" \ + -a -trimpath \ + -tags "sqlite_static" \ + -o /app/main \ + ./bifrost-http + +# Compress binary with upx +RUN upx --best --lzma /app/main + +# Verify build succeeded +RUN test -f /app/main || (echo "Build failed" && exit 1) -# --- Second Stage: Runtime image --- -FROM alpine:latest +# --- Runtime Stage: Minimal runtime image --- +FROM alpine:3.22 WORKDIR /app -# Copy the compiled binary from the builder stage +# Create data directory and set up user COPY --from=builder /app/main . -# Ensure the binary is executable -RUN chmod +x /app/main -# Create a directory to store configuration files -RUN mkdir -p /app/config - -# Define build-time variables for config file paths -ARG CONFIG_PATH -ARG ENV_PATH -ARG PORT -ARG POOL_SIZE -ARG DROP_EXCESS_REQUESTS - -# Set default values if args are not provided -ENV APP_PORT=${PORT:-8080} -ENV APP_POOL_SIZE=${POOL_SIZE:-300} -ENV APP_DROP_EXCESS_REQUESTS=${DROP_EXCESS_REQUESTS:-false} - -# Copy the config and environment files into the image -COPY ${CONFIG_PATH} /app/config/config.json -COPY ${ENV_PATH} /app/config/.env - -# Write a small script to validate config presence and run the app -RUN echo '#!/bin/sh' > /app/entrypoint.sh && \ - echo 'if [ ! -f /app/config/config.json ]; then echo "Missing config.json"; exit 1; fi' >> /app/entrypoint.sh && \ - echo 'if [ ! -f /app/config/.env ]; then echo "Missing .env"; exit 1; fi' >> /app/entrypoint.sh && \ - echo 'if [ ! -f /app/main ]; then echo "Missing main binary"; exit 1; fi' >> /app/entrypoint.sh && \ - echo 'exec /app/main -config /app/config/config.json -env /app/config/.env -port "$APP_PORT" -pool-size "$APP_POOL_SIZE" -drop-excess-requests "$APP_DROP_EXCESS_REQUESTS"' >> /app/entrypoint.sh && \ - chmod +x /app/entrypoint.sh - -# Expose the port defined by argument -EXPOSE ${PORT:-8080} - -# Use the script as the entry point -ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file +COPY --from=builder /app/docker-entrypoint.sh . + +# Getting arguments +ARG ARG_APP_PORT=8080 +ARG ARG_APP_HOST=0.0.0.0 +ARG ARG_LOG_LEVEL=info +ARG ARG_LOG_STYLE=json +ARG ARG_APP_DIR=/app/data + +# Environment variables with defaults (can be overridden at runtime) +ENV APP_PORT=$ARG_APP_PORT \ + APP_HOST=$ARG_APP_HOST \ + LOG_LEVEL=$ARG_LOG_LEVEL \ + LOG_STYLE=$ARG_LOG_STYLE \ + APP_DIR=$ARG_APP_DIR + + +RUN mkdir -p $APP_DIR/logs && \ + adduser -D -s /bin/sh appuser && \ + chown -R appuser:appuser /app && \ + chmod +x /app/docker-entrypoint.sh +USER appuser + + +# Declare volume for data persistence +VOLUME ["/app/data"] +EXPOSE $APP_PORT + +# Health check for container status monitoring +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${APP_PORT}/metrics || exit 1 + +# Use entrypoint script that handles volume permissions and argument processing +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["/app/main"] \ No newline at end of file diff --git a/transports/README.md b/transports/README.md index 0f0670a03..84463e6a0 100644 --- a/transports/README.md +++ b/transports/README.md @@ -1,178 +1,166 @@ -# Bifrost Transports +# Bifrost Gateway -This package contains clients for various transports that can be used to spin up your Bifrost client with just a single line of code. +Bifrost Gateway is a blazing-fast HTTP API that unifies access to 15+ AI providers (OpenAI, Anthropic, AWS Bedrock, Google Vertex, and more) through a single OpenAI-compatible interface. Deploy in seconds with zero configuration and get automatic fallbacks, semantic caching, tool calling, and enterprise-grade features. -## πŸ“‘ Table of Contents - -- [Bifrost Transports](#bifrost-transports) - - [πŸ“‘ Table of Contents](#-table-of-contents) - - [πŸš€ Setting Up Transports](#-setting-up-transports) - - [Prerequisites](#prerequisites) - - [Configuration](#configuration) - - [Docker Setup](#docker-setup) - - [Go Setup](#go-setup) - - [🧰 Usage](#-usage) - - [Text Completions](#text-completions) - - [Chat Completions](#chat-completions) - - [πŸ”§ Advanced Features](#-advanced-features) - - [Fallbacks](#fallbacks) +**Complete Documentation**: [https://docs.getbifrost.ai](https://docs.getbifrost.ai) --- -## πŸš€ Setting Up Transports +## Quick Start -### Prerequisites -- Go 1.23 or higher (if not using Docker) -- Access to at least one AI model provider (OpenAI, Anthropic, etc.) -- API keys for the providers you wish to use +### Installation -### Configuration +Choose your preferred method: -Bifrost uses a combination of a JSON configuration file and environment variables: +#### NPX (Recommended) -1. **JSON Configuration File**: Bifrost requires a configuration file to set up the gateway. This includes all your provider-level settings, keys, and meta configs for each of your providers. - -2. **Environment Variables**: If you don't want to include your keys in your config file, you can provide a `.env` file and add a prefix of `env.` followed by its key in your `.env` file. +```bash +# Install and run locally +npx -y @maximhq/bifrost -```json -{ - "keys": [{ - "value": "env.OPENAI_API_KEY", - "models": ["gpt-4o-mini", "gpt-4-turbo"], - "weight": 1.0 - }] -} +# Open web interface at http://localhost:8080 ``` -In this example, `OPENAI_API_KEY` refers to a key in the `.env` file. At runtime, its value will be used to replace the placeholder. +#### Docker + +```bash +# Pull and run Bifrost Gateway +docker pull maximhq/bifrost +docker run -p 8080:8080 maximhq/bifrost + +# For persistent configuration +docker run -p 8080:8080 -v $(pwd)/data:/app/data maximhq/bifrost +``` + +### Configuration + +Bifrost starts with zero configuration needed. Configure providers through the **built-in web UI** at `http://localhost:8080` or via API: + +```bash +# Add OpenAI provider via API +curl -X POST http://localhost:8080/api/providers \ + -H "Content-Type: application/json" \ + -d '{ + "provider": "openai", + "keys": [{"value": "sk-your-openai-key", "models": ["gpt-4o-mini"], "weight": 1.0}] + }' +``` -The same setup applies to keys in meta configs of all providers: +For file-based configuration, create `config.json` in your app directory: ```json { - "meta_config": { - "secret_access_key": "env.BEDROCK_ACCESS_KEY", - "region": "env.BEDROCK_REGION" + "providers": { + "openai": { + "keys": [{"value": "env.OPENAI_API_KEY", "models": ["gpt-4o-mini"], "weight": 1.0}] + } } } ``` -In this example, `BEDROCK_ACCESS_KEY` and `BEDROCK_REGION` refer to keys in the `.env` file. - -Please refer to `config.example.json` and `.env.sample` for examples. - -### Docker Setup - -You can run Bifrost using our **independent Dockerfile**. Just copy our Dockerfile and run these commands to get your Bifrost instance up and running: +### Your First API Call ```bash -docker build \ - --build-arg CONFIG_PATH=./config.example.json \ - --build-arg ENV_PATH=./.env.sample \ - --build-arg PORT=8080 \ - --build-arg POOL_SIZE=300 \ - -t bifrost-transports . - -docker run -p 8080:8080 bifrost-transports +curl -X POST http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello, Bifrost!"}] + }' ``` -You can also add a flag for `DROP_EXCESS_REQUESTS=false` in your Docker build command to drop excess requests when the buffer is full. Read more about `DROP_EXCESS_REQUESTS` and `POOL_SIZE` [here](https://github.com/maximhq/bifrost/tree/main?tab=README-ov-file#additional-configurations). +**That's it!** You now have a unified AI gateway running locally. --- -### Go Setup +## Key Features -If you wish to run Bifrost in your Go environment, follow these steps: +Bifrost Gateway provides enterprise-grade AI infrastructure with these core capabilities: -1. Install your binary: +### Core Features -```bash -go install github.com/maximhq/bifrost/transports/http@latest -``` +- **[Unified Interface](https://docs.getbifrost.ai/features/unified-interface)** - Single OpenAI-compatible API for all providers +- **[Multi-Provider Support](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)** - OpenAI, Anthropic, AWS Bedrock, Google Vertex, Cerebras, Azure, Cohere, Mistral, Ollama, Groq, and more +- **[Drop-in Replacement](https://docs.getbifrost.ai/features/drop-in-replacement)** - Replace OpenAI/Anthropic/GenAI SDKs with zero code changes +- **[Automatic Fallbacks](https://docs.getbifrost.ai/features/fallbacks)** - Seamless failover between providers and models +- **[Streaming Support](https://docs.getbifrost.ai/quickstart/gateway/streaming)** - Real-time response streaming for all providers -2. Run your binary: +### Advanced Features -- If it's in your PATH: -```bash -http -config config.json -env .env -port 8080 -pool-size 300 -``` +- **[Model Context Protocol (MCP)](https://docs.getbifrost.ai/features/mcp)** - Enable AI models to use external tools (filesystem, web search, databases) +- **[Semantic Caching](https://docs.getbifrost.ai/features/semantic-caching)** - Intelligent response caching based on semantic similarity +- **[Load Balancing](https://docs.getbifrost.ai/features/fallbacks)** - Distribute requests across multiple API keys and providers +- **[Governance & Budget Management](https://docs.getbifrost.ai/features/governance)** - Usage tracking, rate limiting, and cost control +- **[Custom Plugins](https://docs.getbifrost.ai/enterprise/custom-plugins)** - Extensible middleware for analytics, monitoring, and custom logic -- Otherwise: -```bash -./http -config config.json -env .env -port 8080 -pool-size 300 -``` +### Enterprise Features -You can also add a flag for `-drop-excess-requests=false` in your command to drop excess requests when the buffer is full. Read more about `DROP_EXCESS_REQUESTS` and `POOL_SIZE` [here](https://github.com/maximhq/bifrost/tree/main?tab=README-ov-file#additional-configurations). +- **[Clustering](https://docs.getbifrost.ai/enterprise/clustering)** - Multi-node deployment with shared state +- **[SSO Integration](https://docs.getbifrost.ai/features/sso-with-google-github)** - Google, GitHub authentication +- **[Vault Support](https://docs.getbifrost.ai/enterprise/vault-support)** - Secure API key management +- **[Custom Analytics](https://docs.getbifrost.ai/features/observability)** - Detailed usage insights and monitoring +- **[In-VPC Deployments](https://docs.getbifrost.ai/enterprise/invpc-deployments)** - Private cloud deployment options -## 🧰 Usage +**Learn More**: [Complete Feature Documentation](https://docs.getbifrost.ai/features/unified-interface) -Ensure that: -- Bifrost's HTTP server is running -- The providers/models you use are configured in your JSON config file +--- -### Text Completions +## SDK Integrations -```bash -curl -X POST http://localhost:8080/v1/text/completions \ - -H "Content-Type: application/json" \ - -d '{ - "provider": "openai", - "model": "gpt-4o-mini", - "text": "Once upon a time in the land of AI,", - "params": { - "temperature": 0.7, - "max_tokens": 100 - } - }' +Replace your existing SDK base URLs to unlock Bifrost's features instantly: + +### OpenAI SDK + +```python +import openai +client = openai.OpenAI( + base_url="http://localhost:8080/openai", + api_key="dummy" # Handled by Bifrost +) ``` -### Chat Completions +### Anthropic SDK -```bash -curl -X POST http://localhost:8080/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "provider": "openai", - "model": "gpt-4o-mini", - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Tell me about Bifrost in Norse mythology."} - ], - "params": { - "temperature": 0.8, - "max_tokens": 500 - } - }' +```python +import anthropic +client = anthropic.Anthropic( + base_url="http://localhost:8080/anthropic", + api_key="dummy" # Handled by Bifrost +) +``` + +### Google GenAI SDK + +```python +import google.generativeai as genai +genai.configure( + transport="rest", + api_endpoint="http://localhost:8080/genai", + api_key="dummy" # Handled by Bifrost +) ``` +**Complete Integration Guides**: [SDK Integrations](https://docs.getbifrost.ai/integrations/what-is-an-integration) + --- -## πŸ”§ Advanced Features +## Documentation -### Fallbacks +### Getting Started -Configure fallback options in your requests: +- [Quick Setup Guide](https://docs.getbifrost.ai/quickstart/gateway/setting-up) - Detailed installation and configuration +- [Provider Configuration](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration) - Connect multiple AI providers +- [Integration Guide](https://docs.getbifrost.ai/quickstart/gateway/integrations) - SDK replacements -```json -{ - "provider": "openai", - "model": "gpt-4", - "messages": [...], - "fallbacks": [ - { - "provider": "anthropic", - "model": "claude-3-opus-20240229" - }, - { - "provider": "bedrock", - "model": "anthropic.claude-3-sonnet-20240229-v1:0" - } - ] -} -``` +### Advanced Topics + +- [MCP Tool Calling](https://docs.getbifrost.ai/features/mcp) - External tool integration +- [Semantic Caching](https://docs.getbifrost.ai/features/semantic-caching) - Intelligent response caching +- [Fallbacks & Load Balancing](https://docs.getbifrost.ai/features/fallbacks) - Reliability and scaling +- [Budget Management](https://docs.getbifrost.ai/features/governance) - Cost control and governance -Read more about fallbacks and other additional configurations [here](https://github.com/maximhq/bifrost/tree/main?tab=README-ov-file#additional-configurations). +**Browse All Documentation**: [https://docs.getbifrost.ai](https://docs.getbifrost.ai) --- -Built with ❀️ by [Maxim](https://github.com/maximhq) \ No newline at end of file +*Built with ❀️ by [Maxim](https://getmaxim.ai)* diff --git a/transports/bifrost-http/.air.toml b/transports/bifrost-http/.air.toml new file mode 100644 index 000000000..d18ee38e0 --- /dev/null +++ b/transports/bifrost-http/.air.toml @@ -0,0 +1,63 @@ +root = "../.." +testdata_dir = "testdata" +tmp_dir = "transports/bifrost-http/tmp" + +[build] +args_bin = [] +bin = "tmp/main" +cmd = "go build -o ./tmp/main ." +delay = 1000 +exclude_dir = [ + "assets", + "tmp", + "vendor", + "testdata", + "ui", + "node_modules", + "transports/bifrost-http/ui", + "core/tests", + "tests", + "docs", + "npx", +] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +watch_dirs = ["."] +include_dir = [] +include_ext = ["go", "tpl", "tmpl", "html"] +include_file = [] +kill_delay = "1s" +log = "tmp/build-errors.log" +poll = false +stop_on_error = true +poll_interval = 0 +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_root = false + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +time = false + +[misc] +clean_on_exit = false + +[proxy] +enabled = false +proxy_port = 8090 +app_port = 8080 + +[screen] +clear_on_rebuild = false +keep_scroll = true diff --git a/transports/bifrost-http/handlers/cache.go b/transports/bifrost-http/handlers/cache.go new file mode 100644 index 000000000..a91d04aa5 --- /dev/null +++ b/transports/bifrost-http/handlers/cache.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "github.com/fasthttp/router" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/plugins/semanticcache" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +type CacheHandler struct { + plugin *semanticcache.Plugin +} + +func NewCacheHandler(plugin schemas.Plugin) *CacheHandler { + semanticCachePlugin, ok := plugin.(*semanticcache.Plugin) + if !ok { + logger.Fatal("Cache handler requires a semantic cache plugin") + } + + return &CacheHandler{ + plugin: semanticCachePlugin, + } +} + +func (h *CacheHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + r.DELETE("/api/cache/clear/{requestId}", lib.ChainMiddlewares(h.clearCache, middlewares...)) + r.DELETE("/api/cache/clear-by-key/{cacheKey}", lib.ChainMiddlewares(h.clearCacheByKey, middlewares...)) +} + +func (h *CacheHandler) clearCache(ctx *fasthttp.RequestCtx) { + requestID, ok := ctx.UserValue("requestId").(string) + if !ok { + SendError(ctx, fasthttp.StatusBadRequest, "Invalid request ID") + return + } + if err := h.plugin.ClearCacheForRequestID(requestID); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to clear cache") + return + } + + SendJSON(ctx, map[string]any{ + "message": "Cache cleared successfully", + }) +} + +func (h *CacheHandler) clearCacheByKey(ctx *fasthttp.RequestCtx) { + cacheKey, ok := ctx.UserValue("cacheKey").(string) + if !ok { + SendError(ctx, fasthttp.StatusBadRequest, "Invalid cache key") + return + } + if err := h.plugin.ClearCacheForKey(cacheKey); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to clear cache") + return + } + + SendJSON(ctx, map[string]any{ + "message": "Cache cleared successfully", + }) +} diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go new file mode 100644 index 000000000..226ccd8ff --- /dev/null +++ b/transports/bifrost-http/handlers/config.go @@ -0,0 +1,346 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "time" + + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/framework" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/encrypt" + "github.com/maximhq/bifrost/framework/modelcatalog" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// ConfigManager is the interface for the config manager +type ConfigManager interface { + UpdateAuthConfig(ctx context.Context, authConfig *configstore.AuthConfig) error + ReloadClientConfigFromConfigStore() error + ReloadPricingManager() error + UpdateDropExcessRequests(value bool) + ReloadPlugin(ctx context.Context, name string, path *string, pluginConfig any) error +} + +// ConfigHandler manages runtime configuration updates for Bifrost. +// It provides endpoints to update and retrieve settings persisted via the ConfigStore backed by sql database. +type ConfigHandler struct { + store *lib.Config + configManager ConfigManager +} + +// NewConfigHandler creates a new handler for configuration management. +// It requires the Bifrost client, a logger, and the config store. +func NewConfigHandler(configManager ConfigManager, store *lib.Config) *ConfigHandler { + return &ConfigHandler{ + configManager: configManager, + store: store, + } +} + +// RegisterRoutes registers the configuration-related routes. +// It adds the `PUT /api/config` endpoint. +func (h *ConfigHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + r.GET("/api/config", lib.ChainMiddlewares(h.getConfig, middlewares...)) + r.PUT("/api/config", lib.ChainMiddlewares(h.updateConfig, middlewares...)) + r.GET("/api/version", lib.ChainMiddlewares(h.getVersion, middlewares...)) +} + +// getVersion handles GET /api/version - Get the current version +func (h *ConfigHandler) getVersion(ctx *fasthttp.RequestCtx) { + SendJSON(ctx, version) +} + +// getConfig handles GET /config - Get the current configuration +func (h *ConfigHandler) getConfig(ctx *fasthttp.RequestCtx) { + var mapConfig = make(map[string]any) + + if query := string(ctx.QueryArgs().Peek("from_db")); query == "true" { + if h.store.ConfigStore == nil { + SendError(ctx, fasthttp.StatusServiceUnavailable, "config store not available") + return + } + cc, err := h.store.ConfigStore.GetClientConfig(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, + fmt.Sprintf("failed to fetch config from db: %v", err)) + return + } + if cc != nil { + mapConfig["client_config"] = *cc + } + // Fetching framework config + fc, err := h.store.ConfigStore.GetFrameworkConfig(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to fetch framework config from db: %v", err)) + return + } + if fc != nil { + mapConfig["framework_config"] = *fc + } else { + mapConfig["framework_config"] = configstoreTables.TableFrameworkConfig{ + PricingURL: bifrost.Ptr(modelcatalog.DefaultPricingURL), + PricingSyncInterval: bifrost.Ptr(int64(modelcatalog.DefaultPricingSyncInterval.Seconds())), + } + } + } else { + mapConfig["client_config"] = h.store.ClientConfig + if h.store.FrameworkConfig == nil { + mapConfig["framework_config"] = configstoreTables.TableFrameworkConfig{ + PricingURL: bifrost.Ptr(modelcatalog.DefaultPricingURL), + PricingSyncInterval: bifrost.Ptr(int64(modelcatalog.DefaultPricingSyncInterval.Seconds())), + } + } else if h.store.FrameworkConfig.Pricing != nil && h.store.FrameworkConfig.Pricing.PricingURL != nil { + mapConfig["framework_config"] = configstoreTables.TableFrameworkConfig{ + PricingURL: h.store.FrameworkConfig.Pricing.PricingURL, + PricingSyncInterval: bifrost.Ptr(int64(*h.store.FrameworkConfig.Pricing.PricingSyncInterval)), + } + } + } + if h.store.ConfigStore != nil { + // Fetching governance config + authConfig, err := h.store.ConfigStore.GetAuthConfig(ctx) + if err != nil { + logger.Warn(fmt.Sprintf("failed to get auth config from store: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get auth config from store: %v", err)) + return + } + // Getting username and password from auth config + // This username password is for the dashboard authentication + if authConfig != nil { + password := "" + if authConfig.AdminPassword != "" { + password = "" + } + // Password we will hash it + mapConfig["auth_config"] = map[string]any{ + "admin_username": authConfig.AdminUserName, + "admin_password": password, + "is_enabled": authConfig.IsEnabled, + "disable_auth_on_inference": authConfig.DisableAuthOnInference, + } + } + } + mapConfig["is_db_connected"] = h.store.ConfigStore != nil + mapConfig["is_cache_connected"] = h.store.VectorStore != nil + mapConfig["is_logs_connected"] = h.store.LogsStore != nil + SendJSON(ctx, mapConfig) +} + +// updateConfig updates the core configuration settings. +// Currently, it supports hot-reloading of the `drop_excess_requests` setting. +// Note that settings like `prometheus_labels` cannot be changed at runtime. +func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { + if h.store.ConfigStore == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Config store not initialized") + return + } + + payload := struct { + ClientConfig configstore.ClientConfig `json:"client_config"` + FrameworkConfig configstoreTables.TableFrameworkConfig `json:"framework_config"` + AuthConfig *configstore.AuthConfig `json:"auth_config"` + }{} + + if err := json.Unmarshal(ctx.PostBody(), &payload); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Validating framework config + if payload.FrameworkConfig.PricingURL != nil && *payload.FrameworkConfig.PricingURL != modelcatalog.DefaultPricingURL { + // Checking the accessibility of the pricing URL + resp, err := http.Get(*payload.FrameworkConfig.PricingURL) + if err != nil { + logger.Warn(fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", err)) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + logger.Warn(fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", resp.StatusCode)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", resp.StatusCode)) + return + } + } + + // Checking the pricing sync interval + if payload.FrameworkConfig.PricingSyncInterval != nil && *payload.FrameworkConfig.PricingSyncInterval <= 0 { + logger.Warn("pricing sync interval must be greater than 0") + SendError(ctx, fasthttp.StatusBadRequest, "pricing sync interval must be greater than 0") + return + } + + // Get current config with proper locking + currentConfig := h.store.ClientConfig + updatedConfig := currentConfig + + shouldReloadTelemetryPlugin := false + + if payload.ClientConfig.DropExcessRequests != currentConfig.DropExcessRequests { + h.configManager.UpdateDropExcessRequests(payload.ClientConfig.DropExcessRequests) + updatedConfig.DropExcessRequests = payload.ClientConfig.DropExcessRequests + } + + if !slices.Equal(payload.ClientConfig.PrometheusLabels, currentConfig.PrometheusLabels) { + updatedConfig.PrometheusLabels = payload.ClientConfig.PrometheusLabels + shouldReloadTelemetryPlugin = true + } + + if !slices.Equal(payload.ClientConfig.AllowedOrigins, currentConfig.AllowedOrigins) { + updatedConfig.AllowedOrigins = payload.ClientConfig.AllowedOrigins + } + + updatedConfig.InitialPoolSize = payload.ClientConfig.InitialPoolSize + updatedConfig.EnableLogging = payload.ClientConfig.EnableLogging + updatedConfig.DisableContentLogging = payload.ClientConfig.DisableContentLogging + updatedConfig.EnableGovernance = payload.ClientConfig.EnableGovernance + updatedConfig.EnforceGovernanceHeader = payload.ClientConfig.EnforceGovernanceHeader + updatedConfig.AllowDirectKeys = payload.ClientConfig.AllowDirectKeys + updatedConfig.MaxRequestBodySizeMB = payload.ClientConfig.MaxRequestBodySizeMB + updatedConfig.EnableLiteLLMFallbacks = payload.ClientConfig.EnableLiteLLMFallbacks + + // Update the store with the new config + h.store.ClientConfig = updatedConfig + + if err := h.store.ConfigStore.UpdateClientConfig(ctx, &updatedConfig); err != nil { + logger.Warn(fmt.Sprintf("failed to save configuration: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to save configuration: %v", err)) + return + } + // Reloading client config from config store + if err := h.configManager.ReloadClientConfigFromConfigStore(); err != nil { + logger.Warn(fmt.Sprintf("failed to reload client config from config store: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to reload client config from config store: %v", err)) + return + } + // Fetching existing framework config + frameworkConfig, err := h.store.ConfigStore.GetFrameworkConfig(ctx) + if err != nil { + logger.Warn(fmt.Sprintf("failed to get framework config from store: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get framework config from store: %v", err)) + return + } + // if framework config is nil, we will use the default pricing config + if frameworkConfig == nil { + frameworkConfig = &configstoreTables.TableFrameworkConfig{ + ID: 0, + PricingURL: bifrost.Ptr(modelcatalog.DefaultPricingURL), + PricingSyncInterval: bifrost.Ptr(int64(modelcatalog.DefaultPricingSyncInterval.Seconds())), + } + } + // Handling individual nil cases + if frameworkConfig.PricingURL == nil { + frameworkConfig.PricingURL = bifrost.Ptr(modelcatalog.DefaultPricingURL) + } + if frameworkConfig.PricingSyncInterval == nil { + frameworkConfig.PricingSyncInterval = bifrost.Ptr(int64(modelcatalog.DefaultPricingSyncInterval.Seconds())) + } + // Updating framework config + shouldReloadFrameworkConfig := false + if payload.FrameworkConfig.PricingURL != nil && *payload.FrameworkConfig.PricingURL != *frameworkConfig.PricingURL { + // Checking the accessibility of the pricing URL + resp, err := http.Get(*payload.FrameworkConfig.PricingURL) + if err != nil { + logger.Warn(fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", err)) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + logger.Warn(fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", resp.StatusCode)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", resp.StatusCode)) + return + } + frameworkConfig.PricingURL = payload.FrameworkConfig.PricingURL + shouldReloadFrameworkConfig = true + } + if payload.FrameworkConfig.PricingSyncInterval != nil { + syncInterval := int64(*payload.FrameworkConfig.PricingSyncInterval) + if syncInterval != *frameworkConfig.PricingSyncInterval { + frameworkConfig.PricingSyncInterval = &syncInterval + shouldReloadFrameworkConfig = true + } + } + // Reload config if required + if shouldReloadFrameworkConfig { + var syncDuration time.Duration + if frameworkConfig.PricingSyncInterval != nil { + syncDuration = time.Duration(*frameworkConfig.PricingSyncInterval) * time.Second + } else { + syncDuration = modelcatalog.DefaultPricingSyncInterval + } + h.store.FrameworkConfig = &framework.FrameworkConfig{ + Pricing: &modelcatalog.Config{ + PricingURL: frameworkConfig.PricingURL, + PricingSyncInterval: &syncDuration, + }, + } + // Saving framework config + if err := h.store.ConfigStore.UpdateFrameworkConfig(ctx, frameworkConfig); err != nil { + logger.Warn(fmt.Sprintf("failed to save framework configuration: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to save framework configuration: %v", err)) + return + } + // Reloading pricing manager + h.configManager.ReloadPricingManager() + } + if shouldReloadTelemetryPlugin { + //TODO: Reload telemetry plugin - solvable problem by having a reference modifier on the metrics handler, but that will lead to loss of data on update + // if err := h.configManager.ReloadPlugin(ctx, telemetry.PluginName, map[string]any{ + // "custom_labels": updatedConfig.PrometheusLabels, + // }); err != nil { + // logger.Warn(fmt.Sprintf("failed to reload telemetry plugin: %v", err)) + // SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to reload telemetry plugin: %v", err)) + // return + // } + } + // Checking auth config and trying to update if required + if payload.AuthConfig != nil { + // Getting current governance config + authConfig, err := h.store.ConfigStore.GetAuthConfig(ctx) + if err != nil { + logger.Warn(fmt.Sprintf("failed to get auth config from store: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get auth config from store: %v", err)) + return + } + // Fetching current Auth config + if payload.AuthConfig.AdminUserName != "" { + if payload.AuthConfig.AdminPassword == "" { + if authConfig.AdminPassword == "" { + SendError(ctx, fasthttp.StatusBadRequest, "auth password must be provided") + return + } + // Assuming that password hasn't been changed + payload.AuthConfig.AdminPassword = authConfig.AdminPassword + } else { + // Password has been changed + // We will hash the password + hashedPassword, err := encrypt.Hash(payload.AuthConfig.AdminPassword) + if err != nil { + logger.Warn(fmt.Sprintf("failed to hash password: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to hash password: %v", err)) + return + } + payload.AuthConfig.AdminPassword = string(hashedPassword) + } + } + err = h.configManager.UpdateAuthConfig(ctx, payload.AuthConfig) + if err != nil { + logger.Warn(fmt.Sprintf("failed to update auth config: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to update auth config: %v", err)) + return + } + } + ctx.SetStatusCode(fasthttp.StatusOK) + SendJSON(ctx, map[string]any{ + "status": "success", + "message": "configuration updated successfully", + }) +} diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go new file mode 100644 index 000000000..f8db6bda3 --- /dev/null +++ b/transports/bifrost-http/handlers/governance.go @@ -0,0 +1,1512 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains all governance management functionality including CRUD operations for VKs, Rules, and configs. +package handlers + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/fasthttp/router" + "github.com/google/uuid" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" + "gorm.io/gorm" +) + +// GovernanceHandler manages HTTP requests for governance operations +type GovernanceHandler struct { + plugin *governance.GovernancePlugin + pluginStore *governance.GovernanceStore + configStore configstore.ConfigStore +} + +// NewGovernanceHandler creates a new governance handler instance +func NewGovernanceHandler(plugin *governance.GovernancePlugin, configStore configstore.ConfigStore) (*GovernanceHandler, error) { + if configStore == nil { + return nil, fmt.Errorf("config store is required") + } + + return &GovernanceHandler{ + plugin: plugin, + pluginStore: plugin.GetGovernanceStore(), + configStore: configStore, + }, nil +} + +// CreateVirtualKeyRequest represents the request body for creating a virtual key +type CreateVirtualKeyRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description,omitempty"` + ProviderConfigs []struct { + Provider string `json:"provider" validate:"required"` + Weight float64 `json:"weight,omitempty"` + AllowedModels []string `json:"allowed_models,omitempty"` // Empty means all models allowed + Budget *CreateBudgetRequest `json:"budget,omitempty"` // Provider-level budget + RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` // Provider-level rate limit + } `json:"provider_configs,omitempty"` // Empty means all providers allowed + MCPConfigs []struct { + MCPClientName string `json:"mcp_client_name" validate:"required"` + ToolsToExecute []string `json:"tools_to_execute,omitempty"` + } `json:"mcp_configs,omitempty"` // Empty means all MCP clients allowed + TeamID *string `json:"team_id,omitempty"` // Mutually exclusive with CustomerID + CustomerID *string `json:"customer_id,omitempty"` // Mutually exclusive with TeamID + Budget *CreateBudgetRequest `json:"budget,omitempty"` + RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` + KeyIDs []string `json:"key_ids,omitempty"` // List of DBKey UUIDs to associate with this VirtualKey + IsActive *bool `json:"is_active,omitempty"` +} + +// UpdateVirtualKeyRequest represents the request body for updating a virtual key +type UpdateVirtualKeyRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + ProviderConfigs []struct { + ID *uint `json:"id,omitempty"` // null for new entries + Provider string `json:"provider" validate:"required"` + Weight float64 `json:"weight,omitempty"` + AllowedModels []string `json:"allowed_models,omitempty"` // Empty means all models allowed + Budget *UpdateBudgetRequest `json:"budget,omitempty"` // Provider-level budget + RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` // Provider-level rate limit + } `json:"provider_configs,omitempty"` + MCPConfigs []struct { + ID *uint `json:"id,omitempty"` // null for new entries + MCPClientName string `json:"mcp_client_name" validate:"required"` + ToolsToExecute []string `json:"tools_to_execute,omitempty"` + } `json:"mcp_configs,omitempty"` + TeamID *string `json:"team_id,omitempty"` + CustomerID *string `json:"customer_id,omitempty"` + Budget *UpdateBudgetRequest `json:"budget,omitempty"` + RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` + KeyIDs []string `json:"key_ids,omitempty"` // List of DBKey UUIDs to associate with this VirtualKey + IsActive *bool `json:"is_active,omitempty"` +} + +// CreateBudgetRequest represents the request body for creating a budget +type CreateBudgetRequest struct { + MaxLimit float64 `json:"max_limit" validate:"required"` // Maximum budget in dollars + ResetDuration string `json:"reset_duration" validate:"required"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M" +} + +// UpdateBudgetRequest represents the request body for updating a budget +type UpdateBudgetRequest struct { + MaxLimit *float64 `json:"max_limit,omitempty"` + ResetDuration *string `json:"reset_duration,omitempty"` +} + +// CreateRateLimitRequest represents the request body for creating a rate limit using flexible approach +type CreateRateLimitRequest struct { + TokenMaxLimit *int64 `json:"token_max_limit,omitempty"` // Maximum tokens allowed + TokenResetDuration *string `json:"token_reset_duration,omitempty"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M" + RequestMaxLimit *int64 `json:"request_max_limit,omitempty"` // Maximum requests allowed + RequestResetDuration *string `json:"request_reset_duration,omitempty"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M" +} + +// UpdateRateLimitRequest represents the request body for updating a rate limit using flexible approach +type UpdateRateLimitRequest struct { + TokenMaxLimit *int64 `json:"token_max_limit,omitempty"` // Maximum tokens allowed + TokenResetDuration *string `json:"token_reset_duration,omitempty"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M" + RequestMaxLimit *int64 `json:"request_max_limit,omitempty"` // Maximum requests allowed + RequestResetDuration *string `json:"request_reset_duration,omitempty"` // e.g., "30s", "5m", "1h", "1d", "1w", "1M" +} + +// CreateTeamRequest represents the request body for creating a team +type CreateTeamRequest struct { + Name string `json:"name" validate:"required"` + CustomerID *string `json:"customer_id,omitempty"` // Team can belong to a customer + Budget *CreateBudgetRequest `json:"budget,omitempty"` // Team can have its own budget +} + +// UpdateTeamRequest represents the request body for updating a team +type UpdateTeamRequest struct { + Name *string `json:"name,omitempty"` + CustomerID *string `json:"customer_id,omitempty"` + Budget *UpdateBudgetRequest `json:"budget,omitempty"` +} + +// CreateCustomerRequest represents the request body for creating a customer +type CreateCustomerRequest struct { + Name string `json:"name" validate:"required"` + Budget *CreateBudgetRequest `json:"budget,omitempty"` +} + +// UpdateCustomerRequest represents the request body for updating a customer +type UpdateCustomerRequest struct { + Name *string `json:"name,omitempty"` + Budget *UpdateBudgetRequest `json:"budget,omitempty"` +} + +// RegisterRoutes registers all governance-related routes for the new hierarchical system +func (h *GovernanceHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + // Virtual Key CRUD operations + r.GET("/api/governance/virtual-keys", lib.ChainMiddlewares(h.getVirtualKeys, middlewares...)) + r.POST("/api/governance/virtual-keys", lib.ChainMiddlewares(h.createVirtualKey, middlewares...)) + r.GET("/api/governance/virtual-keys/{vk_id}", lib.ChainMiddlewares(h.getVirtualKey, middlewares...)) + r.PUT("/api/governance/virtual-keys/{vk_id}", lib.ChainMiddlewares(h.updateVirtualKey, middlewares...)) + r.DELETE("/api/governance/virtual-keys/{vk_id}", lib.ChainMiddlewares(h.deleteVirtualKey, middlewares...)) + + // Team CRUD operations + r.GET("/api/governance/teams", lib.ChainMiddlewares(h.getTeams, middlewares...)) + r.POST("/api/governance/teams", lib.ChainMiddlewares(h.createTeam, middlewares...)) + r.GET("/api/governance/teams/{team_id}", lib.ChainMiddlewares(h.getTeam, middlewares...)) + r.PUT("/api/governance/teams/{team_id}", lib.ChainMiddlewares(h.updateTeam, middlewares...)) + r.DELETE("/api/governance/teams/{team_id}", lib.ChainMiddlewares(h.deleteTeam, middlewares...)) + + // Customer CRUD operations + r.GET("/api/governance/customers", lib.ChainMiddlewares(h.getCustomers, middlewares...)) + r.POST("/api/governance/customers", lib.ChainMiddlewares(h.createCustomer, middlewares...)) + r.GET("/api/governance/customers/{customer_id}", lib.ChainMiddlewares(h.getCustomer, middlewares...)) + r.PUT("/api/governance/customers/{customer_id}", lib.ChainMiddlewares(h.updateCustomer, middlewares...)) + r.DELETE("/api/governance/customers/{customer_id}", lib.ChainMiddlewares(h.deleteCustomer, middlewares...)) +} + +// Virtual Key CRUD Operations + +// getVirtualKeys handles GET /api/governance/virtual-keys - Get all virtual keys with relationships +func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { + // Preload all relationships for complete information + virtualKeys, err := h.configStore.GetVirtualKeys(ctx) + if err != nil { + logger.Error("failed to retrieve virtual keys: %v", err) + SendError(ctx, 500, "Failed to retrieve virtual keys") + return + } + + SendJSON(ctx, map[string]interface{}{ + "virtual_keys": virtualKeys, + "count": len(virtualKeys), + }) +} + +// createVirtualKey handles POST /api/governance/virtual-keys - Create a new virtual key +func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { + var req CreateVirtualKeyRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, 400, "Invalid JSON") + return + } + + // Validate required fields + if req.Name == "" { + SendError(ctx, 400, "Virtual key name is required") + return + } + + // Validate mutually exclusive TeamID and CustomerID + if req.TeamID != nil && req.CustomerID != nil { + SendError(ctx, 400, "VirtualKey cannot be attached to both Team and Customer") + return + } + + // Validate budget if provided + if req.Budget != nil { + if req.Budget.MaxLimit < 0 { + SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", req.Budget.MaxLimit)) + return + } + // Validate reset duration format + if _, err := configstoreTables.ParseDuration(req.Budget.ResetDuration); err != nil { + SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", req.Budget.ResetDuration)) + return + } + } + + // Set defaults + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + + var vk configstoreTables.TableVirtualKey + if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Get the keys if DBKeyIDs are provided + var keys []configstoreTables.TableKey + if len(req.KeyIDs) > 0 { + var err error + keys, err = h.configStore.GetKeysByIDs(ctx, req.KeyIDs) + if err != nil { + return fmt.Errorf("failed to get keys by IDs: %w", err) + } + if len(keys) != len(req.KeyIDs) { + return fmt.Errorf("some keys not found: expected %d, found %d", len(req.KeyIDs), len(keys)) + } + } + + vk = configstoreTables.TableVirtualKey{ + ID: uuid.NewString(), + Name: req.Name, + Value: governance.VirtualKeyPrefix + uuid.NewString(), + Description: req.Description, + TeamID: req.TeamID, + CustomerID: req.CustomerID, + IsActive: isActive, + Keys: keys, // Set the keys for the many-to-many relationship + } + + if req.Budget != nil { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: req.Budget.MaxLimit, + ResetDuration: req.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + vk.BudgetID = &budget.ID + } + + if req.RateLimit != nil { + rateLimit := configstoreTables.TableRateLimit{ + ID: uuid.NewString(), + TokenMaxLimit: req.RateLimit.TokenMaxLimit, + TokenResetDuration: req.RateLimit.TokenResetDuration, + RequestMaxLimit: req.RateLimit.RequestMaxLimit, + RequestResetDuration: req.RateLimit.RequestResetDuration, + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + if err := validateRateLimit(&rateLimit); err != nil { + return err + } + if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + vk.RateLimitID = &rateLimit.ID + } + + if err := h.configStore.CreateVirtualKey(ctx, &vk, tx); err != nil { + return err + } + + if req.ProviderConfigs != nil { + for _, pc := range req.ProviderConfigs { + // Validate budget if provided + if pc.Budget != nil { + if pc.Budget.MaxLimit < 0 { + return fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", pc.Budget.MaxLimit) + } + // Validate reset duration format + if _, err := configstoreTables.ParseDuration(pc.Budget.ResetDuration); err != nil { + return fmt.Errorf("invalid provider config budget reset duration format: %s", pc.Budget.ResetDuration) + } + } + + providerConfig := &configstoreTables.TableVirtualKeyProviderConfig{ + VirtualKeyID: vk.ID, + Provider: pc.Provider, + Weight: pc.Weight, + AllowedModels: pc.AllowedModels, + } + + // Create budget for provider config if provided + if pc.Budget != nil { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: pc.Budget.MaxLimit, + ResetDuration: pc.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + providerConfig.BudgetID = &budget.ID + } + + // Create rate limit for provider config if provided + if pc.RateLimit != nil { + rateLimit := configstoreTables.TableRateLimit{ + ID: uuid.NewString(), + TokenMaxLimit: pc.RateLimit.TokenMaxLimit, + TokenResetDuration: pc.RateLimit.TokenResetDuration, + RequestMaxLimit: pc.RateLimit.RequestMaxLimit, + RequestResetDuration: pc.RateLimit.RequestResetDuration, + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + if err := validateRateLimit(&rateLimit); err != nil { + return err + } + if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + providerConfig.RateLimitID = &rateLimit.ID + } + + if err := h.configStore.CreateVirtualKeyProviderConfig(ctx, providerConfig, tx); err != nil { + return err + } + } + } + + if req.MCPConfigs != nil { + // Check for duplicate MCPClientName values before processing + seenMCPClientNames := make(map[string]bool) + for _, mc := range req.MCPConfigs { + if seenMCPClientNames[mc.MCPClientName] { + return fmt.Errorf("duplicate mcp_client_name: %s", mc.MCPClientName) + } + seenMCPClientNames[mc.MCPClientName] = true + } + + for _, mc := range req.MCPConfigs { + mcpClient, err := h.configStore.GetMCPClientByName(ctx, mc.MCPClientName) + if err != nil { + return fmt.Errorf("failed to get MCP client: %w", err) + } + if err := h.configStore.CreateVirtualKeyMCPConfig(ctx, &configstoreTables.TableVirtualKeyMCPConfig{ + VirtualKeyID: vk.ID, + MCPClientID: mcpClient.ID, + ToolsToExecute: mc.ToolsToExecute, + }, tx); err != nil { + return err + } + } + } + + return nil + }); err != nil { + // Check if this is a duplicate MCPClientName error and return 400 instead of 500 + if strings.Contains(err.Error(), "duplicate mcp_client_name:") { + SendError(ctx, 400, err.Error()) + return + } + SendError(ctx, 500, err.Error()) + return + } + + // Load relationships for response + preloadedVk, err := h.configStore.GetVirtualKey(ctx, vk.ID) + if err != nil { + logger.Error("failed to load relationships for created VK: %v", err) + // If we can't load the full VK, use the basic one we just created + preloadedVk = &vk + } + + // Add to in-memory store + h.pluginStore.CreateVirtualKeyInMemory(preloadedVk) + + // If budget was created, add it to in-memory store + if vk.BudgetID != nil && preloadedVk.Budget != nil { + h.pluginStore.CreateBudgetInMemory(preloadedVk.Budget) + } + + // Add provider-level budgets to in-memory store + if preloadedVk.ProviderConfigs != nil { + for _, pc := range preloadedVk.ProviderConfigs { + if pc.BudgetID != nil && pc.Budget != nil { + h.pluginStore.CreateBudgetInMemory(pc.Budget) + } + } + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Virtual key created successfully", + "virtual_key": preloadedVk, + }) +} + +// getVirtualKey handles GET /api/governance/virtual-keys/{vk_id} - Get a specific virtual key +func (h *GovernanceHandler) getVirtualKey(ctx *fasthttp.RequestCtx) { + vkID := ctx.UserValue("vk_id").(string) + + vk, err := h.configStore.GetVirtualKey(ctx, vkID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Virtual key not found") + return + } + SendError(ctx, 500, "Failed to retrieve virtual key") + return + } + + SendJSON(ctx, map[string]interface{}{ + "virtual_key": vk, + }) +} + +// updateVirtualKey handles PUT /api/governance/virtual-keys/{vk_id} - Update a virtual key +func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { + vkID := ctx.UserValue("vk_id").(string) + + var req UpdateVirtualKeyRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, 400, "Invalid JSON") + return + } + + // Validate mutually exclusive TeamID and CustomerID + if req.TeamID != nil && req.CustomerID != nil { + SendError(ctx, 400, "VirtualKey cannot be attached to both Team and Customer") + return + } + + vk, err := h.configStore.GetVirtualKey(ctx, vkID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Virtual key not found") + return + } + SendError(ctx, 500, "Failed to retrieve virtual key") + return + } + + if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Update fields if provided + if req.Name != nil { + vk.Name = *req.Name + } + if req.Description != nil { + vk.Description = *req.Description + } + if req.TeamID != nil { + vk.TeamID = req.TeamID + vk.CustomerID = nil // Clear CustomerID if setting TeamID + } + if req.CustomerID != nil { + vk.CustomerID = req.CustomerID + vk.TeamID = nil // Clear TeamID if setting CustomerID + } + // When both TeamID and CustomerID are nil + if req.TeamID == nil && req.CustomerID == nil { + vk.TeamID = nil + vk.CustomerID = nil + } + if req.IsActive != nil { + vk.IsActive = *req.IsActive + } + + // Handle budget updates + if req.Budget != nil { + if vk.BudgetID != nil { + // Update existing budget + budget := configstoreTables.TableBudget{} + if err := tx.First(&budget, "id = ?", *vk.BudgetID).Error; err != nil { + return err + } + + if req.Budget.MaxLimit != nil { + budget.MaxLimit = *req.Budget.MaxLimit + } + if req.Budget.ResetDuration != nil { + budget.ResetDuration = *req.Budget.ResetDuration + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { + return err + } + vk.Budget = &budget + } else { + // Create new budget + if req.Budget.MaxLimit == nil || req.Budget.ResetDuration == nil { + return fmt.Errorf("both max_limit and reset_duration are required when creating a new budget") + } + if *req.Budget.MaxLimit < 0 { + return fmt.Errorf("budget max_limit cannot be negative: %.2f", *req.Budget.MaxLimit) + } + if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { + return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) + } + // Storing now + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: *req.Budget.MaxLimit, + ResetDuration: *req.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + vk.BudgetID = &budget.ID + vk.Budget = &budget + } + } + + // Handle rate limit updates + if req.RateLimit != nil { + if vk.RateLimitID != nil { + // Update existing rate limit + rateLimit := configstoreTables.TableRateLimit{} + if err := tx.First(&rateLimit, "id = ?", *vk.RateLimitID).Error; err != nil { + return err + } + + if req.RateLimit.TokenMaxLimit != nil { + rateLimit.TokenMaxLimit = req.RateLimit.TokenMaxLimit + } + if req.RateLimit.TokenResetDuration != nil { + rateLimit.TokenResetDuration = req.RateLimit.TokenResetDuration + } + if req.RateLimit.RequestMaxLimit != nil { + rateLimit.RequestMaxLimit = req.RateLimit.RequestMaxLimit + } + if req.RateLimit.RequestResetDuration != nil { + rateLimit.RequestResetDuration = req.RateLimit.RequestResetDuration + } + + if err := h.configStore.UpdateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + } else { + // Create new rate limit + rateLimit := configstoreTables.TableRateLimit{ + ID: uuid.NewString(), + TokenMaxLimit: req.RateLimit.TokenMaxLimit, + TokenResetDuration: req.RateLimit.TokenResetDuration, + RequestMaxLimit: req.RateLimit.RequestMaxLimit, + RequestResetDuration: req.RateLimit.RequestResetDuration, + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + if err := validateRateLimit(&rateLimit); err != nil { + return err + } + if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + vk.RateLimitID = &rateLimit.ID + } + } + + // Handle DBKey associations if provided + if req.KeyIDs != nil { + // Get the keys if DBKeyIDs are provided + var keys []configstoreTables.TableKey + if len(req.KeyIDs) > 0 { + var err error + keys, err = h.configStore.GetKeysByIDs(ctx, req.KeyIDs) + if err != nil { + return fmt.Errorf("failed to get keys by IDs: %w", err) + } + if len(keys) != len(req.KeyIDs) { + return fmt.Errorf("some keys not found: expected %d, found %d", len(req.KeyIDs), len(keys)) + } + } + + // Set the keys for the many-to-many relationship + vk.Keys = keys + } + + if err := h.configStore.UpdateVirtualKey(ctx, vk, tx); err != nil { + return err + } + + if req.ProviderConfigs != nil { + // Get existing provider configs for comparison + var existingConfigs []configstoreTables.TableVirtualKeyProviderConfig + if err := tx.Where("virtual_key_id = ?", vk.ID).Find(&existingConfigs).Error; err != nil { + return err + } + + // Create maps for easier lookup + existingConfigsMap := make(map[uint]configstoreTables.TableVirtualKeyProviderConfig) + for _, config := range existingConfigs { + existingConfigsMap[config.ID] = config + } + + requestConfigsMap := make(map[uint]bool) + + // Process new configs: create new ones and update existing ones + for _, pc := range req.ProviderConfigs { + if pc.ID == nil { + // Validate budget if provided for new provider config + if pc.Budget != nil { + if pc.Budget.MaxLimit != nil && *pc.Budget.MaxLimit < 0 { + return fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", *pc.Budget.MaxLimit) + } + if pc.Budget.ResetDuration != nil { + if _, err := configstoreTables.ParseDuration(*pc.Budget.ResetDuration); err != nil { + return fmt.Errorf("invalid provider config budget reset duration format: %s", *pc.Budget.ResetDuration) + } + } + // Both fields are required when creating new budget + if pc.Budget.MaxLimit == nil || pc.Budget.ResetDuration == nil { + return fmt.Errorf("both max_limit and reset_duration are required when creating a new provider budget") + } + } + + // Create new provider config + providerConfig := &configstoreTables.TableVirtualKeyProviderConfig{ + VirtualKeyID: vk.ID, + Provider: pc.Provider, + Weight: pc.Weight, + AllowedModels: pc.AllowedModels, + } + + // Create budget for provider config if provided + if pc.Budget != nil { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: *pc.Budget.MaxLimit, + ResetDuration: *pc.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + providerConfig.BudgetID = &budget.ID + } + + // Create rate limit for provider config if provided + if pc.RateLimit != nil { + rateLimit := configstoreTables.TableRateLimit{ + ID: uuid.NewString(), + TokenMaxLimit: pc.RateLimit.TokenMaxLimit, + TokenResetDuration: pc.RateLimit.TokenResetDuration, + RequestMaxLimit: pc.RateLimit.RequestMaxLimit, + RequestResetDuration: pc.RateLimit.RequestResetDuration, + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + if err := validateRateLimit(&rateLimit); err != nil { + return err + } + if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + providerConfig.RateLimitID = &rateLimit.ID + } + + if err := h.configStore.CreateVirtualKeyProviderConfig(ctx, providerConfig, tx); err != nil { + return err + } + } else { + // Update existing provider config + existing, ok := existingConfigsMap[*pc.ID] + if !ok { + return fmt.Errorf("provider config %d does not belong to this virtual key", *pc.ID) + } + requestConfigsMap[*pc.ID] = true + existing.Provider = pc.Provider + existing.Weight = pc.Weight + existing.AllowedModels = pc.AllowedModels + + // Handle budget updates for provider config + if pc.Budget != nil { + if existing.BudgetID != nil { + // Update existing budget + budget := configstoreTables.TableBudget{} + if err := tx.First(&budget, "id = ?", *existing.BudgetID).Error; err != nil { + return err + } + + if pc.Budget.MaxLimit != nil { + budget.MaxLimit = *pc.Budget.MaxLimit + } + if pc.Budget.ResetDuration != nil { + budget.ResetDuration = *pc.Budget.ResetDuration + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { + return err + } + } else { + // Create new budget for existing provider config + if pc.Budget.MaxLimit == nil || pc.Budget.ResetDuration == nil { + return fmt.Errorf("both max_limit and reset_duration are required when creating a new provider budget") + } + if *pc.Budget.MaxLimit < 0 { + return fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", *pc.Budget.MaxLimit) + } + if _, err := configstoreTables.ParseDuration(*pc.Budget.ResetDuration); err != nil { + return fmt.Errorf("invalid provider config budget reset duration format: %s", *pc.Budget.ResetDuration) + } + + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: *pc.Budget.MaxLimit, + ResetDuration: *pc.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + existing.BudgetID = &budget.ID + } + } + + // Handle rate limit updates for provider config + if pc.RateLimit != nil { + if existing.RateLimitID != nil { + // Update existing rate limit + rateLimit := configstoreTables.TableRateLimit{} + if err := tx.First(&rateLimit, "id = ?", *existing.RateLimitID).Error; err != nil { + return err + } + + if pc.RateLimit.TokenMaxLimit != nil { + rateLimit.TokenMaxLimit = pc.RateLimit.TokenMaxLimit + } + if pc.RateLimit.TokenResetDuration != nil { + rateLimit.TokenResetDuration = pc.RateLimit.TokenResetDuration + } + if pc.RateLimit.RequestMaxLimit != nil { + rateLimit.RequestMaxLimit = pc.RateLimit.RequestMaxLimit + } + if pc.RateLimit.RequestResetDuration != nil { + rateLimit.RequestResetDuration = pc.RateLimit.RequestResetDuration + } + + if err := h.configStore.UpdateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + } else { + // Create new rate limit for existing provider config + rateLimit := configstoreTables.TableRateLimit{ + ID: uuid.NewString(), + TokenMaxLimit: pc.RateLimit.TokenMaxLimit, + TokenResetDuration: pc.RateLimit.TokenResetDuration, + RequestMaxLimit: pc.RateLimit.RequestMaxLimit, + RequestResetDuration: pc.RateLimit.RequestResetDuration, + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + if err := validateRateLimit(&rateLimit); err != nil { + return err + } + if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { + return err + } + existing.RateLimitID = &rateLimit.ID + } + } + + if err := h.configStore.UpdateVirtualKeyProviderConfig(ctx, &existing, tx); err != nil { + return err + } + } + } + + // Delete provider configs that are not in the request + for id := range existingConfigsMap { + if !requestConfigsMap[id] { + if err := h.configStore.DeleteVirtualKeyProviderConfig(ctx, id, tx); err != nil { + return err + } + } + } + } + + if req.MCPConfigs != nil { + // Check for duplicate MCPClientName values among all configs before processing + seenMCPClientNames := make(map[string]bool) + for _, mc := range req.MCPConfigs { + if seenMCPClientNames[mc.MCPClientName] { + return fmt.Errorf("duplicate mcp_client_name: %s", mc.MCPClientName) + } + seenMCPClientNames[mc.MCPClientName] = true + } + + // Get existing MCP configs for comparison + var existingMCPConfigs []configstoreTables.TableVirtualKeyMCPConfig + if err := tx.Where("virtual_key_id = ?", vk.ID).Find(&existingMCPConfigs).Error; err != nil { + return err + } + + // Create maps for easier lookup + existingMCPConfigsMap := make(map[uint]configstoreTables.TableVirtualKeyMCPConfig) + for _, config := range existingMCPConfigs { + existingMCPConfigsMap[config.ID] = config + } + + requestMCPConfigsMap := make(map[uint]bool) + + // Process new configs: create new ones and update existing ones + for _, mc := range req.MCPConfigs { + if mc.ID == nil { + mcpClient, err := h.configStore.GetMCPClientByName(ctx, mc.MCPClientName) + if err != nil { + return fmt.Errorf("failed to get MCP client: %w", err) + } + // Create new MCP config + if err := h.configStore.CreateVirtualKeyMCPConfig(ctx, &configstoreTables.TableVirtualKeyMCPConfig{ + VirtualKeyID: vk.ID, + MCPClientID: mcpClient.ID, + ToolsToExecute: mc.ToolsToExecute, + }, tx); err != nil { + return err + } + } else { + // Update existing MCP config + existing, ok := existingMCPConfigsMap[*mc.ID] + if !ok { + return fmt.Errorf("MCP config %d does not belong to this virtual key", *mc.ID) + } + requestMCPConfigsMap[*mc.ID] = true + existing.ToolsToExecute = mc.ToolsToExecute + if err := h.configStore.UpdateVirtualKeyMCPConfig(ctx, &existing, tx); err != nil { + return err + } + } + } + + // Delete MCP configs that are not in the request + for id := range existingMCPConfigsMap { + if !requestMCPConfigsMap[id] { + if err := h.configStore.DeleteVirtualKeyMCPConfig(ctx, id, tx); err != nil { + return err + } + } + } + } + + return nil + }); err != nil { + errMsg := err.Error() + // Check if this is a duplicate MCPClientName error and return 400 instead of 500 + if strings.Contains(errMsg, "duplicate mcp_client_name:") || + strings.Contains(errMsg, "already exists'") || + strings.Contains(errMsg, "duplicate key") { + SendError(ctx, 400, fmt.Sprintf("Failed to update virtual key: %v", err)) + return + } + SendError(ctx, 500, fmt.Sprintf("Failed to update virtual key: %v", err)) + return + } + + // Load relationships for response + preloadedVk, err := h.configStore.GetVirtualKey(ctx, vk.ID) + if err != nil { + logger.Error("failed to load relationships for updated VK: %v", err) + preloadedVk = vk + } + + // Update in-memory cache for budget and rate limit changes + if req.Budget != nil && preloadedVk.BudgetID != nil { + if err := h.pluginStore.UpdateBudgetInMemory(preloadedVk.Budget); err != nil { + logger.Error("failed to update budget cache: %v", err) + } + } + + // Update in-memory cache for provider-level budget changes + if req.ProviderConfigs != nil && preloadedVk.ProviderConfigs != nil { + for _, pc := range preloadedVk.ProviderConfigs { + if pc.BudgetID != nil && pc.Budget != nil { + if err := h.pluginStore.UpdateBudgetInMemory(pc.Budget); err != nil { + logger.Error("failed to update provider budget cache: %v", err) + } + } + } + } + + // Update in-memory store + h.pluginStore.UpdateVirtualKeyInMemory(preloadedVk) + + SendJSON(ctx, map[string]interface{}{ + "message": "Virtual key updated successfully", + "virtual_key": preloadedVk, + }) +} + +// deleteVirtualKey handles DELETE /api/governance/virtual-keys/{vk_id} - Delete a virtual key +func (h *GovernanceHandler) deleteVirtualKey(ctx *fasthttp.RequestCtx) { + vkID := ctx.UserValue("vk_id").(string) + + // Fetch the virtual key from the database to get the budget and rate limit + vk, err := h.configStore.GetVirtualKey(ctx, vkID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Virtual key not found") + return + } + SendError(ctx, 500, "Failed to retrieve virtual key") + return + } + + budgetID := vk.BudgetID + + if err := h.configStore.DeleteVirtualKey(ctx, vkID); err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Virtual key not found") + return + } + SendError(ctx, 500, "Failed to delete virtual key") + return + } + + // Remove from in-memory store + h.pluginStore.DeleteVirtualKeyInMemory(vkID) + + // Remove Budget from in-memory store + if budgetID != nil { + h.pluginStore.DeleteBudgetInMemory(*budgetID) + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Virtual key deleted successfully", + }) +} + +// Team CRUD Operations + +// getTeams handles GET /api/governance/teams - Get all teams +func (h *GovernanceHandler) getTeams(ctx *fasthttp.RequestCtx) { + customerID := string(ctx.QueryArgs().Peek("customer_id")) + + // Preload relationships for complete information + teams, err := h.configStore.GetTeams(ctx, customerID) + if err != nil { + logger.Error("failed to retrieve teams: %v", err) + SendError(ctx, 500, fmt.Sprintf("Failed to retrieve teams: %v", err)) + return + } + + SendJSON(ctx, map[string]interface{}{ + "teams": teams, + "count": len(teams), + }) +} + +// createTeam handles POST /api/governance/teams - Create a new team +func (h *GovernanceHandler) createTeam(ctx *fasthttp.RequestCtx) { + var req CreateTeamRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, 400, "Invalid JSON") + return + } + + // Validate required fields + if req.Name == "" { + SendError(ctx, 400, "Team name is required") + return + } + + // Validate budget if provided + if req.Budget != nil { + if req.Budget.MaxLimit < 0 { + SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", req.Budget.MaxLimit)) + return + } + // Validate reset duration format + if _, err := configstoreTables.ParseDuration(req.Budget.ResetDuration); err != nil { + SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", req.Budget.ResetDuration)) + return + } + } + + var team configstoreTables.TableTeam + if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + team = configstoreTables.TableTeam{ + ID: uuid.NewString(), + Name: req.Name, + CustomerID: req.CustomerID, + } + + if req.Budget != nil { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: req.Budget.MaxLimit, + ResetDuration: req.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + team.BudgetID = &budget.ID + } + + if err := h.configStore.CreateTeam(ctx, &team, tx); err != nil { + return err + } + return nil + }); err != nil { + logger.Error("failed to create team: %v", err) + SendError(ctx, 500, "failed to create team") + return + } + + // Load relationships for response + preloadedTeam, err := h.configStore.GetTeam(ctx, team.ID) + if err != nil { + logger.Error("failed to load relationships for created team: %v", err) + preloadedTeam = &team + } + + // Add to in-memory store + h.pluginStore.CreateTeamInMemory(preloadedTeam) + + // If budget was created, add it to in-memory store + if preloadedTeam.BudgetID != nil { + h.pluginStore.CreateBudgetInMemory(preloadedTeam.Budget) + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Team created successfully", + "team": preloadedTeam, + }) +} + +// getTeam handles GET /api/governance/teams/{team_id} - Get a specific team +func (h *GovernanceHandler) getTeam(ctx *fasthttp.RequestCtx) { + teamID := ctx.UserValue("team_id").(string) + + team, err := h.configStore.GetTeam(ctx, teamID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Team not found") + return + } + SendError(ctx, 500, "Failed to retrieve team") + return + } + + SendJSON(ctx, map[string]interface{}{ + "team": team, + }) +} + +// updateTeam handles PUT /api/governance/teams/{team_id} - Update a team +func (h *GovernanceHandler) updateTeam(ctx *fasthttp.RequestCtx) { + teamID := ctx.UserValue("team_id").(string) + + var req UpdateTeamRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, 400, "Invalid JSON") + return + } + + team, err := h.configStore.GetTeam(ctx, teamID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Team not found") + return + } + SendError(ctx, 500, "Failed to retrieve team") + return + } + + if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Update fields if provided + if req.Name != nil { + team.Name = *req.Name + } + if req.CustomerID != nil { + team.CustomerID = req.CustomerID + } + + // Handle budget updates + if req.Budget != nil { + if team.BudgetID != nil { + // Update existing budget + budget, err := h.configStore.GetBudget(ctx, *team.BudgetID, tx) + if err != nil { + return err + } + + if req.Budget.MaxLimit != nil { + budget.MaxLimit = *req.Budget.MaxLimit + } + if req.Budget.ResetDuration != nil { + budget.ResetDuration = *req.Budget.ResetDuration + } + + if err := h.configStore.UpdateBudget(ctx, budget, tx); err != nil { + return err + } + team.Budget = budget + } else { + // Create new budget + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: *req.Budget.MaxLimit, + ResetDuration: *req.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + team.BudgetID = &budget.ID + team.Budget = &budget + } + } + + if err := h.configStore.UpdateTeam(ctx, team, tx); err != nil { + return err + } + + return nil + }); err != nil { + SendError(ctx, 500, "Failed to update team") + return + } + + // Update in-memory cache for budget changes + if req.Budget != nil && team.BudgetID != nil { + if err := h.pluginStore.UpdateBudgetInMemory(team.Budget); err != nil { + logger.Error("failed to update budget cache: %v", err) + } + } + + // Load relationships for response + preloadedTeam, err := h.configStore.GetTeam(ctx, team.ID) + if err != nil { + logger.Error("failed to load relationships for updated team: %v", err) + preloadedTeam = team + } + + // Update in-memory store + h.pluginStore.UpdateTeamInMemory(preloadedTeam) + + SendJSON(ctx, map[string]interface{}{ + "message": "Team updated successfully", + "team": preloadedTeam, + }) +} + +// deleteTeam handles DELETE /api/governance/teams/{team_id} - Delete a team +func (h *GovernanceHandler) deleteTeam(ctx *fasthttp.RequestCtx) { + teamID := ctx.UserValue("team_id").(string) + + team, err := h.configStore.GetTeam(ctx, teamID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Team not found") + return + } + SendError(ctx, 500, "Failed to retrieve team") + return + } + + budgetID := team.BudgetID + + if err := h.configStore.DeleteTeam(ctx, teamID); err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Team not found") + return + } + SendError(ctx, 500, "Failed to delete team") + return + } + + // Remove from in-memory store + h.pluginStore.DeleteTeamInMemory(teamID) + + // Remove Budget from in-memory store + if budgetID != nil { + h.pluginStore.DeleteBudgetInMemory(*budgetID) + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Team deleted successfully", + }) +} + +// Customer CRUD Operations + +// getCustomers handles GET /api/governance/customers - Get all customers +func (h *GovernanceHandler) getCustomers(ctx *fasthttp.RequestCtx) { + customers, err := h.configStore.GetCustomers(ctx) + if err != nil { + logger.Error("failed to retrieve customers: %v", err) + SendError(ctx, 500, "failed to retrieve customers") + return + } + + SendJSON(ctx, map[string]interface{}{ + "customers": customers, + "count": len(customers), + }) +} + +// createCustomer handles POST /api/governance/customers - Create a new customer +func (h *GovernanceHandler) createCustomer(ctx *fasthttp.RequestCtx) { + var req CreateCustomerRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, 400, "Invalid JSON") + return + } + + // Validate required fields + if req.Name == "" { + SendError(ctx, 400, "Customer name is required") + return + } + + // Validate budget if provided + if req.Budget != nil { + if req.Budget.MaxLimit < 0 { + SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", req.Budget.MaxLimit)) + return + } + // Validate reset duration format + if _, err := configstoreTables.ParseDuration(req.Budget.ResetDuration); err != nil { + SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", req.Budget.ResetDuration)) + return + } + } + + var customer configstoreTables.TableCustomer + if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + customer = configstoreTables.TableCustomer{ + ID: uuid.NewString(), + Name: req.Name, + } + + if req.Budget != nil { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: req.Budget.MaxLimit, + ResetDuration: req.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + customer.BudgetID = &budget.ID + } + + if err := h.configStore.CreateCustomer(ctx, &customer, tx); err != nil { + return err + } + return nil + }); err != nil { + SendError(ctx, 500, "failed to create customer") + return + } + + // Load relationships for response + preloadedCustomer, err := h.configStore.GetCustomer(ctx, customer.ID) + if err != nil { + logger.Error("failed to load relationships for created customer: %v", err) + preloadedCustomer = &customer + } + + // Add to in-memory store + h.pluginStore.CreateCustomerInMemory(preloadedCustomer) + + // If budget was created, add it to in-memory store + if preloadedCustomer.BudgetID != nil { + h.pluginStore.CreateBudgetInMemory(preloadedCustomer.Budget) + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Customer created successfully", + "customer": preloadedCustomer, + }) +} + +// getCustomer handles GET /api/governance/customers/{customer_id} - Get a specific customer +func (h *GovernanceHandler) getCustomer(ctx *fasthttp.RequestCtx) { + customerID := ctx.UserValue("customer_id").(string) + + customer, err := h.configStore.GetCustomer(ctx, customerID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Customer not found") + return + } + SendError(ctx, 500, "Failed to retrieve customer") + return + } + + SendJSON(ctx, map[string]interface{}{ + "customer": customer, + }) +} + +// updateCustomer handles PUT /api/governance/customers/{customer_id} - Update a customer +func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { + customerID := ctx.UserValue("customer_id").(string) + + var req UpdateCustomerRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, 400, "Invalid JSON") + return + } + + customer, err := h.configStore.GetCustomer(ctx, customerID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Customer not found") + return + } + SendError(ctx, 500, "Failed to retrieve customer") + return + } + + if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Update fields if provided + if req.Name != nil { + customer.Name = *req.Name + } + + // Handle budget updates + if req.Budget != nil { + if customer.BudgetID != nil { + // Update existing budget + budget, err := h.configStore.GetBudget(ctx, *customer.BudgetID, tx) + if err != nil { + return err + } + + if req.Budget.MaxLimit != nil { + budget.MaxLimit = *req.Budget.MaxLimit + } + if req.Budget.ResetDuration != nil { + budget.ResetDuration = *req.Budget.ResetDuration + } + + if err := h.configStore.UpdateBudget(ctx, budget, tx); err != nil { + return err + } + customer.Budget = budget + } else { + // Create new budget + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: *req.Budget.MaxLimit, + ResetDuration: *req.Budget.ResetDuration, + LastReset: time.Now(), + CurrentUsage: 0, + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + customer.BudgetID = &budget.ID + customer.Budget = &budget + } + } + + if err := h.configStore.UpdateCustomer(ctx, customer, tx); err != nil { + return err + } + + return nil + }); err != nil { + SendError(ctx, 500, "Failed to update customer") + return + } + + // Update in-memory cache for budget changes + if req.Budget != nil && customer.BudgetID != nil { + if err := h.pluginStore.UpdateBudgetInMemory(customer.Budget); err != nil { + logger.Error("failed to update budget cache: %v", err) + } + } + + // Load relationships for response + preloadedCustomer, err := h.configStore.GetCustomer(ctx, customer.ID) + if err != nil { + logger.Error("failed to load relationships for updated customer: %v", err) + preloadedCustomer = customer + } + + // Update in-memory store + h.pluginStore.UpdateCustomerInMemory(preloadedCustomer) + + SendJSON(ctx, map[string]interface{}{ + "message": "Customer updated successfully", + "customer": preloadedCustomer, + }) +} + +// deleteCustomer handles DELETE /api/governance/customers/{customer_id} - Delete a customer +func (h *GovernanceHandler) deleteCustomer(ctx *fasthttp.RequestCtx) { + customerID := ctx.UserValue("customer_id").(string) + + customer, err := h.configStore.GetCustomer(ctx, customerID) + if err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Customer not found") + return + } + SendError(ctx, 500, "Failed to retrieve customer") + return + } + + budgetID := customer.BudgetID + + if err := h.configStore.DeleteCustomer(ctx, customerID); err != nil { + if err == gorm.ErrRecordNotFound { + SendError(ctx, 404, "Customer not found") + return + } + SendError(ctx, 500, "Failed to delete customer") + return + } + + // Remove from in-memory store + h.pluginStore.DeleteCustomerInMemory(customerID) + + // Remove Budget from in-memory store + if budgetID != nil { + h.pluginStore.DeleteBudgetInMemory(*budgetID) + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Customer deleted successfully", + }) +} + +func validateRateLimit(rateLimit *configstoreTables.TableRateLimit) error { + if rateLimit.TokenMaxLimit != nil && (*rateLimit.TokenMaxLimit < 0 || *rateLimit.TokenMaxLimit == 0) { + return fmt.Errorf("rate limit token max limit cannot be negative or zero: %d", *rateLimit.TokenMaxLimit) + } + // Only require token reset duration if token limit is set + if rateLimit.TokenMaxLimit != nil { + if rateLimit.TokenResetDuration == nil { + return fmt.Errorf("rate limit token reset duration is required") + } + if _, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err != nil { + return fmt.Errorf("invalid rate limit token reset duration format: %s", *rateLimit.TokenResetDuration) + } + } + + if rateLimit.RequestMaxLimit != nil && (*rateLimit.RequestMaxLimit < 0 || *rateLimit.RequestMaxLimit == 0) { + return fmt.Errorf("rate limit request max limit cannot be negative or zero: %d", *rateLimit.RequestMaxLimit) + } + // Only require request reset duration if request limit is set + if rateLimit.RequestMaxLimit != nil { + if rateLimit.RequestResetDuration == nil { + return fmt.Errorf("rate limit request reset duration is required") + } + if _, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err != nil { + return fmt.Errorf("invalid rate limit request reset duration format: %s", *rateLimit.RequestResetDuration) + } + } + return nil +} + +func validateBudget(budget *configstoreTables.TableBudget) error { + if budget.MaxLimit < 0 || budget.MaxLimit == 0 { + return fmt.Errorf("budget max limit cannot be negative or zero: %.2f", budget.MaxLimit) + } + if budget.ResetDuration == "" { + return fmt.Errorf("budget reset duration is required") + } + if _, err := configstoreTables.ParseDuration(budget.ResetDuration); err != nil { + return fmt.Errorf("invalid budget reset duration format: %s", budget.ResetDuration) + } + return nil +} diff --git a/transports/bifrost-http/handlers/health.go b/transports/bifrost-http/handlers/health.go new file mode 100644 index 000000000..9c4103354 --- /dev/null +++ b/transports/bifrost-http/handlers/health.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "sync" + "time" + + "github.com/fasthttp/router" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// HealthHandler manages HTTP requests for health checks. +type HealthHandler struct { + config *lib.Config +} + +// NewHealthHandler creates a new health handler instance. +func NewHealthHandler(config *lib.Config) *HealthHandler { + return &HealthHandler{ + config: config, + } +} + +// RegisterRoutes registers the health-related routes. +func (h *HealthHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + r.GET("/health", lib.ChainMiddlewares(h.getHealth, middlewares...)) +} + +// getHealth handles GET /api/health - Get the health status of the server. +func (h *HealthHandler) getHealth(ctx *fasthttp.RequestCtx) { + // Pinging config store + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var errors []string + var mu sync.Mutex + var wg sync.WaitGroup + + if h.config.ConfigStore != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := h.config.ConfigStore.Ping(reqCtx); err != nil { + mu.Lock() + errors = append(errors, "config store not available") + mu.Unlock() + } + }() + } + + // Pinging log store + if h.config.LogsStore != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := h.config.LogsStore.Ping(reqCtx); err != nil { + mu.Lock() + errors = append(errors, "log store not available") + mu.Unlock() + } + }() + } + + // Pinging vector store + if h.config.VectorStore != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := h.config.VectorStore.Ping(reqCtx); err != nil { + mu.Lock() + errors = append(errors, "vector store not available") + mu.Unlock() + } + }() + } + + wg.Wait() + + if len(errors) > 0 { + SendError(ctx, fasthttp.StatusServiceUnavailable, errors[0]) + return + } + SendJSON(ctx, map[string]any{"status": "ok"}) +} diff --git a/transports/bifrost-http/handlers/inference.go b/transports/bifrost-http/handlers/inference.go new file mode 100644 index 000000000..c0f4b86f5 --- /dev/null +++ b/transports/bifrost-http/handlers/inference.go @@ -0,0 +1,1102 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains completion request handlers for text and chat completions. +package handlers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/bytedance/sonic" + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// CompletionHandler manages HTTP requests for completion operations +type CompletionHandler struct { + client *bifrost.Bifrost + handlerStore lib.HandlerStore + config *lib.Config +} + +// NewInferenceHandler creates a new completion handler instance +func NewInferenceHandler(client *bifrost.Bifrost, config *lib.Config) *CompletionHandler { + return &CompletionHandler{ + client: client, + handlerStore: config, + config: config, + } +} + +// Known fields for CompletionRequest +var textParamsKnownFields = map[string]bool{ + "model": true, + "text": true, + "fallbacks": true, + "best_of": true, + "echo": true, + "frequency_penalty": true, + "logit_bias": true, + "logprobs": true, + "max_tokens": true, + "n": true, + "presence_penalty": true, + "seed": true, + "stop": true, + "suffix": true, + "temperature": true, + "top_p": true, + "user": true, +} + +// Known fields for CompletionRequest +var chatParamsKnownFields = map[string]bool{ + "model": true, + "messages": true, + "fallbacks": true, + "stream": true, + "frequency_penalty": true, + "logit_bias": true, + "logprobs": true, + "max_completion_tokens": true, + "metadata": true, + "modalities": true, + "parallel_tool_calls": true, + "presence_penalty": true, + "prompt_cache_key": true, + "reasoning_effort": true, + "response_format": true, + "safety_identifier": true, + "service_tier": true, + "stream_options": true, + "store": true, + "temperature": true, + "tool_choice": true, + "tools": true, + "truncation": true, + "user": true, + "verbosity": true, +} + +var responsesParamsKnownFields = map[string]bool{ + "model": true, + "input": true, + "fallbacks": true, + "stream": true, + "background": true, + "conversation": true, + "include": true, + "instructions": true, + "max_output_tokens": true, + "max_tool_calls": true, + "metadata": true, + "parallel_tool_calls": true, + "previous_response_id": true, + "prompt_cache_key": true, + "reasoning": true, + "safety_identifier": true, + "service_tier": true, + "stream_options": true, + "store": true, + "temperature": true, + "text": true, + "top_logprobs": true, + "top_p": true, + "tool_choice": true, + "tools": true, + "truncation": true, +} + +var embeddingParamsKnownFields = map[string]bool{ + "model": true, + "input": true, + "fallbacks": true, + "encoding_format": true, + "dimensions": true, +} + +var speechParamsKnownFields = map[string]bool{ + "model": true, + "input": true, + "fallbacks": true, + "stream_format": true, + "voice": true, + "instructions": true, + "response_format": true, + "speed": true, +} + +var transcriptionParamsKnownFields = map[string]bool{ + "model": true, + "file": true, + "fallbacks": true, + "stream": true, + "language": true, + "prompt": true, + "response_format": true, + "file_format": true, +} + +type BifrostParams struct { + Model string `json:"model"` // Model to use in "provider/model" format + Fallbacks []string `json:"fallbacks"` // Fallback providers and models in "provider/model" format + Stream *bool `json:"stream"` // Whether to stream the response + StreamFormat *string `json:"stream_format,omitempty"` // For speech +} + +type TextRequest struct { + Prompt *schemas.TextCompletionInput `json:"prompt"` + BifrostParams + *schemas.TextCompletionParameters +} + +type ChatRequest struct { + Messages []schemas.ChatMessage `json:"messages"` + BifrostParams + *schemas.ChatParameters +} + +// ResponsesRequestInput is a union of string and array of responses messages +type ResponsesRequestInput struct { + ResponsesRequestInputStr *string + ResponsesRequestInputArray []schemas.ResponsesMessage +} + +// UnmarshalJSON unmarshals the responses request input +func (r *ResponsesRequestInput) UnmarshalJSON(data []byte) error { + var str string + if err := sonic.Unmarshal(data, &str); err == nil { + r.ResponsesRequestInputStr = &str + r.ResponsesRequestInputArray = nil + return nil + } + var array []schemas.ResponsesMessage + if err := sonic.Unmarshal(data, &array); err == nil { + r.ResponsesRequestInputStr = nil + r.ResponsesRequestInputArray = array + return nil + } + return fmt.Errorf("invalid responses request input") +} + +// ResponsesRequest is a bifrost responses request +type ResponsesRequest struct { + Input ResponsesRequestInput `json:"input"` + BifrostParams + *schemas.ResponsesParameters +} + +// EmbeddingRequest is a bifrost embedding request +type EmbeddingRequest struct { + Input *schemas.EmbeddingInput `json:"input"` + BifrostParams + *schemas.EmbeddingParameters +} + +type SpeechRequest struct { + *schemas.SpeechInput + BifrostParams + *schemas.SpeechParameters +} + +type TranscriptionRequest struct { + *schemas.TranscriptionInput + BifrostParams + *schemas.TranscriptionParameters +} + +// Helper functions + +// parseFallbacks extracts fallbacks from string array and converts to Fallback structs +func parseFallbacks(fallbackStrings []string) ([]schemas.Fallback, error) { + fallbacks := make([]schemas.Fallback, 0, len(fallbackStrings)) + for _, fallback := range fallbackStrings { + fallbackProvider, fallbackModelName := schemas.ParseModelString(fallback, "") + if fallbackProvider != "" && fallbackModelName != "" { + fallbacks = append(fallbacks, schemas.Fallback{ + Provider: fallbackProvider, + Model: fallbackModelName, + }) + } + } + return fallbacks, nil +} + +// extractExtraParams processes unknown fields from JSON data into ExtraParams +func extractExtraParams(data []byte, knownFields map[string]bool) (map[string]interface{}, error) { + // Parse JSON to extract unknown fields + var rawData map[string]json.RawMessage + if err := json.Unmarshal(data, &rawData); err != nil { + return nil, err + } + + // Extract unknown fields + extraParams := make(map[string]interface{}) + for key, value := range rawData { + if !knownFields[key] { + var v interface{} + if err := json.Unmarshal(value, &v); err != nil { + continue // Skip fields that can't be unmarshaled + } + extraParams[key] = v + } + } + + return extraParams, nil +} + +const ( + // Maximum file size (25MB) + MaxFileSize = 25 * 1024 * 1024 + + // Primary MIME types for audio formats + AudioMimeMP3 = "audio/mpeg" // Covers MP3, MPEG, MPGA + AudioMimeMP4 = "audio/mp4" // MP4 audio + AudioMimeM4A = "audio/x-m4a" // M4A specific + AudioMimeOGG = "audio/ogg" // OGG audio + AudioMimeWAV = "audio/wav" // WAV audio + AudioMimeWEBM = "audio/webm" // WEBM audio + AudioMimeFLAC = "audio/flac" // FLAC audio + AudioMimeFLAC2 = "audio/x-flac" // Alternative FLAC +) + +// RegisterRoutes registers all completion-related routes +func (h *CompletionHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + // Model endpoints + r.GET("/v1/models", lib.ChainMiddlewares(h.listModels, middlewares...)) + + // Completion endpoints + r.POST("/v1/completions", lib.ChainMiddlewares(h.textCompletion, middlewares...)) + r.POST("/v1/chat/completions", lib.ChainMiddlewares(h.chatCompletion, middlewares...)) + r.POST("/v1/responses", lib.ChainMiddlewares(h.responses, middlewares...)) + r.POST("/v1/embeddings", lib.ChainMiddlewares(h.embeddings, middlewares...)) + r.POST("/v1/audio/speech", lib.ChainMiddlewares(h.speech, middlewares...)) + r.POST("/v1/audio/transcriptions", lib.ChainMiddlewares(h.transcription, middlewares...)) +} + +// listModels handles GET /v1/models - Process list models requests +// If provider is not specified, lists all models from all configured providers +func (h *CompletionHandler) listModels(ctx *fasthttp.RequestCtx) { + // Get provider from query parameters + provider := string(ctx.QueryArgs().Peek("provider")) + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + defer cancel() // Ensure cleanup on function exit + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + var resp *schemas.BifrostListModelsResponse + var bifrostErr *schemas.BifrostError + + pageSize := 0 + if pageSizeStr := ctx.QueryArgs().Peek("page_size"); len(pageSizeStr) > 0 { + if n, err := strconv.Atoi(string(pageSizeStr)); err == nil && n >= 0 { + pageSize = n + } + } + pageToken := string(ctx.QueryArgs().Peek("page_token")) + + bifrostListModelsReq := &schemas.BifrostListModelsRequest{ + Provider: schemas.ModelProvider(provider), + PageSize: pageSize, + PageToken: pageToken, + } + + // Pass-through unknown query params for provider-specific features + extraParams := map[string]interface{}{} + for k, v := range ctx.QueryArgs().All() { + s := string(k) + if s != "provider" && s != "page_size" && s != "page_token" { + extraParams[s] = string(v) + } + } + if len(extraParams) > 0 { + bifrostListModelsReq.ExtraParams = extraParams + } + + // If provider is empty, list all models from all providers + if provider == "" { + resp, bifrostErr = h.client.ListAllModels(*bifrostCtx, bifrostListModelsReq) + } else { + resp, bifrostErr = h.client.ListModelsRequest(*bifrostCtx, bifrostListModelsReq) + } + + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Add pricing data to the response + if len(resp.Data) > 0 && h.config.PricingManager != nil { + for i, modelEntry := range resp.Data { + provider, modelName := schemas.ParseModelString(modelEntry.ID, "") + pricingEntry := h.config.PricingManager.GetPricingEntryForModel(modelName, provider) + if pricingEntry != nil { + pricing := &schemas.Pricing{ + Prompt: bifrost.Ptr(fmt.Sprintf("%f", pricingEntry.InputCostPerToken)), + Completion: bifrost.Ptr(fmt.Sprintf("%f", pricingEntry.OutputCostPerToken)), + } + if pricingEntry.InputCostPerImage != nil { + pricing.Image = bifrost.Ptr(fmt.Sprintf("%f", *pricingEntry.InputCostPerImage)) + } + if pricingEntry.CacheReadInputTokenCost != nil { + pricing.InputCacheRead = bifrost.Ptr(fmt.Sprintf("%f", *pricingEntry.CacheReadInputTokenCost)) + } + resp.Data[i].Pricing = pricing + } + } + } + + // Send successful response + SendJSON(ctx, resp) +} + +// textCompletion handles POST /v1/completions - Process text completion requests +func (h *CompletionHandler) textCompletion(ctx *fasthttp.RequestCtx) { + var req TextRequest + if err := sonic.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + // Create BifrostTextCompletionRequest directly using segregated structure + provider, modelName := schemas.ParseModelString(req.Model, "") + if provider == "" || modelName == "" { + SendError(ctx, fasthttp.StatusBadRequest, "model should be in provider/model format") + return + } + // Parse fallbacks using helper function + fallbacks, err := parseFallbacks(req.Fallbacks) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + if req.Prompt == nil || (req.Prompt.PromptStr == nil && req.Prompt.PromptArray == nil) { + SendError(ctx, fasthttp.StatusBadRequest, "prompt is required for text completion") + return + } + // Extract extra params + if req.TextCompletionParameters == nil { + req.TextCompletionParameters = &schemas.TextCompletionParameters{} + } + extraParams, err := extractExtraParams(ctx.PostBody(), textParamsKnownFields) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to extract extra params: %v", err)) + } else { + req.TextCompletionParameters.ExtraParams = extraParams + } + // Adding fallback context + if h.config.ClientConfig.EnableLiteLLMFallbacks { + ctx.SetUserValue(schemas.BifrostContextKey("x-litellm-fallback"), "true") + } + // Create segregated BifrostTextCompletionRequest + bifrostTextReq := &schemas.BifrostTextCompletionRequest{ + Provider: schemas.ModelProvider(provider), + Model: modelName, + Input: req.Prompt, + Params: req.TextCompletionParameters, + Fallbacks: fallbacks, + } + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + if req.Stream != nil && *req.Stream { + h.handleStreamingTextCompletion(ctx, bifrostTextReq, bifrostCtx, cancel) + return + } + + // NOTE: these defers wont work as expected when a non-streaming request is cancelled on flight. + // valyala/fasthttp does not support cancelling a request in the middle of a request. + // This is a known issue of valyala/fasthttp. And will be fixed here once it is fixed upstream. + defer cancel() // Ensure cleanup on function exit + + resp, bifrostErr := h.client.TextCompletionRequest(*bifrostCtx, bifrostTextReq) + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + SendJSON(ctx, resp) +} + +// chatCompletion handles POST /v1/chat/completions - Process chat completion requests +func (h *CompletionHandler) chatCompletion(ctx *fasthttp.RequestCtx) { + var req ChatRequest + if err := sonic.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Create BifrostChatRequest directly using segregated structure + provider, modelName := schemas.ParseModelString(req.Model, "") + if provider == "" || modelName == "" { + SendError(ctx, fasthttp.StatusBadRequest, "model should be in provider/model format") + return + } + + // Parse fallbacks using helper function + fallbacks, err := parseFallbacks(req.Fallbacks) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + + if len(req.Messages) == 0 { + SendError(ctx, fasthttp.StatusBadRequest, "Messages is required for chat completion") + return + } + + // Extract extra params + if req.ChatParameters == nil { + req.ChatParameters = &schemas.ChatParameters{} + } + + extraParams, err := extractExtraParams(ctx.PostBody(), chatParamsKnownFields) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to extract extra params: %v", err)) + } else { + req.ChatParameters.ExtraParams = extraParams + } + + // Create segregated BifrostChatRequest + bifrostChatReq := &schemas.BifrostChatRequest{ + Provider: schemas.ModelProvider(provider), + Model: modelName, + Input: req.Messages, + Params: req.ChatParameters, + Fallbacks: fallbacks, + } + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + if req.Stream != nil && *req.Stream { + h.handleStreamingChatCompletion(ctx, bifrostChatReq, bifrostCtx, cancel) + return + } + + defer cancel() // Ensure cleanup on function exit + + resp, bifrostErr := h.client.ChatCompletionRequest(*bifrostCtx, bifrostChatReq) + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + SendJSON(ctx, resp) +} + +// responses handles POST /v1/responses - Process responses requests +func (h *CompletionHandler) responses(ctx *fasthttp.RequestCtx) { + var req ResponsesRequest + if err := sonic.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Create BifrostResponsesRequest directly using segregated structure + provider, modelName := schemas.ParseModelString(req.Model, "") + if provider == "" || modelName == "" { + SendError(ctx, fasthttp.StatusBadRequest, "model should be in provider/model format") + return + } + + // Parse fallbacks using helper function + fallbacks, err := parseFallbacks(req.Fallbacks) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + + if len(req.Input.ResponsesRequestInputArray) == 0 && req.Input.ResponsesRequestInputStr == nil { + SendError(ctx, fasthttp.StatusBadRequest, "Input is required for responses") + return + } + + // Extract extra params + if req.ResponsesParameters == nil { + req.ResponsesParameters = &schemas.ResponsesParameters{} + } + + extraParams, err := extractExtraParams(ctx.PostBody(), responsesParamsKnownFields) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to extract extra params: %v", err)) + } else { + req.ResponsesParameters.ExtraParams = extraParams + } + + input := req.Input.ResponsesRequestInputArray + if input == nil { + input = []schemas.ResponsesMessage{ + { + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ContentStr: req.Input.ResponsesRequestInputStr}, + }, + } + } + + // Create segregated BifrostResponsesRequest + bifrostResponsesReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.ModelProvider(provider), + Model: modelName, + Input: input, + Params: req.ResponsesParameters, + Fallbacks: fallbacks, + } + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + if req.Stream != nil && *req.Stream { + h.handleStreamingResponses(ctx, bifrostResponsesReq, bifrostCtx, cancel) + return + } + + defer cancel() // Ensure cleanup on function exit + + resp, bifrostErr := h.client.ResponsesRequest(*bifrostCtx, bifrostResponsesReq) + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + SendJSON(ctx, resp) +} + +// embeddings handles POST /v1/embeddings - Process embeddings requests +func (h *CompletionHandler) embeddings(ctx *fasthttp.RequestCtx) { + var req EmbeddingRequest + if err := sonic.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Create BifrostEmbeddingRequest directly using segregated structure + provider, modelName := schemas.ParseModelString(req.Model, "") + if provider == "" || modelName == "" { + SendError(ctx, fasthttp.StatusBadRequest, "model should be in provider/model format") + return + } + + // Parse fallbacks using helper function + fallbacks, err := parseFallbacks(req.Fallbacks) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + + if req.Input == nil || (req.Input.Text == nil && req.Input.Texts == nil && req.Input.Embedding == nil && req.Input.Embeddings == nil) { + SendError(ctx, fasthttp.StatusBadRequest, "Input is required for embeddings") + return + } + + // Extract extra params + if req.EmbeddingParameters == nil { + req.EmbeddingParameters = &schemas.EmbeddingParameters{} + } + + extraParams, err := extractExtraParams(ctx.PostBody(), embeddingParamsKnownFields) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to extract extra params: %v", err)) + } else { + req.EmbeddingParameters.ExtraParams = extraParams + } + + // Create segregated BifrostEmbeddingRequest + bifrostEmbeddingReq := &schemas.BifrostEmbeddingRequest{ + Provider: schemas.ModelProvider(provider), + Model: modelName, + Input: req.Input, + Params: req.EmbeddingParameters, + Fallbacks: fallbacks, + } + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + defer cancel() // Ensure cleanup on function exit + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + resp, bifrostErr := h.client.EmbeddingRequest(*bifrostCtx, bifrostEmbeddingReq) + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + SendJSON(ctx, resp) +} + +// speech handles POST /v1/audio/speech - Process speech completion requests +func (h *CompletionHandler) speech(ctx *fasthttp.RequestCtx) { + var req SpeechRequest + if err := sonic.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Create BifrostSpeechRequest directly using segregated structure + provider, modelName := schemas.ParseModelString(req.Model, "") + if provider == "" || modelName == "" { + SendError(ctx, fasthttp.StatusBadRequest, "model should be in provider/model format") + return + } + + // Parse fallbacks using helper function + fallbacks, err := parseFallbacks(req.Fallbacks) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + + if req.SpeechInput == nil || req.SpeechInput.Input == "" { + SendError(ctx, fasthttp.StatusBadRequest, "Input is required for speech completion") + return + } + + if req.VoiceConfig == nil || (req.VoiceConfig.Voice == nil && len(req.VoiceConfig.MultiVoiceConfig) == 0) { + SendError(ctx, fasthttp.StatusBadRequest, "Voice is required for speech completion") + return + } + + // Extract extra params + if req.SpeechParameters == nil { + req.SpeechParameters = &schemas.SpeechParameters{} + } + + // Extract extra params + if req.SpeechParameters == nil { + req.SpeechParameters = &schemas.SpeechParameters{} + } + + extraParams, err := extractExtraParams(ctx.PostBody(), speechParamsKnownFields) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to extract extra params: %v", err)) + } else { + req.SpeechParameters.ExtraParams = extraParams + } + + // Create segregated BifrostSpeechRequest + bifrostSpeechReq := &schemas.BifrostSpeechRequest{ + Provider: schemas.ModelProvider(provider), + Model: modelName, + Input: req.SpeechInput, + Params: req.SpeechParameters, + Fallbacks: fallbacks, + } + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + if req.StreamFormat != nil && *req.StreamFormat == "sse" { + h.handleStreamingSpeech(ctx, bifrostSpeechReq, bifrostCtx, cancel) + return + } + + defer cancel() // Ensure cleanup on function exit + + resp, bifrostErr := h.client.SpeechRequest(*bifrostCtx, bifrostSpeechReq) + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + if resp.Audio == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Speech response is missing audio data") + return + } + + ctx.Response.Header.Set("Content-Type", "audio/mpeg") + ctx.Response.Header.Set("Content-Disposition", "attachment; filename=speech.mp3") + ctx.Response.Header.Set("Content-Length", strconv.Itoa(len(resp.Audio))) + ctx.Response.SetBody(resp.Audio) +} + +// transcription handles POST /v1/audio/transcriptions - Process transcription requests +func (h *CompletionHandler) transcription(ctx *fasthttp.RequestCtx) { + // Parse multipart form + form, err := ctx.MultipartForm() + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Failed to parse multipart form: %v", err)) + return + } + + // Extract model (required) + modelValues := form.Value["model"] + if len(modelValues) == 0 || modelValues[0] == "" { + SendError(ctx, fasthttp.StatusBadRequest, "Model is required") + return + } + + provider, modelName := schemas.ParseModelString(modelValues[0], "") + if provider == "" || modelName == "" { + SendError(ctx, fasthttp.StatusBadRequest, "model should be in provider/model format") + return + } + + // Extract file (required) + fileHeaders := form.File["file"] + if len(fileHeaders) == 0 { + SendError(ctx, fasthttp.StatusBadRequest, "File is required") + return + } + + fileHeader := fileHeaders[0] + + // // Validate file size and format + // if err := h.validateAudioFile(fileHeader); err != nil { + // SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + // return + // } + + file, err := fileHeader.Open() + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Failed to open uploaded file: %v", err)) + return + } + defer file.Close() + + // Read file data + fileData := make([]byte, fileHeader.Size) + if _, err := file.Read(fileData); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to read uploaded file: %v", err)) + return + } + + // Create transcription input + transcriptionInput := &schemas.TranscriptionInput{ + File: fileData, + } + + // Create transcription parameters + transcriptionParams := &schemas.TranscriptionParameters{} + + // Extract optional parameters + if languageValues := form.Value["language"]; len(languageValues) > 0 && languageValues[0] != "" { + transcriptionParams.Language = &languageValues[0] + } + + if promptValues := form.Value["prompt"]; len(promptValues) > 0 && promptValues[0] != "" { + transcriptionParams.Prompt = &promptValues[0] + } + + if responseFormatValues := form.Value["response_format"]; len(responseFormatValues) > 0 && responseFormatValues[0] != "" { + transcriptionParams.ResponseFormat = &responseFormatValues[0] + } + + if transcriptionParams.ExtraParams == nil { + transcriptionParams.ExtraParams = make(map[string]interface{}) + } + + for key, value := range form.Value { + if len(value) > 0 && value[0] != "" && !transcriptionParamsKnownFields[key] { + transcriptionParams.ExtraParams[key] = value[0] + } + } + + // Create BifrostTranscriptionRequest + bifrostTranscriptionReq := &schemas.BifrostTranscriptionRequest{ + Model: modelName, + Provider: schemas.ModelProvider(provider), + Input: transcriptionInput, + Params: transcriptionParams, + } + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + if streamValues := form.Value["stream"]; len(streamValues) > 0 && streamValues[0] != "" { + stream := streamValues[0] + if stream == "true" { + h.handleStreamingTranscriptionRequest(ctx, bifrostTranscriptionReq, bifrostCtx, cancel) + return + } + } + + defer cancel() // Ensure cleanup on function exit + + // Make transcription request + resp, bifrostErr := h.client.TranscriptionRequest(*bifrostCtx, bifrostTranscriptionReq) + + // Handle response + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + SendJSON(ctx, resp) +} + +// handleStreamingTextCompletion handles streaming text completion requests using Server-Sent Events (SSE) +func (h *CompletionHandler) handleStreamingTextCompletion(ctx *fasthttp.RequestCtx, req *schemas.BifrostTextCompletionRequest, bifrostCtx *context.Context, cancel context.CancelFunc) { + // Use the cancellable context from ConvertToBifrostContext + // See router.go for detailed explanation of why we need a cancellable context + streamCtx := *bifrostCtx + + getStream := func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return h.client.TextCompletionStreamRequest(streamCtx, req) + } + + h.handleStreamingResponse(ctx, getStream, cancel) +} + +// handleStreamingChatCompletion handles streaming chat completion requests using Server-Sent Events (SSE) +func (h *CompletionHandler) handleStreamingChatCompletion(ctx *fasthttp.RequestCtx, req *schemas.BifrostChatRequest, bifrostCtx *context.Context, cancel context.CancelFunc) { + // Use the cancellable context from ConvertToBifrostContext + // See router.go for detailed explanation of why we need a cancellable context + streamCtx := *bifrostCtx + + getStream := func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return h.client.ChatCompletionStreamRequest(streamCtx, req) + } + + h.handleStreamingResponse(ctx, getStream, cancel) +} + +// handleStreamingResponses handles streaming responses requests using Server-Sent Events (SSE) +func (h *CompletionHandler) handleStreamingResponses(ctx *fasthttp.RequestCtx, req *schemas.BifrostResponsesRequest, bifrostCtx *context.Context, cancel context.CancelFunc) { + // Use the cancellable context from ConvertToBifrostContext + // See router.go for detailed explanation of why we need a cancellable context + streamCtx := *bifrostCtx + + getStream := func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return h.client.ResponsesStreamRequest(streamCtx, req) + } + + h.handleStreamingResponse(ctx, getStream, cancel) +} + +// handleStreamingSpeech handles streaming speech requests using Server-Sent Events (SSE) +func (h *CompletionHandler) handleStreamingSpeech(ctx *fasthttp.RequestCtx, req *schemas.BifrostSpeechRequest, bifrostCtx *context.Context, cancel context.CancelFunc) { + // Use the cancellable context from ConvertToBifrostContext + // See router.go for detailed explanation of why we need a cancellable context + streamCtx := *bifrostCtx + + getStream := func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return h.client.SpeechStreamRequest(streamCtx, req) + } + + h.handleStreamingResponse(ctx, getStream, cancel) +} + +// handleStreamingTranscriptionRequest handles streaming transcription requests using Server-Sent Events (SSE) +func (h *CompletionHandler) handleStreamingTranscriptionRequest(ctx *fasthttp.RequestCtx, req *schemas.BifrostTranscriptionRequest, bifrostCtx *context.Context, cancel context.CancelFunc) { + // Use the cancellable context from ConvertToBifrostContext + // See router.go for detailed explanation of why we need a cancellable context + streamCtx := *bifrostCtx + + getStream := func() (chan *schemas.BifrostStream, *schemas.BifrostError) { + return h.client.TranscriptionStreamRequest(streamCtx, req) + } + + h.handleStreamingResponse(ctx, getStream, cancel) +} + +// handleStreamingResponse is a generic function to handle streaming responses using Server-Sent Events (SSE) +// The cancel function is called ONLY when client disconnects are detected via write errors. +// Bifrost handles cleanup internally for normal completion and errors, so we only cancel +// upstream streams when write errors indicate the client has disconnected. +func (h *CompletionHandler) handleStreamingResponse(ctx *fasthttp.RequestCtx, getStream func() (chan *schemas.BifrostStream, *schemas.BifrostError), cancel context.CancelFunc) { + // Set SSE headers + ctx.SetContentType("text/event-stream") + ctx.Response.Header.Set("Cache-Control", "no-cache") + ctx.Response.Header.Set("Connection", "keep-alive") + ctx.Response.Header.Set("Access-Control-Allow-Origin", "*") + + // Get the streaming channel + stream, bifrostErr := getStream() + if bifrostErr != nil { + // Cancel stream context since we're not proceeding + cancel() + SendBifrostError(ctx, bifrostErr) + return + } + + var includeEventType bool + + // Use streaming response writer + ctx.Response.SetBodyStreamWriter(func(w *bufio.Writer) { + defer w.Flush() + + // Process streaming responses + for chunk := range stream { + if chunk == nil { + continue + } + + includeEventType = false + if chunk.BifrostResponsesStreamResponse != nil || + (chunk.BifrostError != nil && chunk.BifrostError.ExtraFields.RequestType == schemas.ResponsesStreamRequest) { + includeEventType = true + } + + // Convert response to JSON + chunkJSON, err := sonic.Marshal(chunk) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to marshal streaming response: %v", err)) + continue + } + + // Send as SSE data + if includeEventType { + // For responses API, use OpenAI-compatible format with event line + eventType := "" + if chunk.BifrostResponsesStreamResponse != nil { + eventType = string(chunk.BifrostResponsesStreamResponse.Type) + } else if chunk.BifrostError != nil { + eventType = string(schemas.ResponsesStreamResponseTypeError) + } + + if eventType != "" { + if _, err := fmt.Fprintf(w, "event: %s\n", eventType); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + + if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkJSON); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } else { + // For other APIs, use standard format + if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkJSON); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + + // Flush immediately to send the chunk + if err := w.Flush(); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + + if !includeEventType { + // Send the [DONE] marker to indicate the end of the stream (only for non-responses APIs) + if _, err := fmt.Fprint(w, "data: [DONE]\n\n"); err != nil { + logger.Warn(fmt.Sprintf("Failed to write SSE [DONE] marker: %v", err)) + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + // Note: OpenAI responses API doesn't use [DONE] marker, it ends when the stream closes + // Stream completed normally, Bifrost handles cleanup internally + }) +} + +// validateAudioFile checks if the file size and format are valid +func (h *CompletionHandler) validateAudioFile(fileHeader *multipart.FileHeader) error { + // Check file size + if fileHeader.Size > MaxFileSize { + return fmt.Errorf("file size exceeds maximum limit of %d MB", MaxFileSize/1024/1024) + } + + // Get file extension + ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) + + // Check file extension + validExtensions := map[string]bool{ + ".flac": true, + ".mp3": true, + ".mp4": true, + ".mpeg": true, + ".mpga": true, + ".m4a": true, + ".ogg": true, + ".wav": true, + ".webm": true, + } + + if !validExtensions[ext] { + return fmt.Errorf("unsupported file format: %s. Supported formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm", ext) + } + + // Open file to check MIME type + file, err := fileHeader.Open() + if err != nil { + return fmt.Errorf("failed to open file: %v", err) + } + defer file.Close() + + // Read first 512 bytes for MIME type detection + buffer := make([]byte, 512) + _, err = file.Read(buffer) + if err != nil && err != io.EOF { + return fmt.Errorf("failed to read file header: %v", err) + } + + // Check MIME type + mimeType := http.DetectContentType(buffer) + validMimeTypes := map[string]bool{ + // Primary MIME types + AudioMimeMP3: true, // Covers MP3, MPEG, MPGA + AudioMimeMP4: true, + AudioMimeM4A: true, + AudioMimeOGG: true, + AudioMimeWAV: true, + AudioMimeWEBM: true, + AudioMimeFLAC: true, + AudioMimeFLAC2: true, + + // Alternative MIME types + "audio/mpeg3": true, + "audio/x-wav": true, + "audio/vnd.wave": true, + "audio/x-mpeg": true, + "audio/x-mpeg3": true, + "audio/x-mpg": true, + "audio/x-mpegaudio": true, + } + + if !validMimeTypes[mimeType] { + return fmt.Errorf("invalid file type: %s. Supported audio formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm", mimeType) + } + + // Reset file pointer for subsequent reads + _, err = file.Seek(0, 0) + if err != nil { + return fmt.Errorf("failed to reset file pointer: %v", err) + } + + return nil +} diff --git a/transports/bifrost-http/handlers/init.go b/transports/bifrost-http/handlers/init.go new file mode 100644 index 000000000..7ffec4825 --- /dev/null +++ b/transports/bifrost-http/handlers/init.go @@ -0,0 +1,16 @@ +package handlers + +import "github.com/maximhq/bifrost/core/schemas" + +var version string +var logger schemas.Logger + +// SetLogger sets the logger for the application. +func SetLogger(l schemas.Logger) { + logger = l +} + +// SetVersion sets the version for the application. +func SetVersion(v string) { + version = v +} diff --git a/transports/bifrost-http/handlers/integrations.go b/transports/bifrost-http/handlers/integrations.go new file mode 100644 index 000000000..1576454d4 --- /dev/null +++ b/transports/bifrost-http/handlers/integrations.go @@ -0,0 +1,39 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains integration management handlers for AI provider integrations. +package handlers + +import ( + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/transports/bifrost-http/integrations" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" +) + +// IntegrationHandler manages HTTP requests for AI provider integrations +type IntegrationHandler struct { + extensions []integrations.ExtensionRouter +} + +// NewIntegrationHandler creates a new integration handler instance +func NewIntegrationHandler(client *bifrost.Bifrost, handlerStore lib.HandlerStore) *IntegrationHandler { + // Initialize all available integration routers + extensions := []integrations.ExtensionRouter{ + integrations.NewOpenAIRouter(client, handlerStore, logger), + integrations.NewAnthropicRouter(client, handlerStore, logger), + integrations.NewGenAIRouter(client, handlerStore, logger), + integrations.NewLiteLLMRouter(client, handlerStore, logger), + integrations.NewLangChainRouter(client, handlerStore, logger), + } + + return &IntegrationHandler{ + extensions: extensions, + } +} + +// RegisterRoutes registers all integration routes for AI provider compatibility endpoints +func (h *IntegrationHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + // Register routes for each integration extension + for _, extension := range h.extensions { + extension.RegisterRoutes(r, middlewares...) + } +} diff --git a/transports/bifrost-http/handlers/logging.go b/transports/bifrost-http/handlers/logging.go new file mode 100644 index 000000000..5e5d2c2f7 --- /dev/null +++ b/transports/bifrost-http/handlers/logging.go @@ -0,0 +1,347 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains logging-related handlers for log search, stats, and management. +package handlers + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/fasthttp/router" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/plugins/logging" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// LoggingHandler manages HTTP requests for logging operations +type LoggingHandler struct { + logManager logging.LogManager + redactedKeysManager RedactedKeysManager +} + +type RedactedKeysManager interface { + GetAllRedactedKeys(ctx context.Context, ids []string) []schemas.Key + GetAllRedactedVirtualKeys(ctx context.Context, ids []string) []tables.TableVirtualKey +} + +// NewLoggingHandler creates a new logging handler instance +func NewLoggingHandler(logManager logging.LogManager, redactedKeysManager RedactedKeysManager) *LoggingHandler { + return &LoggingHandler{ + logManager: logManager, + redactedKeysManager: redactedKeysManager, + } +} + +// RegisterRoutes registers all logging-related routes +func (h *LoggingHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + // Log retrieval with filtering, search, and pagination + r.GET("/api/logs", lib.ChainMiddlewares(h.getLogs, middlewares...)) + r.GET("/api/logs/dropped", lib.ChainMiddlewares(h.getDroppedRequests, middlewares...)) + r.GET("/api/logs/filterdata", lib.ChainMiddlewares(h.getAvailableFilterData, middlewares...)) +} + +// getLogs handles GET /api/logs - Get logs with filtering, search, and pagination via query parameters +func (h *LoggingHandler) getLogs(ctx *fasthttp.RequestCtx) { + // Parse query parameters into filters + filters := &logstore.SearchFilters{} + pagination := &logstore.PaginationOptions{} + + // Extract filters from query parameters + if providers := string(ctx.QueryArgs().Peek("providers")); providers != "" { + filters.Providers = parseCommaSeparated(providers) + } + if models := string(ctx.QueryArgs().Peek("models")); models != "" { + filters.Models = parseCommaSeparated(models) + } + if statuses := string(ctx.QueryArgs().Peek("status")); statuses != "" { + filters.Status = parseCommaSeparated(statuses) + } + if objects := string(ctx.QueryArgs().Peek("objects")); objects != "" { + filters.Objects = parseCommaSeparated(objects) + } + if selectedKeyIDs := string(ctx.QueryArgs().Peek("selected_key_ids")); selectedKeyIDs != "" { + filters.SelectedKeyIDs = parseCommaSeparated(selectedKeyIDs) + } + if virtualKeyIDs := string(ctx.QueryArgs().Peek("virtual_key_ids")); virtualKeyIDs != "" { + filters.VirtualKeyIDs = parseCommaSeparated(virtualKeyIDs) + } + if startTime := string(ctx.QueryArgs().Peek("start_time")); startTime != "" { + if t, err := time.Parse(time.RFC3339, startTime); err == nil { + filters.StartTime = &t + } + } + if endTime := string(ctx.QueryArgs().Peek("end_time")); endTime != "" { + if t, err := time.Parse(time.RFC3339, endTime); err == nil { + filters.EndTime = &t + } + } + if minLatency := string(ctx.QueryArgs().Peek("min_latency")); minLatency != "" { + if f, err := strconv.ParseFloat(minLatency, 64); err == nil { + filters.MinLatency = &f + } + } + if maxLatency := string(ctx.QueryArgs().Peek("max_latency")); maxLatency != "" { + if val, err := strconv.ParseFloat(maxLatency, 64); err == nil { + filters.MaxLatency = &val + } + } + if minTokens := string(ctx.QueryArgs().Peek("min_tokens")); minTokens != "" { + if val, err := strconv.Atoi(minTokens); err == nil { + filters.MinTokens = &val + } + } + if maxTokens := string(ctx.QueryArgs().Peek("max_tokens")); maxTokens != "" { + if val, err := strconv.Atoi(maxTokens); err == nil { + filters.MaxTokens = &val + } + } + if cost := string(ctx.QueryArgs().Peek("min_cost")); cost != "" { + if val, err := strconv.ParseFloat(cost, 64); err == nil { + filters.MinCost = &val + } + } + if maxCost := string(ctx.QueryArgs().Peek("max_cost")); maxCost != "" { + if val, err := strconv.ParseFloat(maxCost, 64); err == nil { + filters.MaxCost = &val + } + } + if contentSearch := string(ctx.QueryArgs().Peek("content_search")); contentSearch != "" { + filters.ContentSearch = contentSearch + } + + // Extract pagination parameters + pagination.Limit = 50 // Default limit + if limit := string(ctx.QueryArgs().Peek("limit")); limit != "" { + if i, err := strconv.Atoi(limit); err == nil { + if i <= 0 { + SendError(ctx, fasthttp.StatusBadRequest, "limit must be greater than 0") + return + } + if i > 1000 { + SendError(ctx, fasthttp.StatusBadRequest, "limit cannot exceed 1000") + return + } + pagination.Limit = i + } + } + + pagination.Offset = 0 // Default offset + if offset := string(ctx.QueryArgs().Peek("offset")); offset != "" { + if i, err := strconv.Atoi(offset); err == nil { + if i < 0 { + SendError(ctx, fasthttp.StatusBadRequest, "offset cannot be negative") + return + } + pagination.Offset = i + } + } + + // Sort parameters + pagination.SortBy = "timestamp" // Default sort field + if sortBy := string(ctx.QueryArgs().Peek("sort_by")); sortBy != "" { + if sortBy == "timestamp" || sortBy == "latency" || sortBy == "tokens" || sortBy == "cost" { + pagination.SortBy = sortBy + } + } + + pagination.Order = "desc" // Default sort order + if order := string(ctx.QueryArgs().Peek("order")); order != "" { + if order == "asc" || order == "desc" { + pagination.Order = order + } + } + + result, err := h.logManager.Search(ctx, filters, pagination) + if err != nil { + logger.Error("failed to search logs: %v", err) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Search failed: %v", err)) + return + } + + selectedKeyIDs := make(map[string]struct{}) + virtualKeyIDs := make(map[string]struct{}) + for _, log := range result.Logs { + if log.SelectedKeyID != "" { + selectedKeyIDs[log.SelectedKeyID] = struct{}{} + } + if log.VirtualKeyID != nil && *log.VirtualKeyID != "" { + virtualKeyIDs[*log.VirtualKeyID] = struct{}{} + } + } + + toSlice := func(m map[string]struct{}) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for id := range m { + out = append(out, id) + } + return out + } + + redactedKeys := h.redactedKeysManager.GetAllRedactedKeys(ctx, toSlice(selectedKeyIDs)) + redactedVirtualKeys := h.redactedKeysManager.GetAllRedactedVirtualKeys(ctx, toSlice(virtualKeyIDs)) + + // Add selected key and virtual key to the result + for i, log := range result.Logs { + if log.SelectedKeyID != "" && log.SelectedKeyName != "" { + result.Logs[i].SelectedKey = findRedactedKey(redactedKeys, log.SelectedKeyID, log.SelectedKeyName) + } + if log.VirtualKeyID != nil && log.VirtualKeyName != nil && *log.VirtualKeyID != "" && *log.VirtualKeyName != "" { + result.Logs[i].VirtualKey = findRedactedVirtualKey(redactedVirtualKeys, *log.VirtualKeyID, *log.VirtualKeyName) + } + } + + SendJSON(ctx, result) +} + +// getDroppedRequests handles GET /api/logs/dropped - Get the number of dropped requests +func (h *LoggingHandler) getDroppedRequests(ctx *fasthttp.RequestCtx) { + droppedRequests := h.logManager.GetDroppedRequests(ctx) + SendJSON(ctx, map[string]int64{"dropped_requests": droppedRequests}) +} + +// getAvailableFilterData handles GET /api/logs/filterdata - Get all unique filter data from logs +func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) { + models := h.logManager.GetAvailableModels(ctx) + selectedKeys := h.logManager.GetAvailableSelectedKeys(ctx) + virtualKeys := h.logManager.GetAvailableVirtualKeys(ctx) + + // Extract IDs for redaction lookup + selectedKeyIDs := make([]string, len(selectedKeys)) + for i, key := range selectedKeys { + selectedKeyIDs[i] = key.ID + } + virtualKeyIDs := make([]string, len(virtualKeys)) + for i, key := range virtualKeys { + virtualKeyIDs[i] = key.ID + } + + redactedSelectedKeys := make(map[string]schemas.Key) + for _, selectedKey := range h.redactedKeysManager.GetAllRedactedKeys(ctx, selectedKeyIDs) { + redactedSelectedKeys[selectedKey.ID] = selectedKey + } + redactedVirtualKeys := make(map[string]tables.TableVirtualKey) + for _, virtualKey := range h.redactedKeysManager.GetAllRedactedVirtualKeys(ctx, virtualKeyIDs) { + redactedVirtualKeys[virtualKey.ID] = virtualKey + } + + // Check if all selected key ids are present in the redacted selected keys (will not be present in case a key is deleted, but we still need to show its filter) + for _, selectedKey := range selectedKeys { + if _, ok := redactedSelectedKeys[selectedKey.ID]; !ok { + // Create a new key struct directly since we know it doesn't exist + redactedSelectedKeys[selectedKey.ID] = schemas.Key{ + ID: selectedKey.ID, + Name: selectedKey.Name + " (deleted)", + } + } + } + + // Check if all virtual key ids are present in the redacted virtual keys (will not be present in case a virtual key is deleted, but we still need to show its filter) + for _, virtualKey := range virtualKeys { + if _, ok := redactedVirtualKeys[virtualKey.ID]; !ok { + // Create a new virtual key struct directly since we know it doesn't exist + redactedVirtualKeys[virtualKey.ID] = tables.TableVirtualKey{ + ID: virtualKey.ID, + Name: virtualKey.Name + " (deleted)", + } + } + } + + // Convert maps to arrays for frontend consumption + selectedKeysArray := make([]schemas.Key, 0, len(redactedSelectedKeys)) + for _, key := range redactedSelectedKeys { + selectedKeysArray = append(selectedKeysArray, key) + } + + virtualKeysArray := make([]tables.TableVirtualKey, 0, len(redactedVirtualKeys)) + for _, key := range redactedVirtualKeys { + virtualKeysArray = append(virtualKeysArray, key) + } + + SendJSON(ctx, map[string]interface{}{"models": models, "selected_keys": selectedKeysArray, "virtual_keys": virtualKeysArray}) +} + +// Helper functions + +func findRedactedKey(redactedKeys []schemas.Key, id string, name string) *schemas.Key { + if len(redactedKeys) == 0 { + return &schemas.Key{ + ID: id, + Name: func() string { + if name != "" { + return name + " (deleted)" + } else { + return "" + } + }(), + } + } + for _, key := range redactedKeys { + if key.ID == id { + return &key + } + } + return &schemas.Key{ + ID: id, + Name: func() string { + if name != "" { + return name + " (deleted)" + } else { + return "" + } + }(), + } +} + +func findRedactedVirtualKey(redactedVirtualKeys []tables.TableVirtualKey, id string, name string) *tables.TableVirtualKey { + if len(redactedVirtualKeys) == 0 { + return &tables.TableVirtualKey{ + ID: id, + Name: func() string { + if name != "" { + return name + " (deleted)" + } else { + return "" + } + }(), + } + } + for _, virtualKey := range redactedVirtualKeys { + if virtualKey.ID == id { + return &virtualKey + } + } + return &tables.TableVirtualKey{ + ID: id, + Name: func() string { + if name != "" { + return name + " (deleted)" + } else { + return "" + } + }(), + } +} + +// parseCommaSeparated splits a comma-separated string into a slice +func parseCommaSeparated(s string) []string { + if s == "" { + return nil + } + + var result []string + for _, item := range strings.Split(s, ",") { + if trimmed := strings.TrimSpace(item); trimmed != "" { + result = append(result, trimmed) + } + } + + return result +} diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go new file mode 100644 index 000000000..befdbaa1c --- /dev/null +++ b/transports/bifrost-http/handlers/mcp.go @@ -0,0 +1,278 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains MCP (Model Context Protocol) tool execution handlers. +package handlers + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// MCPHandler manages HTTP requests for MCP tool operations +type MCPHandler struct { + client *bifrost.Bifrost + store *lib.Config +} + +// NewMCPHandler creates a new MCP handler instance +func NewMCPHandler(client *bifrost.Bifrost, store *lib.Config) *MCPHandler { + return &MCPHandler{ + client: client, + store: store, + } +} + +// RegisterRoutes registers all MCP-related routes +func (h *MCPHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + // MCP tool execution endpoint + r.POST("/v1/mcp/tool/execute", lib.ChainMiddlewares(h.executeTool, middlewares...)) + r.GET("/api/mcp/clients", lib.ChainMiddlewares(h.getMCPClients, middlewares...)) + r.POST("/api/mcp/client", lib.ChainMiddlewares(h.addMCPClient, middlewares...)) + r.PUT("/api/mcp/client/{id}", lib.ChainMiddlewares(h.editMCPClient, middlewares...)) + r.DELETE("/api/mcp/client/{id}", lib.ChainMiddlewares(h.removeMCPClient, middlewares...)) + r.POST("/api/mcp/client/{id}/reconnect", lib.ChainMiddlewares(h.reconnectMCPClient, middlewares...)) +} + +// executeTool handles POST /v1/mcp/tool/execute - Execute MCP tool +func (h *MCPHandler) executeTool(ctx *fasthttp.RequestCtx) { + var req schemas.ChatAssistantMessageToolCall + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Validate required fields + if req.Function.Name == nil || *req.Function.Name == "" { + SendError(ctx, fasthttp.StatusBadRequest, "Tool function name is required") + return + } + + // Convert context + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, false) + defer cancel() // Ensure cleanup on function exit + if bifrostCtx == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") + return + } + + // Execute MCP tool + resp, bifrostErr := h.client.ExecuteMCPTool(*bifrostCtx, req) + if bifrostErr != nil { + SendBifrostError(ctx, bifrostErr) + return + } + + // Send successful response + SendJSON(ctx, resp) +} + +// getMCPClients handles GET /api/mcp/clients - Get all MCP clients +func (h *MCPHandler) getMCPClients(ctx *fasthttp.RequestCtx) { + // Get clients from store config + configsInStore := h.store.MCPConfig + if configsInStore == nil { + SendJSON(ctx, []schemas.MCPClient{}) + return + } + + // Get actual connected clients from Bifrost + clientsInBifrost, err := h.client.GetMCPClients() + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get MCP clients from Bifrost: %v", err)) + return + } + + // Create a map of connected clients for quick lookup + connectedClientsMap := make(map[string]schemas.MCPClient) + for _, client := range clientsInBifrost { + connectedClientsMap[client.Config.ID] = client + } + + // Build the final client list, including errored clients + clients := make([]schemas.MCPClient, 0, len(configsInStore.ClientConfigs)) + + for _, configClient := range configsInStore.ClientConfigs { + if connectedClient, exists := connectedClientsMap[configClient.ID]; exists { + // Sort tools alphabetically by name + sortedTools := make([]schemas.ChatToolFunction, len(connectedClient.Tools)) + copy(sortedTools, connectedClient.Tools) + sort.Slice(sortedTools, func(i, j int) bool { + return sortedTools[i].Name < sortedTools[j].Name + }) + + clients = append(clients, schemas.MCPClient{ + Config: h.store.RedactMCPClientConfig(connectedClient.Config), + Tools: sortedTools, + State: connectedClient.State, + }) + } else { + // Client is in config but not connected, mark as errored + clients = append(clients, schemas.MCPClient{ + Config: h.store.RedactMCPClientConfig(configClient), + Tools: []schemas.ChatToolFunction{}, // No tools available since connection failed + State: schemas.MCPConnectionStateError, + }) + } + } + + SendJSON(ctx, clients) +} + +// reconnectMCPClient handles POST /api/mcp/client/{id}/reconnect - Reconnect an MCP client +func (h *MCPHandler) reconnectMCPClient(ctx *fasthttp.RequestCtx) { + id, err := getIDFromCtx(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid id: %v", err)) + return + } + + // Check if client is registered in Bifrost (can be not registered if client initialization failed) + if clients, err := h.client.GetMCPClients(); err == nil && len(clients) > 0 { + for _, client := range clients { + if client.Config.ID == id { + if err := h.client.ReconnectMCPClient(id); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to reconnect MCP client: %v", err)) + return + } else { + SendJSON(ctx, map[string]any{ + "status": "success", + "message": "MCP client reconnected successfully", + }) + return + } + } + } + } + + // Config exists in store, but not in Bifrost (can happen if client initialization failed) + clientConfig, err := h.store.GetMCPClient(id) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get MCP client config: %v", err)) + return + } + + if err := h.client.AddMCPClient(*clientConfig); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to add MCP client: %v", err)) + return + } + + SendJSON(ctx, map[string]any{ + "status": "success", + "message": "MCP client reconnected successfully", + }) +} + +// addMCPClient handles POST /api/mcp/client - Add a new MCP client +func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { + var req schemas.MCPClientConfig + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + if err := validateToolsToExecute(req.ToolsToExecute); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid tools_to_execute: %v", err)) + return + } + + if err := h.store.AddMCPClient(ctx, req); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to add MCP client: %v", err)) + return + } + + SendJSON(ctx, map[string]any{ + "status": "success", + "message": "MCP client added successfully", + }) +} + +// editMCPClient handles PUT /api/mcp/client/{id} - Edit MCP client +func (h *MCPHandler) editMCPClient(ctx *fasthttp.RequestCtx) { + id, err := getIDFromCtx(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid id: %v", err)) + return + } + + var req schemas.MCPClientConfig + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Validate tools_to_execute + if err := validateToolsToExecute(req.ToolsToExecute); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid tools_to_execute: %v", err)) + return + } + + if err := h.store.EditMCPClient(ctx, id, req); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to edit MCP client: %v", err)) + return + } + + SendJSON(ctx, map[string]any{ + "status": "success", + "message": "MCP client edited successfully", + }) +} + +// removeMCPClient handles DELETE /api/mcp/client/{id} - Remove an MCP client +func (h *MCPHandler) removeMCPClient(ctx *fasthttp.RequestCtx) { + id, err := getIDFromCtx(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid id: %v", err)) + return + } + + if err := h.store.RemoveMCPClient(ctx, id); err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to remove MCP client: %v", err)) + return + } + + SendJSON(ctx, map[string]any{ + "status": "success", + "message": "MCP client removed successfully", + }) +} + +func getIDFromCtx(ctx *fasthttp.RequestCtx) (string, error) { + idValue := ctx.UserValue("id") + if idValue == nil { + return "", fmt.Errorf("missing id parameter") + } + idStr, ok := idValue.(string) + if !ok { + return "", fmt.Errorf("invalid id parameter type") + } + + return idStr, nil +} + +func validateToolsToExecute(toolsToExecute []string) error { + if len(toolsToExecute) > 0 { + // Check if wildcard "*" is combined with other tool names + hasWildcard := slices.Contains(toolsToExecute, "*") + if hasWildcard && len(toolsToExecute) > 1 { + return fmt.Errorf("invalid tools_to_execute: wildcard '*' cannot be combined with other tool names") + } + + // Check for duplicate entries + seen := make(map[string]bool) + for _, tool := range toolsToExecute { + if seen[tool] { + return fmt.Errorf("invalid tools_to_execute: duplicate tool name '%s'", tool) + } + seen[tool] = true + } + } + + return nil +} diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go new file mode 100644 index 000000000..806a4045c --- /dev/null +++ b/transports/bifrost-http/handlers/middlewares.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/framework/encrypt" + "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// CorsMiddleware handles CORS headers for localhost and configured allowed origins +func CorsMiddleware(config *lib.Config) lib.BifrostHTTPMiddleware { + return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + origin := string(ctx.Request.Header.Peek("Origin")) + allowed := IsOriginAllowed(origin, config.ClientConfig.AllowedOrigins) + // Check if origin is allowed (localhost always allowed + configured origins) + if allowed { + ctx.Response.Header.Set("Access-Control-Allow-Origin", origin) + ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + ctx.Response.Header.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + ctx.Response.Header.Set("Access-Control-Allow-Credentials", "true") + ctx.Response.Header.Set("Access-Control-Max-Age", "86400") + } + // Handle preflight OPTIONS requests + if string(ctx.Method()) == "OPTIONS" { + if allowed { + ctx.SetStatusCode(fasthttp.StatusOK) + } else { + ctx.SetStatusCode(fasthttp.StatusForbidden) + } + return + } + next(ctx) + } + } +} + +// TransportInterceptorMiddleware collects all plugin interceptors and calls them one by one +func TransportInterceptorMiddleware(config *lib.Config) lib.BifrostHTTPMiddleware { + return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + // Get plugins from config - lock-free read + plugins := config.GetLoadedPlugins() + if len(plugins) == 0 { + next(ctx) + return + } + + // If governance plugin is not loaded, skip interception + hasGovernance := false + for _, p := range plugins { + if p.GetName() == governance.PluginName { + hasGovernance = true + break + } + } + if !hasGovernance { + next(ctx) + return + } + + // Parse headers + headers := make(map[string]string) + originalHeaderNames := make([]string, 0, 16) + ctx.Request.Header.All()(func(key, value []byte) bool { + name := string(key) + headers[name] = string(value) + originalHeaderNames = append(originalHeaderNames, name) + + return true + }) + + // Unmarshal request body + requestBody := make(map[string]any) + bodyBytes := ctx.Request.Body() + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, &requestBody); err != nil { + // If body is not valid JSON, log warning and continue without interception + logger.Warn(fmt.Sprintf("TransportInterceptor: Failed to unmarshal request body: %v, skipping interceptor", err)) + next(ctx) + return + } + } + for _, plugin := range plugins { + // Call TransportInterceptor on all plugins + pluginCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + modifiedHeaders, modifiedBody, err := plugin.TransportInterceptor(&pluginCtx, string(ctx.Request.URI().RequestURI()), headers, requestBody) + cancel() + if err != nil { + logger.Warn(fmt.Sprintf("TransportInterceptor: Plugin '%s' returned error: %v", plugin.GetName(), err)) + // Continue with unmodified headers/body + continue + } + // Update headers and body with modifications + if modifiedHeaders != nil { + headers = modifiedHeaders + } + if modifiedBody != nil { + requestBody = modifiedBody + } + } + + // Marshal the body back to JSON + updatedBody, err := json.Marshal(requestBody) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("TransportInterceptor: Failed to marshal request body: %v", err)) + return + } + ctx.Request.SetBody(updatedBody) + + // Remove headers that were present originally but removed by plugins + for _, name := range originalHeaderNames { + if _, exists := headers[name]; !exists { + ctx.Request.Header.Del(name) + } + } + + // Set modified headers back on the request + for key, value := range headers { + ctx.Request.Header.Set(key, value) + } + + next(ctx) + } + } +} + +// validateSession checks if a session token is valid +func validateSession(ctx *fasthttp.RequestCtx, store configstore.ConfigStore, token string) bool { + session, err := store.GetSession(context.Background(), token) + if err != nil || session == nil { + return false + } + if session.ExpiresAt.Before(time.Now()) { + return false + } + return true +} + +// AuthMiddleware if authConfig is set, it will verify the auth cookie in the header +// This uses basic auth style username + password based authentication +// No session tracking is used, so this is not suitable for production environments +// These basicauth routes are only used for the dashboard and API routes +func AuthMiddleware(store configstore.ConfigStore) lib.BifrostHTTPMiddleware { + if store == nil { + logger.Info("auth middleware is disabled because store is not present") + return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return next + } + } + authConfig, err := store.GetAuthConfig(context.Background()) + if err != nil || authConfig == nil || !authConfig.IsEnabled { + return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return next + } + } + whitelistedRoutes := []string{ + "/api/session/is-auth-enabled", + "/api/session/login", + "/api/session/logout", + "/health", + } + return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + // We skip authorization for the login route + if slices.Contains(whitelistedRoutes, string(ctx.Request.URI().RequestURI())) { + next(ctx) + return + } + // Get the authorization header + authorization := string(ctx.Request.Header.Peek("Authorization")) + if authorization == "" { + // Check if its a websocket 101 upgrade request + if string(ctx.Request.Header.Peek("Upgrade")) == "websocket" { + // Here we get the token from query params + token := string(ctx.Request.URI().QueryArgs().Peek("token")) + if token == "" { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Verify the session + if !validateSession(ctx, store, token) { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Continue with the next handler + next(ctx) + return + } + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Split the authorization header into the scheme and the token + scheme, token, ok := strings.Cut(authorization, " ") + if !ok { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Checking basic auth for inference calls + if scheme == "Basic" { + // Decode the base64 token + decodedBytes, err := base64.StdEncoding.DecodeString(token) + if err != nil { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Split the decoded token into the username and password + username, password, ok := strings.Cut(string(decodedBytes), ":") + if !ok { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Verify the username and password + if username != authConfig.AdminUserName { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + compare, err := encrypt.CompareHash(authConfig.AdminPassword, password) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to compare password: %v", err)) + return + } + if !compare { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Continue with the next handler + next(ctx) + return + } + // Checking bearer auth for dashboard calls + if scheme == "Bearer" { + // Verify the session + if !validateSession(ctx, store, token) { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + // Continue with the next handler + next(ctx) + return + } + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + } + } +} diff --git a/transports/bifrost-http/handlers/middlewares_test.go b/transports/bifrost-http/handlers/middlewares_test.go new file mode 100644 index 000000000..855449ec9 --- /dev/null +++ b/transports/bifrost-http/handlers/middlewares_test.go @@ -0,0 +1,513 @@ +package handlers + +import ( + "testing" + + "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// TestCorsMiddleware_LocalhostOrigins tests that localhost origins are always allowed +func TestCorsMiddleware_LocalhostOrigins(t *testing.T) { + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{}, + }, + } + + localhostOrigins := []string{ + "http://localhost:3000", + "https://localhost:3000", + "http://127.0.0.1:8080", + "http://0.0.0.0:5000", + "https://127.0.0.1:3000", + } + + for _, origin := range localhostOrigins { + t.Run(origin, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Origin", origin) + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check CORS headers are set + if string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != origin { + t.Errorf("Expected Access-Control-Allow-Origin to be %s, got %s", origin, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin"))) + } + if string(ctx.Response.Header.Peek("Access-Control-Allow-Methods")) != "GET, POST, PUT, DELETE, OPTIONS" { + t.Errorf("Access-Control-Allow-Methods header not set correctly") + } + if string(ctx.Response.Header.Peek("Access-Control-Allow-Headers")) != "Content-Type, Authorization, X-Requested-With" { + t.Errorf("Access-Control-Allow-Headers header not set correctly") + } + if string(ctx.Response.Header.Peek("Access-Control-Allow-Credentials")) != "true" { + t.Errorf("Access-Control-Allow-Credentials header not set correctly") + } + if string(ctx.Response.Header.Peek("Access-Control-Max-Age")) != "86400" { + t.Errorf("Access-Control-Max-Age header not set correctly") + } + + // Check next handler was called + if !nextCalled { + t.Error("Next handler was not called") + } + }) + } +} + +// TestCorsMiddleware_ConfiguredOrigins tests that configured allowed origins work +func TestCorsMiddleware_ConfiguredOrigins(t *testing.T) { + allowedOrigin := "https://example.com" + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{allowedOrigin}, + }, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Origin", allowedOrigin) + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check CORS headers are set + if string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != allowedOrigin { + t.Errorf("Expected Access-Control-Allow-Origin to be %s, got %s", allowedOrigin, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin"))) + } + + // Check next handler was called + if !nextCalled { + t.Error("Next handler was not called") + } +} + +// TestCorsMiddleware_NonAllowedOrigins tests that non-allowed origins don't get CORS headers +func TestCorsMiddleware_NonAllowedOrigins(t *testing.T) { + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{"https://allowed.com"}, + }, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Origin", "https://malicious.com") + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check CORS headers are NOT set + if len(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != 0 { + t.Error("Access-Control-Allow-Origin header should not be set for non-allowed origin") + } + + // Check next handler was still called for non-OPTIONS requests + if !nextCalled { + t.Error("Next handler was not called") + } +} + +// TestCorsMiddleware_PreflightAllowedOrigin tests OPTIONS preflight requests for allowed origins +func TestCorsMiddleware_PreflightAllowedOrigin(t *testing.T) { + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{"https://example.com"}, + }, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod("OPTIONS") + ctx.Request.Header.Set("Origin", "https://example.com") + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check status code is 200 OK + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Errorf("Expected status code %d for allowed origin preflight, got %d", fasthttp.StatusOK, ctx.Response.StatusCode()) + } + + // Check CORS headers are set + if string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != "https://example.com" { + t.Error("Access-Control-Allow-Origin header not set correctly for allowed origin preflight") + } + + // Check next handler was NOT called for OPTIONS requests + if nextCalled { + t.Error("Next handler should not be called for OPTIONS preflight requests") + } +} + +// TestCorsMiddleware_PreflightNonAllowedOrigin tests OPTIONS preflight requests for non-allowed origins +func TestCorsMiddleware_PreflightNonAllowedOrigin(t *testing.T) { + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{"https://allowed.com"}, + }, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod("OPTIONS") + ctx.Request.Header.Set("Origin", "https://malicious.com") + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check status code is 403 Forbidden + if ctx.Response.StatusCode() != fasthttp.StatusForbidden { + t.Errorf("Expected status code %d for non-allowed origin preflight, got %d", fasthttp.StatusForbidden, ctx.Response.StatusCode()) + } + + // Check CORS headers are NOT set + if len(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != 0 { + t.Error("Access-Control-Allow-Origin header should not be set for non-allowed origin preflight") + } + + // Check next handler was NOT called for OPTIONS requests + if nextCalled { + t.Error("Next handler should not be called for OPTIONS preflight requests") + } +} + +// TestCorsMiddleware_PreflightLocalhost tests OPTIONS preflight requests for localhost +func TestCorsMiddleware_PreflightLocalhost(t *testing.T) { + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{}, + }, + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod("OPTIONS") + ctx.Request.Header.Set("Origin", "http://localhost:3000") + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check status code is 200 OK + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Errorf("Expected status code %d for localhost preflight, got %d", fasthttp.StatusOK, ctx.Response.StatusCode()) + } + + // Check CORS headers are set + if string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != "http://localhost:3000" { + t.Error("Access-Control-Allow-Origin header not set correctly for localhost preflight") + } + + // Check next handler was NOT called for OPTIONS requests + if nextCalled { + t.Error("Next handler should not be called for OPTIONS preflight requests") + } +} + +// TestCorsMiddleware_NoOriginHeader tests behavior when no Origin header is present +func TestCorsMiddleware_NoOriginHeader(t *testing.T) { + config := &lib.Config{ + ClientConfig: configstore.ClientConfig{ + AllowedOrigins: []string{}, + }, + } + + ctx := &fasthttp.RequestCtx{} + // No Origin header set + + nextCalled := false + next := func(ctx *fasthttp.RequestCtx) { + nextCalled = true + } + + middleware := CorsMiddleware(config) + handler := middleware(next) + handler(ctx) + + // Check CORS headers are NOT set when no origin is present + if len(ctx.Response.Header.Peek("Access-Control-Allow-Origin")) != 0 { + t.Error("Access-Control-Allow-Origin header should not be set when no Origin header is present") + } + + // Check next handler was called + if !nextCalled { + t.Error("Next handler was not called") + } +} + +// Testlib.ChainMiddlewares_NoMiddlewares tests chaining with no middlewares +func TestChainMiddlewares_NoMiddlewares(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + handlerCalled := false + + handler := func(ctx *fasthttp.RequestCtx) { + handlerCalled = true + } + + chained := lib.ChainMiddlewares(handler) + chained(ctx) + + if !handlerCalled { + t.Error("Handler was not called when no middlewares are present") + } +} + +// Testlib.ChainMiddlewares_SingleMiddleware tests chaining with a single middleware +func TestChainMiddlewares_SingleMiddleware(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + middlewareCalled := false + handlerCalled := false + + middleware := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + middlewareCalled = true + next(ctx) + } + }) + + handler := func(ctx *fasthttp.RequestCtx) { + handlerCalled = true + } + + chained := lib.ChainMiddlewares(handler, middleware) + chained(ctx) + + if !middlewareCalled { + t.Error("Middleware was not called") + } + if !handlerCalled { + t.Error("Handler was not called") + } +} + +// Testlib.ChainMiddlewares_MultipleMiddlewares tests chaining with multiple middlewares +func TestChainMiddlewares_MultipleMiddlewares(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + executionOrder := []int{} + + middleware1 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 1) + next(ctx) + } + }) + + middleware2 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 2) + next(ctx) + } + }) + + middleware3 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 3) + next(ctx) + } + }) + + handler := func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 4) + } + + chained := lib.ChainMiddlewares(handler, middleware1, middleware2, middleware3) + chained(ctx) + + // Check execution order: middlewares should execute in order, then handler + expectedOrder := []int{1, 2, 3, 4} + if len(executionOrder) != len(expectedOrder) { + t.Errorf("Expected %d function calls, got %d", len(expectedOrder), len(executionOrder)) + } + + for i, expected := range expectedOrder { + if i >= len(executionOrder) || executionOrder[i] != expected { + t.Errorf("Expected execution order %v, got %v", expectedOrder, executionOrder) + break + } + } +} + +// Testlib.ChainMiddlewares_MiddlewareCanModifyContext tests that middlewares can modify the context +func TestChainMiddlewares_MiddlewareCanModifyContext(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + + middleware := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + ctx.SetUserValue("test-key", "test-value") + next(ctx) + } + }) + + handler := func(ctx *fasthttp.RequestCtx) { + value := ctx.UserValue("test-key") + if value == nil { + t.Error("Handler did not receive modified context from middleware") + } else if value.(string) != "test-value" { + t.Errorf("Expected user value to be 'test-value', got '%s'", value.(string)) + } + } + + chained := lib.ChainMiddlewares(handler, middleware) + chained(ctx) +} + +// Testlib.ChainMiddlewares_ShortCircuit tests that when a middleware writes a response +// and does not call next, subsequent middlewares and handler do not execute. +func TestChainMiddlewares_ShortCircuit(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + executionOrder := []int{} + + // First middleware - writes response and short-circuits by not calling next + middleware1 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 1) + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + ctx.SetBodyString("Unauthorized") + // Not calling next(ctx) to short-circuit + } + }) + + // Second middleware - should NOT execute when middleware1 short-circuits + middleware2 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 2) + next(ctx) + } + }) + + // Third middleware - should NOT execute when middleware1 short-circuits + middleware3 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 3) + next(ctx) + } + }) + + // Handler - should NOT execute when middleware1 short-circuits + handler := func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 4) + ctx.SetStatusCode(fasthttp.StatusOK) + ctx.SetBodyString("Success") + } + + chained := lib.ChainMiddlewares(handler, middleware1, middleware2, middleware3) + chained(ctx) + + // Verify only middleware1 executed + expectedOrder := []int{1} + if len(executionOrder) != len(expectedOrder) { + t.Errorf("Expected %d function calls, got %d", len(expectedOrder), len(executionOrder)) + } + + for i, expected := range expectedOrder { + if i >= len(executionOrder) || executionOrder[i] != expected { + t.Errorf("Expected execution order %v, got %v", expectedOrder, executionOrder) + break + } + } + + // The middleware's response should be preserved (not overwritten) + if ctx.Response.StatusCode() != fasthttp.StatusUnauthorized { + t.Errorf("Expected status code %d, got %d", fasthttp.StatusUnauthorized, ctx.Response.StatusCode()) + } + if string(ctx.Response.Body()) != "Unauthorized" { + t.Errorf("Expected body 'Unauthorized', got '%s'", string(ctx.Response.Body())) + } +} + +// Testlib.ChainMiddlewares_ShortCircuitMiddlePosition tests that middleware in the middle +// can short-circuit, preventing later middlewares and handler from executing. +func TestChainMiddlewares_ShortCircuitMiddlePosition(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + executionOrder := []int{} + + // First middleware - executes and calls next + middleware1 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 1) + next(ctx) + } + }) + + // Second middleware - writes response and short-circuits + middleware2 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 2) + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + ctx.SetBodyString("Unauthorized") + // Not calling next(ctx) to short-circuit + } + }) + + // Third middleware - should NOT execute + middleware3 := lib.BifrostHTTPMiddleware(func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 3) + next(ctx) + } + }) + + // Handler - should NOT execute + handler := func(ctx *fasthttp.RequestCtx) { + executionOrder = append(executionOrder, 4) + ctx.SetStatusCode(fasthttp.StatusOK) + ctx.SetBodyString("Success") + } + + chained := lib.ChainMiddlewares(handler, middleware1, middleware2, middleware3) + chained(ctx) + + // Verify only middleware1 and middleware2 executed + expectedOrder := []int{1, 2} + if len(executionOrder) != len(expectedOrder) { + t.Errorf("Expected %d function calls, got %d", len(expectedOrder), len(executionOrder)) + } + + for i, expected := range expectedOrder { + if i >= len(executionOrder) || executionOrder[i] != expected { + t.Errorf("Expected execution order %v, got %v", expectedOrder, executionOrder) + break + } + } + + // The middleware2's response should be preserved + if ctx.Response.StatusCode() != fasthttp.StatusUnauthorized { + t.Errorf("Expected status code %d, got %d", fasthttp.StatusUnauthorized, ctx.Response.StatusCode()) + } + if string(ctx.Response.Body()) != "Unauthorized" { + t.Errorf("Expected body 'Unauthorized', got '%s'", string(ctx.Response.Body())) + } +} diff --git a/transports/bifrost-http/handlers/plugins.go b/transports/bifrost-http/handlers/plugins.go new file mode 100644 index 000000000..033e64040 --- /dev/null +++ b/transports/bifrost-http/handlers/plugins.go @@ -0,0 +1,439 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/fasthttp/router" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" + "gorm.io/gorm" +) + +type PluginsLoader interface { + ReloadPlugin(ctx context.Context, name string, path *string, pluginConfig any) error + RemovePlugin(ctx context.Context, name string) error + GetPluginStatus() []schemas.PluginStatus +} + +// PluginsHandler is the handler for the plugins API +type PluginsHandler struct { + configStore configstore.ConfigStore + pluginsLoader PluginsLoader +} + +// NewPluginsHandler creates a new PluginsHandler +func NewPluginsHandler(pluginsLoader PluginsLoader, configStore configstore.ConfigStore) *PluginsHandler { + return &PluginsHandler{ + pluginsLoader: pluginsLoader, + configStore: configStore, + } +} + +// CreatePluginRequest is the request body for creating a plugin +type CreatePluginRequest struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config map[string]any `json:"config"` + Path *string `json:"path"` +} + +// UpdatePluginRequest is the request body for updating a plugin +type UpdatePluginRequest struct { + Enabled bool `json:"enabled"` + Path *string `json:"path"` + Config map[string]any `json:"config"` +} + +// RegisterRoutes registers the routes for the PluginsHandler +func (h *PluginsHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + r.GET("/api/plugins", lib.ChainMiddlewares(h.getPlugins, middlewares...)) + r.GET("/api/plugins/{name}", lib.ChainMiddlewares(h.getPlugin, middlewares...)) + r.POST("/api/plugins", lib.ChainMiddlewares(h.createPlugin, middlewares...)) + r.PUT("/api/plugins/{name}", lib.ChainMiddlewares(h.updatePlugin, middlewares...)) + r.DELETE("/api/plugins/{name}", lib.ChainMiddlewares(h.deletePlugin, middlewares...)) +} + +// getPlugins gets all plugins +func (h *PluginsHandler) getPlugins(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + pluginStatus := h.pluginsLoader.GetPluginStatus() + finalPlugins := []struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` + IsCustom bool `json:"isCustom"` + Path *string `json:"path"` + Status schemas.PluginStatus `json:"status"` + }{} + for _, pluginStatus := range pluginStatus { + finalPlugins = append(finalPlugins, struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` + IsCustom bool `json:"isCustom"` + Path *string `json:"path"` + Status schemas.PluginStatus `json:"status"` + }{ + Name: pluginStatus.Name, + Enabled: true, + Config: map[string]any{}, + IsCustom: true, + Path: nil, + Status: pluginStatus, + }) + } + SendJSON(ctx, map[string]any{ + "plugins": finalPlugins, + "count": len(finalPlugins), + }) + return + } + plugins, err := h.configStore.GetPlugins(ctx) + if err != nil { + logger.Error("failed to get plugins: %v", err) + SendError(ctx, 500, "Failed to retrieve plugins") + return + } + // Fetching status + pluginStatus := h.pluginsLoader.GetPluginStatus() + // Creating ephemeral struct for the plugins + finalPlugins := []struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` + IsCustom bool `json:"isCustom"` + Path *string `json:"path"` + Status schemas.PluginStatus `json:"status"` + }{} + // Iterating over plugin status to get the plugin info + for _, pluginStatus := range pluginStatus { + var pluginInfo *configstoreTables.TablePlugin + for _, plugin := range plugins { + if plugin.Name == pluginStatus.Name { + pluginInfo = plugin + break + } + } + if pluginInfo == nil { + continue + } + finalPlugins = append(finalPlugins, struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` + IsCustom bool `json:"isCustom"` + Path *string `json:"path"` + Status schemas.PluginStatus `json:"status"` + }{ + Name: pluginInfo.Name, + Enabled: pluginInfo.Enabled, + Config: pluginInfo.Config, + IsCustom: pluginInfo.IsCustom, + Path: pluginInfo.Path, + Status: pluginStatus, + }) + } + // Creating ephemeral struct + SendJSON(ctx, map[string]any{ + "plugins": finalPlugins, + "count": len(finalPlugins), + }) +} + +// getPlugin gets a plugin by name +func (h *PluginsHandler) getPlugin(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + pluginStatus := h.pluginsLoader.GetPluginStatus() + pluginInfo := struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` + IsCustom bool `json:"isCustom"` + Path *string `json:"path"` + Status schemas.PluginStatus `json:"status"` + }{} + for _, pluginStatus := range pluginStatus { + if pluginStatus.Name == ctx.UserValue("name") { + pluginInfo = struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Config any `json:"config"` + IsCustom bool `json:"isCustom"` + Path *string `json:"path"` + Status schemas.PluginStatus `json:"status"` + }{ + Name: pluginStatus.Name, + Enabled: true, + Config: map[string]any{}, + IsCustom: true, + Path: nil, + Status: pluginStatus, + } + break + } + } + SendJSON(ctx, pluginInfo) + return + } + // Safely validate the "name" parameter + nameValue := ctx.UserValue("name") + if nameValue == nil { + logger.Warn("missing required 'name' parameter in request") + SendError(ctx, 400, "Missing required 'name' parameter") + return + } + + name, ok := nameValue.(string) + if !ok { + logger.Warn("invalid 'name' parameter type, expected string but got %T", nameValue) + SendError(ctx, 400, "Invalid 'name' parameter type, expected string") + return + } + + if name == "" { + logger.Warn("empty 'name' parameter provided") + SendError(ctx, 400, "Empty 'name' parameter not allowed") + return + } + + plugin, err := h.configStore.GetPlugin(ctx, name) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + SendError(ctx, fasthttp.StatusNotFound, "Plugin not found") + return + } + logger.Error("failed to get plugin: %v", err) + SendError(ctx, 500, "Failed to retrieve plugin") + return + } + SendJSON(ctx, plugin) +} + +// createPlugin creates a new plugin +func (h *PluginsHandler) createPlugin(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, 400, "Plugins creation is not supported when configstore is disabled") + return + } + var request CreatePluginRequest + if err := json.Unmarshal(ctx.PostBody(), &request); err != nil { + logger.Error("failed to unmarshal create plugin request: %v", err) + SendError(ctx, 400, "Invalid request body") + return + } + // Validate required fields + if request.Name == "" { + SendError(ctx, fasthttp.StatusBadRequest, "Plugin name is required") + return + } + // Check if plugin already exists + existingPlugin, err := h.configStore.GetPlugin(ctx, request.Name) + if err == nil && existingPlugin != nil { + SendError(ctx, fasthttp.StatusConflict, "Plugin already exists") + return + } + if err := h.configStore.CreatePlugin(ctx, &configstoreTables.TablePlugin{ + Name: request.Name, + Enabled: request.Enabled, + Config: request.Config, + Path: request.Path, + IsCustom: true, + }); err != nil { + logger.Error("failed to create plugin: %v", err) + SendError(ctx, 500, "Failed to create plugin") + return + } + + plugin, err := h.configStore.GetPlugin(ctx, request.Name) + if err != nil { + logger.Error("failed to get plugin: %v", err) + SendError(ctx, 500, "Failed to retrieve plugin") + return + } + + // We reload the plugin if its enabled + if request.Enabled { + if err := h.pluginsLoader.ReloadPlugin(ctx, request.Name, request.Path, request.Config); err != nil { + logger.Error("failed to load plugin: %v", err) + SendJSON(ctx, map[string]any{ + "message": fmt.Sprintf("Plugin created successfully; but failed to load plugin with new config: %v", err), + "plugin": plugin, + }) + return + } + } + + ctx.SetStatusCode(fasthttp.StatusCreated) + SendJSON(ctx, map[string]any{ + "message": "Plugin created successfully", + "plugin": plugin, + }) +} + +// updatePlugin updates an existing plugin +func (h *PluginsHandler) updatePlugin(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, 400, "Plugins update is not supported when configstore is disabled") + return + } + // Safely validate the "name" parameter + nameValue := ctx.UserValue("name") + if nameValue == nil { + logger.Warn("missing required 'name' parameter in update plugin request") + SendError(ctx, 400, "Missing required 'name' parameter") + return + } + + name, ok := nameValue.(string) + if !ok { + logger.Warn("invalid 'name' parameter type in update plugin request, expected string but got %T", nameValue) + SendError(ctx, 400, "Invalid 'name' parameter type, expected string") + return + } + + if name == "" { + logger.Warn("empty 'name' parameter provided in update plugin request") + SendError(ctx, 400, "Empty 'name' parameter not allowed") + return + } + var plugin *configstoreTables.TablePlugin + var err error + // Check if plugin exists + plugin, err = h.configStore.GetPlugin(ctx, name) + if err != nil { + // If doesn't exist, create it + if errors.Is(err, configstore.ErrNotFound) { + plugin = &configstoreTables.TablePlugin{ + Name: name, + Enabled: false, + Config: map[string]any{}, + Path: nil, + IsCustom: true, + } + if err := h.configStore.CreatePlugin(ctx, plugin); err != nil { + logger.Error("failed to create plugin: %v", err) + SendError(ctx, 500, "Failed to create plugin") + return + } + } else { + logger.Error("failed to get plugin: %v", err) + SendError(ctx, 404, "Plugin not found") + return + } + } + + // Unmarshalling the request body + var request UpdatePluginRequest + if err := json.Unmarshal(ctx.PostBody(), &request); err != nil { + logger.Error("failed to unmarshal update plugin request: %v", err) + SendError(ctx, 400, "Invalid request body") + return + } + + // Updating the plugin + if err := h.configStore.UpdatePlugin(ctx, &configstoreTables.TablePlugin{ + Name: name, + Enabled: request.Enabled, + Config: request.Config, + Path: request.Path, + IsCustom: plugin.IsCustom, + }); err != nil { + logger.Error("failed to update plugin: %v", err) + SendError(ctx, 500, "Failed to update plugin") + return + } + + plugin, err = h.configStore.GetPlugin(ctx, name) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + SendError(ctx, fasthttp.StatusNotFound, "Plugin not found") + return + } + logger.Error("failed to get plugin: %v", err) + SendError(ctx, 500, "Failed to retrieve plugin") + return + } + // We reload the plugin if its enabled, otherwise we stop it + if request.Enabled { + if err := h.pluginsLoader.ReloadPlugin(ctx, name, request.Path, request.Config); err != nil { + logger.Error("failed to load plugin: %v", err) + SendJSON(ctx, map[string]any{ + "message": fmt.Sprintf("Plugin updated successfully; but failed to load plugin with new config: %v", err), + "plugin": plugin, + }) + return + } + } else { + ctx.SetUserValue("isDisabled", true) + if err := h.pluginsLoader.RemovePlugin(ctx, name); err != nil { + logger.Error("failed to stop plugin: %v", err) + SendJSON(ctx, map[string]any{ + "message": fmt.Sprintf("Plugin updated successfully; but failed to stop plugin: %v", err), + "plugin": plugin, + }) + return + } + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Plugin updated successfully", + "plugin": plugin, + }) +} + +// deletePlugin deletes an existing plugin +func (h *PluginsHandler) deletePlugin(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, 400, "Plugins deletion is not supported when configstore is disabled") + return + } + // Safely validate the "name" parameter + nameValue := ctx.UserValue("name") + if nameValue == nil { + logger.Warn("missing required 'name' parameter in delete plugin request") + SendError(ctx, 400, "Missing required 'name' parameter") + return + } + + name, ok := nameValue.(string) + if !ok { + logger.Warn("invalid 'name' parameter type in delete plugin request, expected string but got %T", nameValue) + SendError(ctx, 400, "Invalid 'name' parameter type, expected string") + return + } + + if name == "" { + logger.Warn("empty 'name' parameter provided in delete plugin request") + SendError(ctx, 400, "Empty 'name' parameter not allowed") + return + } + + if err := h.configStore.DeletePlugin(ctx, name); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + SendError(ctx, fasthttp.StatusNotFound, "Plugin not found") + return + } + logger.Error("failed to delete plugin: %v", err) + SendError(ctx, 500, "Failed to delete plugin") + return + } + + if err := h.pluginsLoader.RemovePlugin(ctx, name); err != nil { + logger.Error("failed to stop plugin: %v", err) + SendJSON(ctx, map[string]any{ + "message": fmt.Sprintf("Plugin deleted successfully; but failed to stop plugin: %v", err), + "plugin": name, + }) + return + } + + SendJSON(ctx, map[string]interface{}{ + "message": "Plugin deleted successfully", + }) +} diff --git a/transports/bifrost-http/handlers/providers.go b/transports/bifrost-http/handlers/providers.go new file mode 100644 index 000000000..420992e54 --- /dev/null +++ b/transports/bifrost-http/handlers/providers.go @@ -0,0 +1,682 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains all provider management functionality including CRUD operations. +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "slices" + "sort" + "strings" + + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// ModelsManager defines the interface for managing provider models +type ModelsManager interface { + RefetchModelsForProvider(ctx context.Context, provider schemas.ModelProvider) error + DeleteModelsForProvider(provider schemas.ModelProvider) error +} + +// ProviderHandler manages HTTP requests for provider operations +type ProviderHandler struct { + store *lib.Config + client *bifrost.Bifrost + modelsManager ModelsManager +} + +// NewProviderHandler creates a new provider handler instance +func NewProviderHandler(modelsManager ModelsManager, store *lib.Config, client *bifrost.Bifrost) *ProviderHandler { + return &ProviderHandler{ + store: store, + client: client, + modelsManager: modelsManager, + } +} + +type ProviderStatus = string + +const ( + ProviderStatusActive ProviderStatus = "active" // Provider is active and working + ProviderStatusError ProviderStatus = "error" // Provider failed to initialize + ProviderStatusDeleted ProviderStatus = "deleted" // Provider is deleted from the store +) + +// ProviderResponse represents the response for provider operations +type ProviderResponse struct { + Name schemas.ModelProvider `json:"name"` + Keys []schemas.Key `json:"keys"` // API keys for the provider + NetworkConfig schemas.NetworkConfig `json:"network_config"` // Network-related settings + ConcurrencyAndBufferSize schemas.ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size"` // Concurrency settings + ProxyConfig *schemas.ProxyConfig `json:"proxy_config"` // Proxy configuration + SendBackRawResponse bool `json:"send_back_raw_response"` // Include raw response in BifrostResponse + CustomProviderConfig *schemas.CustomProviderConfig `json:"custom_provider_config,omitempty"` // Custom provider configuration + Status ProviderStatus `json:"status"` // Status of the provider +} + +// ListProvidersResponse represents the response for listing all providers +type ListProvidersResponse struct { + Providers []ProviderResponse `json:"providers"` + Total int `json:"total"` +} + +// ErrorResponse represents an error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} + +// RegisterRoutes registers all provider management routes +func (h *ProviderHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + // Provider CRUD operations + r.GET("/api/providers", lib.ChainMiddlewares(h.listProviders, middlewares...)) + r.GET("/api/providers/{provider}", lib.ChainMiddlewares(h.getProvider, middlewares...)) + r.POST("/api/providers", lib.ChainMiddlewares(h.addProvider, middlewares...)) + r.PUT("/api/providers/{provider}", lib.ChainMiddlewares(h.updateProvider, middlewares...)) + r.DELETE("/api/providers/{provider}", lib.ChainMiddlewares(h.deleteProvider, middlewares...)) + r.GET("/api/keys", lib.ChainMiddlewares(h.listKeys, middlewares...)) +} + +// listProviders handles GET /api/providers - List all providers +func (h *ProviderHandler) listProviders(ctx *fasthttp.RequestCtx) { + providers, err := h.store.GetAllProviders() + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get providers: %v", err)) + return + } + + providersInClient, err := h.client.GetConfiguredProviders() + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get providers from client: %v", err)) + return + } + + providerResponses := []ProviderResponse{} + + // Sort providers alphabetically + sort.Slice(providers, func(i, j int) bool { + return string(providers[i]) < string(providers[j]) + }) + + for _, provider := range providers { + config, err := h.store.GetProviderConfigRedacted(provider) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to get config for provider %s: %v", provider, err)) + // Include provider even if config fetch fails + providerResponses = append(providerResponses, ProviderResponse{ + Name: provider, + Status: ProviderStatusError, + }) + continue + } + + providerStatus := ProviderStatusError + if slices.Contains(providersInClient, provider) { + providerStatus = ProviderStatusActive + } + + providerResponses = append(providerResponses, h.getProviderResponseFromConfig(provider, *config, providerStatus)) + } + + response := ListProvidersResponse{ + Providers: providerResponses, + Total: len(providerResponses), + } + + SendJSON(ctx, response) +} + +// getProvider handles GET /api/providers/{provider} - Get specific provider +func (h *ProviderHandler) getProvider(ctx *fasthttp.RequestCtx) { + provider, err := getProviderFromCtx(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid provider: %v", err)) + return + } + + providersInClient, err := h.client.GetConfiguredProviders() + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get providers from client: %v", err)) + return + } + + config, err := h.store.GetProviderConfigRedacted(provider) + if err != nil { + SendError(ctx, fasthttp.StatusNotFound, fmt.Sprintf("Provider not found: %v", err)) + return + } + + providerStatus := ProviderStatusError + if slices.Contains(providersInClient, provider) { + providerStatus = ProviderStatusActive + } + + response := h.getProviderResponseFromConfig(provider, *config, providerStatus) + + SendJSON(ctx, response) +} + +// addProvider handles POST /api/providers - Add a new provider +func (h *ProviderHandler) addProvider(ctx *fasthttp.RequestCtx) { + // Payload structure + var payload = struct { + Provider schemas.ModelProvider `json:"provider"` + Keys []schemas.Key `json:"keys"` // API keys for the provider + NetworkConfig *schemas.NetworkConfig `json:"network_config,omitempty"` // Network-related settings + ConcurrencyAndBufferSize *schemas.ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size,omitempty"` // Concurrency settings + ProxyConfig *schemas.ProxyConfig `json:"proxy_config,omitempty"` // Proxy configuration + SendBackRawResponse *bool `json:"send_back_raw_response,omitempty"` // Include raw response in BifrostResponse + CustomProviderConfig *schemas.CustomProviderConfig `json:"custom_provider_config,omitempty"` // Custom provider configuration + }{} + + if err := json.Unmarshal(ctx.PostBody(), &payload); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } + + // Validate provider + if payload.Provider == "" { + SendError(ctx, fasthttp.StatusBadRequest, "Missing provider") + return + } + + if payload.CustomProviderConfig != nil { + // custom provider key should not be same as standard provider names + if bifrost.IsStandardProvider(payload.Provider) { + SendError(ctx, fasthttp.StatusBadRequest, "Custom provider cannot be same as a standard provider") + return + } + + if payload.CustomProviderConfig.BaseProviderType == "" { + SendError(ctx, fasthttp.StatusBadRequest, "BaseProviderType is required when CustomProviderConfig is provided") + return + } + + // check if base provider is a supported base provider + if !bifrost.IsSupportedBaseProvider(payload.CustomProviderConfig.BaseProviderType) { + SendError(ctx, fasthttp.StatusBadRequest, "BaseProviderType must be a standard provider") + return + } + } + + if payload.ConcurrencyAndBufferSize != nil { + if payload.ConcurrencyAndBufferSize.Concurrency == 0 { + SendError(ctx, fasthttp.StatusBadRequest, "Concurrency must be greater than 0") + return + } + if payload.ConcurrencyAndBufferSize.BufferSize == 0 { + SendError(ctx, fasthttp.StatusBadRequest, "Buffer size must be greater than 0") + return + } + + if payload.ConcurrencyAndBufferSize.Concurrency > payload.ConcurrencyAndBufferSize.BufferSize { + SendError(ctx, fasthttp.StatusBadRequest, "Concurrency must be less than or equal to buffer size") + return + } + } + + // Check if provider already exists + if _, err := h.store.GetProviderConfigRedacted(payload.Provider); err == nil { + SendError(ctx, fasthttp.StatusConflict, fmt.Sprintf("Provider %s already exists", payload.Provider)) + return + } + + // Construct ProviderConfig from individual fields + config := configstore.ProviderConfig{ + Keys: payload.Keys, + NetworkConfig: payload.NetworkConfig, + ProxyConfig: payload.ProxyConfig, + ConcurrencyAndBufferSize: payload.ConcurrencyAndBufferSize, + SendBackRawResponse: payload.SendBackRawResponse != nil && *payload.SendBackRawResponse, + CustomProviderConfig: payload.CustomProviderConfig, + } + + // Validate custom provider configuration before persisting + if err := lib.ValidateCustomProvider(config, payload.Provider); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid custom provider config: %v", err)) + return + } + + // Add provider to store (env vars will be processed by store) + if err := h.store.AddProvider(ctx, payload.Provider, config); err != nil { + logger.Warn(fmt.Sprintf("Failed to add provider %s: %v", payload.Provider, err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to add provider: %v", err)) + return + } + + logger.Info(fmt.Sprintf("Provider %s added successfully", payload.Provider)) + + // Get redacted config for response + redactedConfig, err := h.store.GetProviderConfigRedacted(payload.Provider) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to get redacted config for provider %s: %v", payload.Provider, err)) + // Fall back to the raw config (no keys) + response := h.getProviderResponseFromConfig(payload.Provider, configstore.ProviderConfig{ + NetworkConfig: config.NetworkConfig, + ConcurrencyAndBufferSize: config.ConcurrencyAndBufferSize, + ProxyConfig: config.ProxyConfig, + SendBackRawResponse: config.SendBackRawResponse, + CustomProviderConfig: config.CustomProviderConfig, + }, ProviderStatusActive) + SendJSON(ctx, response) + return + } + + if payload.CustomProviderConfig == nil || + !payload.CustomProviderConfig.IsKeyLess || + (payload.CustomProviderConfig.AllowedRequests != nil && payload.CustomProviderConfig.AllowedRequests.ListModels) { + if err := h.modelsManager.RefetchModelsForProvider(ctx, payload.Provider); err != nil { + logger.Warn(fmt.Sprintf("Failed to refetch models for provider %s: %v", payload.Provider, err)) + } + } + + response := h.getProviderResponseFromConfig(payload.Provider, *redactedConfig, ProviderStatusActive) + + SendJSON(ctx, response) +} + +// updateProvider handles PUT /api/providers/{provider} - Update provider config +// NOTE: This endpoint expects ALL fields to be provided in the request body, +// including both edited and non-edited fields. Partial updates are not supported. +// The frontend should send the complete provider configuration. +// This flow upserts the config +func (h *ProviderHandler) updateProvider(ctx *fasthttp.RequestCtx) { + provider, err := getProviderFromCtx(ctx) + if err != nil { + // If not found, then first we create and then update + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid provider: %v", err)) + return + } + + var payload = struct { + Keys []schemas.Key `json:"keys"` // API keys for the provider + NetworkConfig schemas.NetworkConfig `json:"network_config"` // Network-related settings + ConcurrencyAndBufferSize schemas.ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size"` // Concurrency settings + ProxyConfig *schemas.ProxyConfig `json:"proxy_config,omitempty"` // Proxy configuration + SendBackRawResponse *bool `json:"send_back_raw_response,omitempty"` // Include raw response in BifrostResponse + CustomProviderConfig *schemas.CustomProviderConfig `json:"custom_provider_config,omitempty"` // Custom provider configuration + }{} + + if err := json.Unmarshal(ctx.PostBody(), &payload); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } + + // Get the raw config to access actual values for merging with redacted request values + oldConfigRaw, err := h.store.GetProviderConfigRaw(provider) + if err != nil { + if !errors.Is(err, lib.ErrNotFound) { + logger.Warn(fmt.Sprintf("Failed to get old config for provider %s: %v", provider, err)) + SendError(ctx, fasthttp.StatusInternalServerError, err.Error()) + return + } + } + + if oldConfigRaw == nil { + oldConfigRaw = &configstore.ProviderConfig{} + } + + oldConfigRedacted, err := h.store.GetProviderConfigRedacted(provider) + if err != nil { + if !errors.Is(err, lib.ErrNotFound) { + logger.Warn(fmt.Sprintf("Failed to get old redacted config for provider %s: %v", provider, err)) + SendError(ctx, fasthttp.StatusInternalServerError, err.Error()) + return + } + } + + if oldConfigRedacted == nil { + oldConfigRedacted = &configstore.ProviderConfig{} + } + + // Construct ProviderConfig from individual fields + config := configstore.ProviderConfig{ + Keys: oldConfigRaw.Keys, + NetworkConfig: oldConfigRaw.NetworkConfig, + ConcurrencyAndBufferSize: oldConfigRaw.ConcurrencyAndBufferSize, + ProxyConfig: oldConfigRaw.ProxyConfig, + CustomProviderConfig: oldConfigRaw.CustomProviderConfig, + } + + // Environment variable cleanup is now handled automatically by mergeKeys function + + var keysToAdd []schemas.Key + var keysToUpdate []schemas.Key + + for _, key := range payload.Keys { + if !slices.ContainsFunc(oldConfigRaw.Keys, func(k schemas.Key) bool { + return k.ID == key.ID + }) { + keysToAdd = append(keysToAdd, key) + } else { + keysToUpdate = append(keysToUpdate, key) + } + } + + var keysToDelete []schemas.Key + for _, key := range oldConfigRaw.Keys { + if !slices.ContainsFunc(payload.Keys, func(k schemas.Key) bool { + return k.ID == key.ID + }) { + keysToDelete = append(keysToDelete, key) + } + } + + keys, err := h.mergeKeys(provider, oldConfigRaw.Keys, oldConfigRedacted.Keys, keysToAdd, keysToDelete, keysToUpdate) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid keys: %v", err)) + return + } + config.Keys = keys + + if payload.ConcurrencyAndBufferSize.Concurrency == 0 { + SendError(ctx, fasthttp.StatusBadRequest, "Concurrency must be greater than 0") + return + } + if payload.ConcurrencyAndBufferSize.BufferSize == 0 { + SendError(ctx, fasthttp.StatusBadRequest, "Buffer size must be greater than 0") + return + } + + if payload.ConcurrencyAndBufferSize.Concurrency > payload.ConcurrencyAndBufferSize.BufferSize { + SendError(ctx, fasthttp.StatusBadRequest, "Concurrency must be less than or equal to buffer size") + return + } + + // Build a prospective config with the requested CustomProviderConfig (including nil) + prospective := config + prospective.CustomProviderConfig = payload.CustomProviderConfig + if err := lib.ValidateCustomProviderUpdate(prospective, *oldConfigRaw, provider); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid custom provider config: %v", err)) + return + } + + config.ConcurrencyAndBufferSize = &payload.ConcurrencyAndBufferSize + config.NetworkConfig = &payload.NetworkConfig + config.ProxyConfig = payload.ProxyConfig + config.CustomProviderConfig = payload.CustomProviderConfig + if payload.SendBackRawResponse != nil { + config.SendBackRawResponse = *payload.SendBackRawResponse + } + + // Update provider config in store (env vars will be processed by store) + if err := h.store.UpdateProviderConfig(ctx, provider, config); err != nil { + if !errors.Is(err, lib.ErrNotFound) { + logger.Warn(fmt.Sprintf("Failed to update provider %s: %v", provider, err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to update provider: %v", err)) + return + } + // Creating provider instance with current config + if addErr := h.store.AddProvider(ctx, provider, config); addErr != nil { + logger.Warn(fmt.Sprintf("Failed to add provider %s: %v", provider, addErr)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to upsert provider: %v", addErr)) + return + } + } + + // First update the provider config in store because account interface fetched config from there in client update + if err := h.client.UpdateProvider(provider); err != nil { + logger.Warn(fmt.Sprintf("Failed to update provider %s: %v", provider, err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to update provider: %v", err)) + return + } + + // Get redacted config for response + redactedConfig, err := h.store.GetProviderConfigRedacted(provider) + if err != nil { + logger.Warn(fmt.Sprintf("Failed to get redacted config for provider %s: %v", provider, err)) + // Fall back to sanitized config (no keys) + response := h.getProviderResponseFromConfig(provider, configstore.ProviderConfig{ + NetworkConfig: config.NetworkConfig, + ConcurrencyAndBufferSize: config.ConcurrencyAndBufferSize, + ProxyConfig: config.ProxyConfig, + SendBackRawResponse: config.SendBackRawResponse, + CustomProviderConfig: config.CustomProviderConfig, + }, ProviderStatusActive) + SendJSON(ctx, response) + return + } + + if len(redactedConfig.Keys) > 0 && + (payload.CustomProviderConfig == nil || + !payload.CustomProviderConfig.IsKeyLess || + (payload.CustomProviderConfig.AllowedRequests != nil && payload.CustomProviderConfig.AllowedRequests.ListModels)) { + if err := h.modelsManager.RefetchModelsForProvider(ctx, provider); err != nil { + logger.Warn(fmt.Sprintf("Failed to refetch models for provider %s: %v", provider, err)) + } + } else { + if err := h.modelsManager.DeleteModelsForProvider(provider); err != nil { + logger.Warn(fmt.Sprintf("Failed to delete models for provider %s: %v", provider, err)) + } + } + + response := h.getProviderResponseFromConfig(provider, *redactedConfig, ProviderStatusActive) + + SendJSON(ctx, response) +} + +// deleteProvider handles DELETE /api/providers/{provider} - Remove provider +func (h *ProviderHandler) deleteProvider(ctx *fasthttp.RequestCtx) { + provider, err := getProviderFromCtx(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid provider: %v", err)) + return + } + + // Check if provider exists + if _, err := h.store.GetProviderConfigRedacted(provider); err != nil { + SendError(ctx, fasthttp.StatusNotFound, fmt.Sprintf("Provider not found: %v", err)) + return + } + + // Remove provider from store + if err := h.store.RemoveProvider(ctx, provider); err != nil { + logger.Warn(fmt.Sprintf("Failed to remove provider %s: %v", provider, err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to remove provider: %v", err)) + return + } + + logger.Info(fmt.Sprintf("Provider %s removed successfully", provider)) + + if err := h.modelsManager.DeleteModelsForProvider(provider); err != nil { + logger.Warn(fmt.Sprintf("Failed to delete models for provider %s: %v", provider, err)) + } + + response := ProviderResponse{ + Name: provider, + } + + SendJSON(ctx, response) +} + +// listKeys handles GET /api/keys - List all keys +func (h *ProviderHandler) listKeys(ctx *fasthttp.RequestCtx) { + keys, err := h.store.GetAllKeys() + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get keys: %v", err)) + return + } + + SendJSON(ctx, keys) +} + +// mergeKeys merges new keys with old, preserving values that are redacted in the new config +func (h *ProviderHandler) mergeKeys(provider schemas.ModelProvider, oldRawKeys []schemas.Key, oldRedactedKeys []schemas.Key, keysToAdd []schemas.Key, keysToDelete []schemas.Key, keysToUpdate []schemas.Key) ([]schemas.Key, error) { + // Clean up environment variables for deleted keys only + // Updated keys will be cleaned up after merge to avoid premature cleanup + h.store.CleanupEnvKeysForKeys(provider, keysToDelete) + // Create a map of indices to delete + toDelete := make(map[int]bool) + for _, key := range keysToDelete { + for i, oldKey := range oldRawKeys { + if oldKey.ID == key.ID { + toDelete[i] = true + break + } + } + } + + // Create a map of updates by ID for quick lookup + updates := make(map[string]schemas.Key) + for _, key := range keysToUpdate { + updates[key.ID] = key + } + + // Map old redacted keys by ID for reliable lookup + redactedByID := make(map[string]schemas.Key) + for _, rk := range oldRedactedKeys { + redactedByID[rk.ID] = rk + } + + // Process existing keys (handle updates and deletions) + var resultKeys []schemas.Key + for i, oldRawKey := range oldRawKeys { + // Skip if this key should be deleted + if toDelete[i] { + continue + } + + // Check if this key should be updated + if updateKey, exists := updates[oldRawKey.ID]; exists { + oldRedactedKey, ok := redactedByID[oldRawKey.ID] + if !ok { + oldRedactedKey = schemas.Key{} + } + mergedKey := updateKey + + // Handle redacted values - preserve old value if new value is redacted/env var AND it's the same as old redacted value + if lib.IsRedacted(updateKey.Value) && + strings.EqualFold(updateKey.Value, oldRedactedKey.Value) { + mergedKey.Value = oldRawKey.Value + } + + // Handle Azure config redacted values + if updateKey.AzureKeyConfig != nil && oldRedactedKey.AzureKeyConfig != nil && oldRawKey.AzureKeyConfig != nil { + if lib.IsRedacted(updateKey.AzureKeyConfig.Endpoint) && + strings.EqualFold(updateKey.AzureKeyConfig.Endpoint, oldRedactedKey.AzureKeyConfig.Endpoint) { + mergedKey.AzureKeyConfig.Endpoint = oldRawKey.AzureKeyConfig.Endpoint + } + if updateKey.AzureKeyConfig.APIVersion != nil && + oldRedactedKey.AzureKeyConfig.APIVersion != nil && + oldRawKey.AzureKeyConfig != nil { + if lib.IsRedacted(*updateKey.AzureKeyConfig.APIVersion) && + strings.EqualFold(*updateKey.AzureKeyConfig.APIVersion, *oldRedactedKey.AzureKeyConfig.APIVersion) { + mergedKey.AzureKeyConfig.APIVersion = oldRawKey.AzureKeyConfig.APIVersion + } + } + } + + // Handle Vertex config redacted values + if updateKey.VertexKeyConfig != nil && oldRedactedKey.VertexKeyConfig != nil && oldRawKey.VertexKeyConfig != nil { + if lib.IsRedacted(updateKey.VertexKeyConfig.ProjectID) && + strings.EqualFold(updateKey.VertexKeyConfig.ProjectID, oldRedactedKey.VertexKeyConfig.ProjectID) { + mergedKey.VertexKeyConfig.ProjectID = oldRawKey.VertexKeyConfig.ProjectID + } + if lib.IsRedacted(updateKey.VertexKeyConfig.Region) && + strings.EqualFold(updateKey.VertexKeyConfig.Region, oldRedactedKey.VertexKeyConfig.Region) { + mergedKey.VertexKeyConfig.Region = oldRawKey.VertexKeyConfig.Region + } + if lib.IsRedacted(updateKey.VertexKeyConfig.AuthCredentials) && + strings.EqualFold(updateKey.VertexKeyConfig.AuthCredentials, oldRedactedKey.VertexKeyConfig.AuthCredentials) { + mergedKey.VertexKeyConfig.AuthCredentials = oldRawKey.VertexKeyConfig.AuthCredentials + } + } + + // Handle Bedrock config redacted values + if updateKey.BedrockKeyConfig != nil && oldRedactedKey.BedrockKeyConfig != nil && oldRawKey.BedrockKeyConfig != nil { + if lib.IsRedacted(updateKey.BedrockKeyConfig.AccessKey) && + strings.EqualFold(updateKey.BedrockKeyConfig.AccessKey, oldRedactedKey.BedrockKeyConfig.AccessKey) { + mergedKey.BedrockKeyConfig.AccessKey = oldRawKey.BedrockKeyConfig.AccessKey + } + if lib.IsRedacted(updateKey.BedrockKeyConfig.SecretKey) && + strings.EqualFold(updateKey.BedrockKeyConfig.SecretKey, oldRedactedKey.BedrockKeyConfig.SecretKey) { + mergedKey.BedrockKeyConfig.SecretKey = oldRawKey.BedrockKeyConfig.SecretKey + } + if updateKey.BedrockKeyConfig.SessionToken != nil && + oldRedactedKey.BedrockKeyConfig.SessionToken != nil && + oldRawKey.BedrockKeyConfig != nil { + if lib.IsRedacted(*updateKey.BedrockKeyConfig.SessionToken) && + strings.EqualFold(*updateKey.BedrockKeyConfig.SessionToken, *oldRedactedKey.BedrockKeyConfig.SessionToken) { + mergedKey.BedrockKeyConfig.SessionToken = oldRawKey.BedrockKeyConfig.SessionToken + } + } + if updateKey.BedrockKeyConfig.Region != nil { + if lib.IsRedacted(*updateKey.BedrockKeyConfig.Region) && + (!strings.HasPrefix(*updateKey.BedrockKeyConfig.Region, "env.") || + (oldRedactedKey.BedrockKeyConfig.Region != nil && + !strings.EqualFold(*updateKey.BedrockKeyConfig.Region, *oldRedactedKey.BedrockKeyConfig.Region))) { + mergedKey.BedrockKeyConfig.Region = oldRawKey.BedrockKeyConfig.Region + } + } + if updateKey.BedrockKeyConfig.ARN != nil { + if lib.IsRedacted(*updateKey.BedrockKeyConfig.ARN) && + (!strings.HasPrefix(*updateKey.BedrockKeyConfig.ARN, "env.") || + (oldRedactedKey.BedrockKeyConfig.ARN != nil && + !strings.EqualFold(*updateKey.BedrockKeyConfig.ARN, *oldRedactedKey.BedrockKeyConfig.ARN))) { + mergedKey.BedrockKeyConfig.ARN = oldRawKey.BedrockKeyConfig.ARN + } + } + } + + resultKeys = append(resultKeys, mergedKey) + } else { + // Keep unchanged key + resultKeys = append(resultKeys, oldRawKey) + } + } + + // Add new keys + resultKeys = append(resultKeys, keysToAdd...) + + // Clean up environment variables for updated keys after merge + // This allows us to compare the final merged values with the original values + h.store.CleanupEnvKeysForUpdatedKeys(provider, keysToUpdate, oldRawKeys, resultKeys) + + return resultKeys, nil +} + +func (h *ProviderHandler) getProviderResponseFromConfig(provider schemas.ModelProvider, config configstore.ProviderConfig, status ProviderStatus) ProviderResponse { + if config.NetworkConfig == nil { + config.NetworkConfig = &schemas.DefaultNetworkConfig + } + if config.ConcurrencyAndBufferSize == nil { + config.ConcurrencyAndBufferSize = &schemas.DefaultConcurrencyAndBufferSize + } + + return ProviderResponse{ + Name: provider, + Keys: config.Keys, + NetworkConfig: *config.NetworkConfig, + ConcurrencyAndBufferSize: *config.ConcurrencyAndBufferSize, + ProxyConfig: config.ProxyConfig, + SendBackRawResponse: config.SendBackRawResponse, + CustomProviderConfig: config.CustomProviderConfig, + Status: status, + } +} + +func getProviderFromCtx(ctx *fasthttp.RequestCtx) (schemas.ModelProvider, error) { + providerValue := ctx.UserValue("provider") + if providerValue == nil { + return "", fmt.Errorf("missing provider parameter") + } + providerStr, ok := providerValue.(string) + if !ok { + return "", fmt.Errorf("invalid provider parameter type") + } + + decoded, err := url.PathUnescape(providerStr) + if err != nil { + return "", fmt.Errorf("invalid provider parameter encoding: %v", err) + } + + return schemas.ModelProvider(decoded), nil +} diff --git a/transports/bifrost-http/handlers/session.go b/transports/bifrost-http/handlers/session.go new file mode 100644 index 000000000..a118525b0 --- /dev/null +++ b/transports/bifrost-http/handlers/session.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/fasthttp/router" + "github.com/google/uuid" + "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/encrypt" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// SessionHandler manages HTTP requests for session operations +type SessionHandler struct { + configStore configstore.ConfigStore +} + +// NewSessionHandler creates a new session handler instance +func NewSessionHandler(configStore configstore.ConfigStore) *SessionHandler { + if configStore == nil { + return nil + } + return &SessionHandler{ + configStore: configStore, + } +} + +// RegisterRoutes registers the session-related routes +func (h *SessionHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + r.POST("/api/session/login", lib.ChainMiddlewares(h.login, middlewares...)) + r.POST("/api/session/logout", lib.ChainMiddlewares(h.logout, middlewares...)) + r.GET("/api/session/is-auth-enabled", lib.ChainMiddlewares(h.isAuthEnabled, middlewares...)) +} + +// isAuthEnabled handles GET /api/session/is-auth-enabled - Check if auth is enabled +func (h *SessionHandler) isAuthEnabled(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendJSON(ctx, map[string]any{ + "is_auth_enabled": false, + }) + return + } + authConfig, err := h.configStore.GetAuthConfig(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get auth config: %v", err)) + return + } + if authConfig == nil { + SendJSON(ctx, map[string]any{ + "is_auth_enabled": false, + }) + return + } + // Check if the header has a token and is valid + token := string(ctx.Request.Header.Peek("Authorization")) + token = strings.TrimPrefix(token, "Bearer ") + hasValidToken := false + if token != "" { + session, err := h.configStore.GetSession(ctx, token) + if err == nil && session != nil && session.ExpiresAt.After(time.Now()) { + hasValidToken = true + } + } + SendJSON(ctx, map[string]any{ + "is_auth_enabled": authConfig.IsEnabled, + "has_valid_token": hasValidToken, + }) +} + +// login handles POST /api/session/login - Login a user +func (h *SessionHandler) login(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, fasthttp.StatusForbidden, "Authentication is not enabled") + return + } + payload := struct { + Username string `json:"username"` + Password string `json:"password"` + }{} + if err := json.Unmarshal(ctx.PostBody(), &payload); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) + return + } + + // Get auth config + authConfig, err := h.configStore.GetAuthConfig(ctx) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get auth config: %v", err)) + return + } + + // Check if auth is enabled + if !authConfig.IsEnabled { + SendError(ctx, fasthttp.StatusForbidden, "Authentication is not enabled") + return + } + + // Verify credentials + if payload.Username != authConfig.AdminUserName { + SendError(ctx, fasthttp.StatusUnauthorized, "Invalid username or password") + return + } + compare, err := encrypt.CompareHash(authConfig.AdminPassword, payload.Password) + if err != nil { + SendError(ctx, fasthttp.StatusUnauthorized, "Unauthorized") + return + } + if !compare { + SendError(ctx, fasthttp.StatusUnauthorized, "Invalid username or password") + return + } + + // Creating a new session + token := uuid.New().String() + session := &tables.SessionsTable{ + Token: token, + ExpiresAt: time.Now().Add(time.Hour * 24 * 30), // 30 days + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = h.configStore.CreateSession(ctx, session) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to create session: %v", err)) + return + } + + // Setting cookies + cookie := fasthttp.AcquireCookie() + defer fasthttp.ReleaseCookie(cookie) + cookie.SetKey("token") + cookie.SetValue(token) + cookie.SetExpire(time.Now().Add(time.Hour * 24 * 30)) + cookie.SetPath("/") + cookie.SetHTTPOnly(true) + cookie.SetSameSite(fasthttp.CookieSameSiteLaxMode) + // Check if source is https then set secure + if string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { + cookie.SetSecure(true) + } + ctx.Response.Header.SetCookie(cookie) + + SendJSON(ctx, map[string]any{ + "message": "Login successful", + "token": token, + }) +} + +// logout handles POST /api/session/logout - Logout a user +func (h *SessionHandler) logout(ctx *fasthttp.RequestCtx) { + if h.configStore == nil { + SendError(ctx, fasthttp.StatusForbidden, "Authentication is not enabled") + return + } + // Get token from Authorization header + token := string(ctx.Request.Header.Peek("Authorization")) + token = strings.TrimPrefix(token, "Bearer ") + + // If no token in header, try to get from cookie + if token == "" { + token = string(ctx.Request.Header.Cookie("token")) + } + + // clear token from cookies + cookie := fasthttp.AcquireCookie() + defer fasthttp.ReleaseCookie(cookie) + cookie.SetKey("token") + cookie.SetValue("") + cookie.SetExpire(time.Now().Add(-time.Hour * 24 * 30)) + cookie.SetPath("/") + cookie.SetHTTPOnly(true) + cookie.SetSameSite(fasthttp.CookieSameSiteLaxMode) + // Check if source is https then set secure + if string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { + cookie.SetSecure(true) + } + ctx.Response.Header.SetCookie(cookie) + + // delete session from database if token exists + if token != "" { + err := h.configStore.DeleteSession(ctx, token) + if err != nil { + // we will ignore this error + logger.Warn(fmt.Sprintf("failed to delete session: %v", err)) + } + } + + SendJSON(ctx, map[string]any{ + "message": "Logout successful", + }) +} diff --git a/transports/bifrost-http/handlers/ui.go b/transports/bifrost-http/handlers/ui.go new file mode 100644 index 000000000..cd42ad7dc --- /dev/null +++ b/transports/bifrost-http/handlers/ui.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "embed" + "mime" + "path" + "path/filepath" + "strings" + + "github.com/fasthttp/router" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// UIHandler handles UI routes. +type UIHandler struct { + uiContent embed.FS +} + +// NewUIHandler creates a new UIHandler instance. +func NewUIHandler(uiContent embed.FS) *UIHandler { + return &UIHandler{ + uiContent: uiContent, + } +} + +// RegisterRoutes registers the UI routes with the provided router. +func (h *UIHandler) RegisterRoutes(router *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + router.GET("/", lib.ChainMiddlewares(h.serveDashboard, middlewares...)) + router.GET("/{filepath:*}", lib.ChainMiddlewares(h.serveDashboard, middlewares...)) +} + +// ServeDashboard serves the dashboard UI. +func (h *UIHandler) serveDashboard(ctx *fasthttp.RequestCtx) { + // Get the request path + requestPath := string(ctx.Path()) + + // Clean the path to prevent directory traversal + cleanPath := path.Clean(requestPath) + + // Handle .txt files (Next.js RSC payload files) - map from /{page}.txt to /{page}/index.txt + if strings.HasSuffix(cleanPath, ".txt") { + // Remove .txt extension and add /index.txt + basePath := strings.TrimSuffix(cleanPath, ".txt") + if basePath == "/" || basePath == "" { + basePath = "/index" + } + cleanPath = basePath + "/index.txt" + } + + // Remove leading slash and add ui prefix + if cleanPath == "/" { + cleanPath = "ui/index.html" + } else { + cleanPath = "ui" + cleanPath + } + + // Check if this is a static asset request (has file extension) + hasExtension := strings.Contains(filepath.Base(cleanPath), ".") + + // Try to read the file from embedded filesystem + data, err := h.uiContent.ReadFile(cleanPath) + if err != nil { + + // If it's a static asset (has extension) and not found, return 404 + if hasExtension { + ctx.SetStatusCode(fasthttp.StatusNotFound) + ctx.SetBodyString("404 - Static asset not found: " + requestPath) + return + } + + // For routes without extensions (SPA routing), try {path}/index.html first + if !hasExtension { + indexPath := cleanPath + "/index.html" + data, err = h.uiContent.ReadFile(indexPath) + if err == nil { + cleanPath = indexPath + } else { + // If that fails, serve root index.html as fallback + data, err = h.uiContent.ReadFile("ui/index.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusNotFound) + ctx.SetBodyString("404 - File not found") + return + } + cleanPath = "ui/index.html" + } + } else { + ctx.SetStatusCode(fasthttp.StatusNotFound) + ctx.SetBodyString("404 - File not found") + return + } + } + + // Set content type based on file extension + ext := filepath.Ext(cleanPath) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + ctx.SetContentType(contentType) + + // Set cache headers for static assets + if strings.HasPrefix(cleanPath, "ui/_next/static/") { + ctx.Response.Header.Set("Cache-Control", "public, max-age=31536000, immutable") + } else if ext == ".html" { + ctx.Response.Header.Set("Cache-Control", "no-cache") + } else { + ctx.Response.Header.Set("Cache-Control", "public, max-age=3600") + } + + // Send the file content + ctx.SetBody(data) +} diff --git a/transports/bifrost-http/handlers/utils.go b/transports/bifrost-http/handlers/utils.go new file mode 100644 index 000000000..834a6d624 --- /dev/null +++ b/transports/bifrost-http/handlers/utils.go @@ -0,0 +1,156 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains common utility functions used across all handlers. +package handlers + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// SendJSON sends a JSON response with 200 OK status +func SendJSON(ctx *fasthttp.RequestCtx, data interface{}) { + ctx.SetContentType("application/json") + if err := json.NewEncoder(ctx).Encode(data); err != nil { + logger.Warn(fmt.Sprintf("Failed to encode JSON response: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to encode response: %v", err)) + } +} + +// SendJSONWithStatus sends a JSON response with a custom status code +func SendJSONWithStatus(ctx *fasthttp.RequestCtx, data interface{}, statusCode int) { + ctx.SetContentType("application/json") + ctx.SetStatusCode(statusCode) + if err := json.NewEncoder(ctx).Encode(data); err != nil { + logger.Warn(fmt.Sprintf("Failed to encode JSON response: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to encode response: %v", err)) + } +} + +// SendError sends a BifrostError response +func SendError(ctx *fasthttp.RequestCtx, statusCode int, message string) { + bifrostErr := &schemas.BifrostError{ + IsBifrostError: false, + StatusCode: &statusCode, + Error: &schemas.ErrorField{ + Message: message, + }, + } + SendBifrostError(ctx, bifrostErr) +} + +// SendBifrostError sends a BifrostError response +func SendBifrostError(ctx *fasthttp.RequestCtx, bifrostErr *schemas.BifrostError) { + if bifrostErr.StatusCode != nil { + ctx.SetStatusCode(*bifrostErr.StatusCode) + } else if !bifrostErr.IsBifrostError { + ctx.SetStatusCode(fasthttp.StatusBadRequest) + } else { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + } + + ctx.SetContentType("application/json") + if encodeErr := json.NewEncoder(ctx).Encode(bifrostErr); encodeErr != nil { + logger.Warn(fmt.Sprintf("Failed to encode error response: %v", encodeErr)) + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + ctx.SetBodyString(fmt.Sprintf("Failed to encode error response: %v", encodeErr)) + } +} + +// SendSSEError sends an error in Server-Sent Events format +func SendSSEError(ctx *fasthttp.RequestCtx, bifrostErr *schemas.BifrostError) { + errorJSON, err := json.Marshal(map[string]interface{}{ + "error": bifrostErr, + }) + if err != nil { + logger.Error("failed to marshal error for SSE: %v", err) + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + return + } + + if _, err := fmt.Fprintf(ctx, "data: %s\n\n", errorJSON); err != nil { + logger.Warn(fmt.Sprintf("Failed to write SSE error: %v", err)) + } +} + +// IsOriginAllowed checks if the given origin is allowed based on localhost rules and configured allowed origins. +// Localhost origins are always allowed. Additional origins can be configured in allowedOrigins. +// Supports wildcard patterns like *.example.com to match any subdomain. +func IsOriginAllowed(origin string, allowedOrigins []string) bool { + // Always allow localhost origins + if isLocalhostOrigin(origin) { + return true + } + + // Check configured allowed origins + for _, allowedOrigin := range allowedOrigins { + // Check for exact match first + if allowedOrigin == origin { + return true + } + + // Check for wildcard pattern + if strings.Contains(allowedOrigin, "*") { + if matchesWildcardPattern(origin, allowedOrigin) { + return true + } + } + } + + return false +} + +// isLocalhostOrigin checks if the given origin is a localhost origin +func isLocalhostOrigin(origin string) bool { + return strings.HasPrefix(origin, "http://localhost:") || + strings.HasPrefix(origin, "https://localhost:") || + strings.HasPrefix(origin, "http://127.0.0.1:") || + strings.HasPrefix(origin, "http://0.0.0.0:") || + strings.HasPrefix(origin, "https://127.0.0.1:") +} + +// matchesWildcardPattern checks if an origin matches a wildcard pattern. +// Supports patterns like *.example.com, https://*.example.com, or http://*.example.com +func matchesWildcardPattern(origin string, pattern string) bool { + // Convert wildcard pattern to regex pattern + // Escape special regex characters except * + regexPattern := regexp.QuoteMeta(pattern) + // Replace escaped \* with regex pattern for subdomain matching + // \* should match one or more characters that are not dots (to match a subdomain) + regexPattern = strings.ReplaceAll(regexPattern, `\*`, `[^/.]+`) + // Anchor the pattern to match the entire origin + regexPattern = "^" + regexPattern + "$" + + // Compile and test the regex + re, err := regexp.Compile(regexPattern) + if err != nil { + return false + } + + return re.MatchString(origin) +} + +// ParseModel parses a model string in the format "provider/model" or "provider/nested/model" +// Returns the provider and full model name after the first slash +func ParseModel(model string) (string, string, error) { + model = strings.TrimSpace(model) + if model == "" { + return "", "", fmt.Errorf("model cannot be empty") + } + + parts := strings.SplitN(model, "/", 2) + if len(parts) < 2 { + return "", "", fmt.Errorf("model must be in the format 'provider/model'") + } + + provider := strings.TrimSpace(parts[0]) + name := strings.TrimSpace(parts[1]) + if provider == "" || name == "" { + return "", "", fmt.Errorf("model must be in the format 'provider/model' with non-empty provider and model") + } + return provider, name, nil +} diff --git a/transports/bifrost-http/handlers/websocket.go b/transports/bifrost-http/handlers/websocket.go new file mode 100644 index 000000000..eb4b05f5a --- /dev/null +++ b/transports/bifrost-http/handlers/websocket.go @@ -0,0 +1,265 @@ +// Package handlers provides HTTP request handlers for the Bifrost HTTP transport. +// This file contains WebSocket handlers for real-time log streaming. +package handlers + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/fasthttp/router" + "github.com/fasthttp/websocket" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/plugins/logging" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// WebSocketClient represents a connected WebSocket client with its own mutex +type WebSocketClient struct { + conn *websocket.Conn + mu sync.Mutex // Per-connection mutex for thread-safe writes +} + +// WebSocketHandler manages WebSocket connections for real-time updates +type WebSocketHandler struct { + ctx context.Context + logManager logging.LogManager + allowedOrigins []string + clients map[*websocket.Conn]*WebSocketClient + mu sync.RWMutex + stopChan chan struct{} // Channel to signal heartbeat goroutine to stop + done chan struct{} // Channel to signal when heartbeat goroutine has stopped +} + +// NewWebSocketHandler creates a new WebSocket handler instance +func NewWebSocketHandler(ctx context.Context, logManager logging.LogManager, allowedOrigins []string) *WebSocketHandler { + return &WebSocketHandler{ + ctx: ctx, + logManager: logManager, + allowedOrigins: allowedOrigins, + clients: make(map[*websocket.Conn]*WebSocketClient), + stopChan: make(chan struct{}), + done: make(chan struct{}), + } +} + +// RegisterRoutes registers all WebSocket-related routes +func (h *WebSocketHandler) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + r.GET("/ws", lib.ChainMiddlewares(h.connectStream, middlewares...)) +} + +// getUpgrader returns a WebSocket upgrader configured with the current allowed origins +func (h *WebSocketHandler) getUpgrader() websocket.FastHTTPUpgrader { + return websocket.FastHTTPUpgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(ctx *fasthttp.RequestCtx) bool { + origin := string(ctx.Request.Header.Peek("Origin")) + if origin == "" { + // If no Origin header, check the Host header for direct connections + host := string(ctx.Request.Header.Peek("Host")) + return isLocalhost(host) + } + // Check if origin is allowed (localhost always allowed + configured origins) + return IsOriginAllowed(origin, h.allowedOrigins) + }, + } +} + +// isLocalhost checks if the given host is localhost +func isLocalhost(host string) bool { + // Remove port if present + if idx := strings.LastIndex(host, ":"); idx != -1 { + host = host[:idx] + } + + // Check for localhost variations + return host == "localhost" || + host == "127.0.0.1" || + host == "::1" || + host == "" +} + +// connectStream handles WebSocket connections for real-time streaming +func (h *WebSocketHandler) connectStream(ctx *fasthttp.RequestCtx) { + upgrader := h.getUpgrader() + err := upgrader.Upgrade(ctx, func(ws *websocket.Conn) { + // Read safety & liveness + ws.SetReadLimit(50 << 20) // 50 MiB + ws.SetReadDeadline(time.Now().Add(60 * time.Second)) + ws.SetPongHandler(func(string) error { + ws.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + // Create a new client with its own mutex + client := &WebSocketClient{ + conn: ws, + } + + // Register new client + h.mu.Lock() + h.clients[ws] = client + h.mu.Unlock() + + // Clean up on disconnect + defer func() { + h.mu.Lock() + delete(h.clients, ws) + h.mu.Unlock() + ws.Close() + }() + + // Keep connection alive and handle client messages + // This loop continuously reads and discards incoming WebSocket messages to: + // 1. Keep the connection alive by processing client pings and control frames + // 2. Detect when the client disconnects by watching for close frames or errors + // 3. Maintain proper WebSocket protocol handling without accumulating messages + for { + _, _, err := ws.ReadMessage() + if err != nil { + // Only log unexpected close errors + if websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseAbnormalClosure, + websocket.CloseNoStatusReceived) { + logger.Error("websocket read error: %v", err) + } + break + } + } + }) + + if err != nil { + logger.Error("websocket upgrade error: %v", err) + return + } +} + +// sendMessageSafely sends a message to a client with proper locking and error handling +func (h *WebSocketHandler) sendMessageSafely(client *WebSocketClient, messageType int, data []byte) error { + client.mu.Lock() + defer client.mu.Unlock() + + // Set a write deadline to prevent hanging connections + client.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + defer client.conn.SetWriteDeadline(time.Time{}) // Clear the deadline + + err := client.conn.WriteMessage(messageType, data) + if err != nil { + // Remove the client from the map if write fails + go func() { + h.mu.Lock() + delete(h.clients, client.conn) + h.mu.Unlock() + client.conn.Close() + }() + } + + return err +} + +// BroadcastLogUpdate sends a log update to all connected WebSocket clients +func (h *WebSocketHandler) BroadcastLogUpdate(logEntry *logstore.Log) { + // Add panic recovery to prevent server crashes + defer func() { + if r := recover(); r != nil { + logger.Error("panic in BroadcastLogUpdate: %v", r) + } + }() + + // Determine operation type based on log status and timestamp + operationType := "update" + if logEntry.Status == "processing" && logEntry.CreatedAt.Equal(logEntry.Timestamp) { + operationType = "create" + } + + message := struct { + Type string `json:"type"` + Operation string `json:"operation"` // "create" or "update" + Payload *logstore.Log `json:"payload"` + }{ + Type: "log", + Operation: operationType, + Payload: logEntry, + } + + data, err := json.Marshal(message) + if err != nil { + logger.Error("failed to marshal log entry: %v", err) + return + } + + h.BroadcastMarshaledMessage(data) +} + +// BroadcastMarshaledMessage sends an adaptive routing update to all connected WebSocket clients +func (h *WebSocketHandler) BroadcastMarshaledMessage(data []byte) { + // Get a snapshot of clients to avoid holding the lock during writes + h.mu.RLock() + clients := make([]*WebSocketClient, 0, len(h.clients)) + for _, client := range h.clients { + clients = append(clients, client) + } + h.mu.RUnlock() + + // Send message to each client safely + for _, client := range clients { + if err := h.sendMessageSafely(client, websocket.TextMessage, data); err != nil { + logger.Error("failed to send message to client: %v", err) + } + } +} + +// StartHeartbeat starts sending periodic heartbeat messages to keep connections alive +func (h *WebSocketHandler) StartHeartbeat() { + ticker := time.NewTicker(30 * time.Second) + go func() { + defer func() { + ticker.Stop() + close(h.done) + }() + + for { + select { + case <-h.ctx.Done(): + logger.Info("got context cancel(), stopping webserver") + return + case <-ticker.C: + // Get a snapshot of clients to avoid holding the lock during writes + h.mu.RLock() + clients := make([]*WebSocketClient, 0, len(h.clients)) + for _, client := range h.clients { + clients = append(clients, client) + } + h.mu.RUnlock() + + // Send heartbeat to each client safely + for _, client := range clients { + if err := h.sendMessageSafely(client, websocket.PingMessage, nil); err != nil { + logger.Error("failed to send heartbeat: %v", err) + } + } + case <-h.stopChan: + return + } + } + }() +} + +// Stop gracefully shuts down the WebSocket handler +func (h *WebSocketHandler) Stop() { + close(h.stopChan) // Signal heartbeat goroutine to stop + <-h.done // Wait for heartbeat goroutine to finish + + // Close all client connections + h.mu.Lock() + for _, client := range h.clients { + client.conn.Close() + } + h.clients = make(map[*websocket.Conn]*WebSocketClient) + h.mu.Unlock() +} diff --git a/transports/bifrost-http/integrations/anthropic.go b/transports/bifrost-http/integrations/anthropic.go new file mode 100644 index 000000000..b034cd88a --- /dev/null +++ b/transports/bifrost-http/integrations/anthropic.go @@ -0,0 +1,214 @@ +package integrations + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/schemas" + + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// AnthropicRouter handles Anthropic-compatible API endpoints +type AnthropicRouter struct { + *GenericRouter +} + +// createAnthropicCompleteRouteConfig creates a route configuration for the `/v1/complete` endpoint. +func createAnthropicCompleteRouteConfig(pathPrefix string) RouteConfig { + return RouteConfig{ + Type: RouteConfigTypeAnthropic, + Path: pathPrefix + "/v1/complete", + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &anthropic.AnthropicTextRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if anthropicReq, ok := req.(*anthropic.AnthropicTextRequest); ok { + return &schemas.BifrostRequest{ + TextCompletionRequest: anthropicReq.ToBifrostTextCompletionRequest(), + }, nil + } + return nil, errors.New("invalid request type") + }, + TextResponseConverter: func(resp *schemas.BifrostTextCompletionResponse) (interface{}, error) { + return anthropic.ToAnthropicTextCompletionResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return anthropic.ToAnthropicChatCompletionError(err) + }, + } +} + +// createAnthropicMessagesRouteConfig creates a route configuration for the `/v1/messages` endpoint. +func createAnthropicMessagesRouteConfig(pathPrefix string) []RouteConfig { + var routes []RouteConfig + for _, path := range []string{ + "/v1/messages", + "/v1/messages/{path:*}", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeAnthropic, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &anthropic.AnthropicMessageRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if anthropicReq, ok := req.(*anthropic.AnthropicMessageRequest); ok { + return &schemas.BifrostRequest{ + ResponsesRequest: anthropicReq.ToBifrostResponsesRequest(), + }, nil + } + return nil, errors.New("invalid request type") + }, + ResponsesResponseConverter: func(resp *schemas.BifrostResponsesResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.Anthropic { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return anthropic.ToAnthropicResponsesResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return anthropic.ToAnthropicChatCompletionError(err) + }, + StreamConfig: &StreamConfig{ + ResponsesStreamResponseConverter: func(resp *schemas.BifrostResponsesStreamResponse) (interface{}, error) { + return anthropic.ToAnthropicResponsesStreamResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return anthropic.ToAnthropicResponsesStreamError(err) + }, + }, + PreCallback: checkAnthropicPassthrough, + }) + } + return routes +} + +// CreateAnthropicRouteConfigs creates route configurations for Anthropic endpoints. +func CreateAnthropicRouteConfigs(pathPrefix string) []RouteConfig { + return append([]RouteConfig{ + createAnthropicCompleteRouteConfig(pathPrefix), + }, createAnthropicMessagesRouteConfig(pathPrefix)...) +} + +func CreateAnthropicListModelsRouteConfigs(pathPrefix string, handlerStore lib.HandlerStore) []RouteConfig { + return []RouteConfig{ + { + Type: RouteConfigTypeAnthropic, + Path: pathPrefix + "/v1/models", + Method: "GET", + GetRequestTypeInstance: func() interface{} { + return &schemas.BifrostListModelsRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if listModelsReq, ok := req.(*schemas.BifrostListModelsRequest); ok { + return &schemas.BifrostRequest{ + ListModelsRequest: listModelsReq, + }, nil + } + return nil, errors.New("invalid request type") + }, + ListModelsResponseConverter: func(resp *schemas.BifrostListModelsResponse) (interface{}, error) { + return anthropic.ToAnthropicListModelsResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return anthropic.ToAnthropicChatCompletionError(err) + }, + PreCallback: extractAnthropicListModelsParams, + }, + } +} + +// checkAnthropicPassthrough pre-callback checks if the request is for a claude model. +// If it is, it attaches the raw request body for direct use by the provider. +// It also checks for anthropic oauth headers and sets the bifrost context. +func checkAnthropicPassthrough(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + var provider schemas.ModelProvider + var model string + + switch r := req.(type) { + case *anthropic.AnthropicTextRequest: + provider, model = schemas.ParseModelString(r.Model, "") + // Check if model parameter explicitly has `anthropic/` prefix + if provider == schemas.Anthropic { + r.Model = model + } + + case *anthropic.AnthropicMessageRequest: + provider, model = schemas.ParseModelString(r.Model, "") + // Check if model parameter explicitly has `anthropic/` prefix + if provider == schemas.Anthropic { + r.Model = model + } + } + + if !strings.Contains(model, "claude") || (provider != schemas.Anthropic && provider != "") { + // Not a Claude model or not an Anthropic model, so we can continue + return nil + } + + // Check if anthropic oauth headers are present + if !isAnthropicAPIKeyAuth(ctx) { + headers := extractHeadersFromRequest(ctx) + url := extractExactPath(ctx) + if !strings.HasPrefix(url, "/") { + url = "/" + url + } + + *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeyExtraHeaders, headers) + *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeyURLPath, url) + *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeySkipKeySelection, true) + *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeyUseRawRequestBody, true) + } + return nil +} + +// extractAnthropicListModelsParams extracts query parameters for list models request +func extractAnthropicListModelsParams(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + if listModelsReq, ok := req.(*schemas.BifrostListModelsRequest); ok { + // Set provider to Anthropic + listModelsReq.Provider = schemas.Anthropic + + // Extract limit from query parameters + if limitStr := string(ctx.QueryArgs().Peek("limit")); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + listModelsReq.PageSize = limit + } else { + return fmt.Errorf("invalid limit parameter: %w", err) + } + } + + if beforeID := string(ctx.QueryArgs().Peek("before_id")); beforeID != "" { + if listModelsReq.ExtraParams == nil { + listModelsReq.ExtraParams = make(map[string]interface{}) + } + listModelsReq.ExtraParams["before_id"] = beforeID + } + + if afterID := string(ctx.QueryArgs().Peek("after_id")); afterID != "" { + if listModelsReq.ExtraParams == nil { + listModelsReq.ExtraParams = make(map[string]interface{}) + } + listModelsReq.ExtraParams["after_id"] = afterID + } + + return nil + } + return errors.New("invalid request type for Anthropic list models") +} + +// NewAnthropicRouter creates a new AnthropicRouter with the given bifrost client. +func NewAnthropicRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *AnthropicRouter { + return &AnthropicRouter{ + GenericRouter: NewGenericRouter(client, handlerStore, append(CreateAnthropicRouteConfigs("/anthropic"), CreateAnthropicListModelsRouteConfigs("/anthropic", handlerStore)...), logger), + } +} diff --git a/transports/bifrost-http/integrations/genai.go b/transports/bifrost-http/integrations/genai.go new file mode 100644 index 000000000..220e36de6 --- /dev/null +++ b/transports/bifrost-http/integrations/genai.go @@ -0,0 +1,184 @@ +package integrations + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/providers/gemini" + "github.com/maximhq/bifrost/core/schemas" + + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// GenAIRouter holds route registrations for genai endpoints. +type GenAIRouter struct { + *GenericRouter +} + +// CreateGenAIRouteConfigs creates a route configurations for GenAI endpoints. +func CreateGenAIRouteConfigs(pathPrefix string) []RouteConfig { + var routes []RouteConfig + + // Chat completions endpoint + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeGenAI, + Path: pathPrefix + "/v1beta/models/{model:*}", + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &gemini.GeminiGenerationRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if geminiReq, ok := req.(*gemini.GeminiGenerationRequest); ok { + if geminiReq.IsEmbedding { + return &schemas.BifrostRequest{ + EmbeddingRequest: geminiReq.ToBifrostEmbeddingRequest(), + }, nil + } else { + return &schemas.BifrostRequest{ + ChatRequest: geminiReq.ToBifrostChatRequest(), + }, nil + } + } + return nil, errors.New("invalid request type") + }, + EmbeddingResponseConverter: func(resp *schemas.BifrostEmbeddingResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.Gemini { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return gemini.ToGeminiEmbeddingResponse(resp), nil + }, + ChatResponseConverter: func(resp *schemas.BifrostChatResponse) (interface{}, error) { + return gemini.ToGeminiChatResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return gemini.ToGeminiError(err) + }, + StreamConfig: &StreamConfig{ + ChatStreamResponseConverter: func(resp *schemas.BifrostChatResponse) (interface{}, error) { + return gemini.ToGeminiChatResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return gemini.ToGeminiError(err) + }, + }, + PreCallback: extractAndSetModelFromURL, + }) + + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeGenAI, + Path: pathPrefix + "/v1beta/models", + Method: "GET", + GetRequestTypeInstance: func() interface{} { + return &schemas.BifrostListModelsRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if listModelsReq, ok := req.(*schemas.BifrostListModelsRequest); ok { + return &schemas.BifrostRequest{ + ListModelsRequest: listModelsReq, + }, nil + } + return nil, errors.New("invalid request type") + }, + ListModelsResponseConverter: func(resp *schemas.BifrostListModelsResponse) (interface{}, error) { + return gemini.ToGeminiListModelsResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return gemini.ToGeminiError(err) + }, + PreCallback: extractGeminiListModelsParams, + }) + + return routes +} + +// NewGenAIRouter creates a new GenAIRouter with the given bifrost client. +func NewGenAIRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *GenAIRouter { + return &GenAIRouter{ + GenericRouter: NewGenericRouter(client, handlerStore, CreateGenAIRouteConfigs("/genai"), logger), + } +} + +var embeddingPaths = []string{ + ":embedContent", + ":batchEmbedContents", + ":predict", +} + +// extractAndSetModelFromURL extracts model from URL and sets it in the request +func extractAndSetModelFromURL(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + model := ctx.UserValue("model") + if model == nil { + return fmt.Errorf("model parameter is required") + } + + modelStr := model.(string) + + // Check if this is an embedding request + isEmbedding := false + for _, path := range embeddingPaths { + if strings.HasSuffix(modelStr, path) { + isEmbedding = true + break + } + } + + // Check if this is a streaming request + isStreaming := strings.HasSuffix(modelStr, ":streamGenerateContent") + + // Remove Google GenAI API endpoint suffixes if present + for _, sfx := range []string{ + ":streamGenerateContent", + ":generateContent", + ":countTokens", + ":embedContent", + ":batchEmbedContents", + ":predict", + } { + modelStr = strings.TrimSuffix(modelStr, sfx) + } + + // Remove trailing colon if present + if len(modelStr) > 0 && modelStr[len(modelStr)-1] == ':' { + modelStr = modelStr[:len(modelStr)-1] + } + + // Set the model and flags in the request + if geminiReq, ok := req.(*gemini.GeminiGenerationRequest); ok { + geminiReq.Model = modelStr + geminiReq.Stream = isStreaming + geminiReq.IsEmbedding = isEmbedding + return nil + } + + return fmt.Errorf("invalid request type for GenAI") +} + +// extractGeminiListModelsParams extracts query parameters for list models request +func extractGeminiListModelsParams(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + if listModelsReq, ok := req.(*schemas.BifrostListModelsRequest); ok { + // Set provider to Gemini + listModelsReq.Provider = schemas.Gemini + + // Extract pageSize from query parameters (Gemini uses pageSize instead of limit) + if pageSizeStr := string(ctx.QueryArgs().Peek("pageSize")); pageSizeStr != "" { + if pageSize, err := strconv.Atoi(pageSizeStr); err == nil { + listModelsReq.PageSize = pageSize + } + } + + // Extract pageToken from query parameters + if pageToken := string(ctx.QueryArgs().Peek("pageToken")); pageToken != "" { + listModelsReq.PageToken = pageToken + } + + return nil + } + return errors.New("invalid request type for Gemini list models") +} diff --git a/transports/bifrost-http/integrations/langchain.go b/transports/bifrost-http/integrations/langchain.go new file mode 100644 index 000000000..38a13171a --- /dev/null +++ b/transports/bifrost-http/integrations/langchain.go @@ -0,0 +1,33 @@ +package integrations + +import ( + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" +) + +// LangChainRouter holds route registrations for LangChain endpoints. +// It supports standard chat completions and image-enabled vision capabilities. +// LangChain is fully OpenAI-compatible, so we reuse OpenAI types +// with aliases for clarity and minimal LangChain-specific extensions +type LangChainRouter struct { + *GenericRouter +} + +// NewLangChainRouter creates a new LangChainRouter with the given bifrost client. +func NewLangChainRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *LangChainRouter { + routes := []RouteConfig{} + + // Add OpenAI routes to LangChain for OpenAI API compatibility + routes = append(routes, CreateOpenAIRouteConfigs("/langchain", handlerStore)...) + + // Add Anthropic routes to LangChain for Anthropic API compatibility + routes = append(routes, CreateAnthropicRouteConfigs("/langchain")...) + + // Add GenAI routes to LangChain for Vertex AI compatibility + routes = append(routes, CreateGenAIRouteConfigs("/langchain")...) + + return &LangChainRouter{ + GenericRouter: NewGenericRouter(client, handlerStore, routes, logger), + } +} diff --git a/transports/bifrost-http/integrations/litellm.go b/transports/bifrost-http/integrations/litellm.go new file mode 100644 index 000000000..dd9bf3035 --- /dev/null +++ b/transports/bifrost-http/integrations/litellm.go @@ -0,0 +1,33 @@ +package integrations + +import ( + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" +) + +// LiteLLMRouter holds route registrations for LiteLLM endpoints. +// It supports standard chat completions and image-enabled vision capabilities. +// LiteLLM is fully OpenAI-compatible, so we reuse OpenAI types +// with aliases for clarity and minimal LiteLLM-specific extensions +type LiteLLMRouter struct { + *GenericRouter +} + +// NewLiteLLMRouter creates a new LiteLLMRouter with the given bifrost client. +func NewLiteLLMRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *LiteLLMRouter { + routes := []RouteConfig{} + + // Add OpenAI routes to LiteLLM for OpenAI API compatibility + routes = append(routes, CreateOpenAIRouteConfigs("/litellm", handlerStore)...) + + // Add Anthropic routes to LiteLLM for Anthropic API compatibility + routes = append(routes, CreateAnthropicRouteConfigs("/litellm")...) + + // Add GenAI routes to LiteLLM for Vertex AI compatibility + routes = append(routes, CreateGenAIRouteConfigs("/litellm")...) + + return &LiteLLMRouter{ + GenericRouter: NewGenericRouter(client, handlerStore, routes, logger), + } +} diff --git a/transports/bifrost-http/integrations/openai.go b/transports/bifrost-http/integrations/openai.go new file mode 100644 index 000000000..2a402760c --- /dev/null +++ b/transports/bifrost-http/integrations/openai.go @@ -0,0 +1,490 @@ +package integrations + +import ( + "context" + "errors" + "strconv" + "strings" + + "github.com/google/uuid" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/providers/openai" + "github.com/maximhq/bifrost/core/schemas" + + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// setAzureModelName sets the model name for Azure requests with proper prefix handling +// When deploymentID is present, it always takes precedence over the request body model +// to avoid deployment/model mismatches. +func setAzureModelName(currentModel, deploymentID string) string { + if deploymentID != "" { + return "azure/" + deploymentID + } else if currentModel != "" && !strings.HasPrefix(currentModel, "azure/") { + return "azure/" + currentModel + } + return currentModel +} + +// OpenAIRouter holds route registrations for OpenAI endpoints. +// It supports standard chat completions, speech synthesis, audio transcription, and streaming capabilities with OpenAI-specific formatting. +type OpenAIRouter struct { + *GenericRouter +} + +func AzureEndpointPreHook(handlerStore lib.HandlerStore) func(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + return func(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + azureKey := ctx.Request.Header.Peek("authorization") + deploymentEndpoint := ctx.Request.Header.Peek("x-bf-azure-endpoint") + deploymentID := ctx.UserValue("deployment-id") + apiVersion := ctx.QueryArgs().Peek("api-version") + + if deploymentID != nil { + deploymentIDStr, ok := deploymentID.(string) + if !ok { + return errors.New("deployment-id is required in path") + } + + switch r := req.(type) { + case *openai.OpenAIChatRequest: + r.Model = setAzureModelName(r.Model, deploymentIDStr) + case *openai.OpenAIResponsesRequest: + r.Model = setAzureModelName(r.Model, deploymentIDStr) + case *openai.OpenAISpeechRequest: + r.Model = setAzureModelName(r.Model, deploymentIDStr) + case *openai.OpenAITranscriptionRequest: + r.Model = setAzureModelName(r.Model, deploymentIDStr) + case *openai.OpenAIEmbeddingRequest: + r.Model = setAzureModelName(r.Model, deploymentIDStr) + case *schemas.BifrostListModelsRequest: + r.Provider = schemas.Azure + } + + if deploymentEndpoint == nil || azureKey == nil || !handlerStore.ShouldAllowDirectKeys() { + return nil + } + + azureKeyStr := string(azureKey) + deploymentEndpointStr := string(deploymentEndpoint) + apiVersionStr := string(apiVersion) + + key := schemas.Key{ + ID: uuid.New().String(), + Models: []string{}, + AzureKeyConfig: &schemas.AzureKeyConfig{}, + } + + if deploymentEndpointStr != "" && deploymentIDStr != "" && azureKeyStr != "" { + key.Value = strings.TrimPrefix(azureKeyStr, "Bearer ") + key.AzureKeyConfig.Endpoint = deploymentEndpointStr + key.AzureKeyConfig.Deployments = map[string]string{deploymentIDStr: deploymentIDStr} + } + + if apiVersionStr != "" { + key.AzureKeyConfig.APIVersion = &apiVersionStr + } + + ctx.SetUserValue(string(schemas.BifrostContextKeyDirectKey), key) + + return nil + } + + return nil + } +} + +// CreateOpenAIRouteConfigs creates route configurations for OpenAI endpoints. +func CreateOpenAIRouteConfigs(pathPrefix string, handlerStore lib.HandlerStore) []RouteConfig { + var routes []RouteConfig + + // Text completions endpoint + for _, path := range []string{ + "/v1/completions", + "/completions", + "/openai/deployments/{deployment-id}/completions", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &openai.OpenAITextCompletionRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if openaiReq, ok := req.(*openai.OpenAITextCompletionRequest); ok { + return &schemas.BifrostRequest{ + TextCompletionRequest: openaiReq.ToBifrostTextCompletionRequest(), + }, nil + } + return nil, errors.New("invalid request type") + }, + TextResponseConverter: func(resp *schemas.BifrostTextCompletionResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.OpenAI { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + StreamConfig: &StreamConfig{ + TextStreamResponseConverter: func(resp *schemas.BifrostTextCompletionResponse) (interface{}, error) { + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + }, + PreCallback: AzureEndpointPreHook(handlerStore), + }) + } + + // Chat completions endpoint + for _, path := range []string{ + "/v1/chat/completions", + "/chat/completions", + "/openai/deployments/{deployment-id}/chat/completions", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &openai.OpenAIChatRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if openaiReq, ok := req.(*openai.OpenAIChatRequest); ok { + return &schemas.BifrostRequest{ + ChatRequest: openaiReq.ToBifrostChatRequest(), + }, nil + } + return nil, errors.New("invalid request type") + }, + ChatResponseConverter: func(resp *schemas.BifrostChatResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.OpenAI { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + StreamConfig: &StreamConfig{ + ChatStreamResponseConverter: func(resp *schemas.BifrostChatResponse) (interface{}, error) { + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + }, + PreCallback: AzureEndpointPreHook(handlerStore), + }) + } + + // Responses endpoint + for _, path := range []string{ + "/v1/responses", + "/responses", + "/openai/deployments/{deployment-id}/responses", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &openai.OpenAIResponsesRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if openaiReq, ok := req.(*openai.OpenAIResponsesRequest); ok { + return &schemas.BifrostRequest{ + ResponsesRequest: openaiReq.ToBifrostResponsesRequest(), + }, nil + + } + return nil, errors.New("invalid request type") + }, + ResponsesResponseConverter: func(resp *schemas.BifrostResponsesResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.OpenAI { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + StreamConfig: &StreamConfig{ + ResponsesStreamResponseConverter: func(resp *schemas.BifrostResponsesStreamResponse) (interface{}, error) { + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + }, + PreCallback: AzureEndpointPreHook(handlerStore), + }) + } + + // Embeddings endpoint + for _, path := range []string{ + "/v1/embeddings", + "/embeddings", + "/openai/deployments/{deployment-id}/embeddings", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &openai.OpenAIEmbeddingRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if embeddingReq, ok := req.(*openai.OpenAIEmbeddingRequest); ok { + return &schemas.BifrostRequest{ + EmbeddingRequest: embeddingReq.ToBifrostEmbeddingRequest(), + }, nil + } + return nil, errors.New("invalid embedding request type") + }, + EmbeddingResponseConverter: func(resp *schemas.BifrostEmbeddingResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.OpenAI { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + PreCallback: AzureEndpointPreHook(handlerStore), + }) + } + + // Speech synthesis endpoint + for _, path := range []string{ + "/v1/audio/speech", + "/audio/speech", + "/openai/deployments/{deployment-id}/audio/speech", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &openai.OpenAISpeechRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if speechReq, ok := req.(*openai.OpenAISpeechRequest); ok { + return &schemas.BifrostRequest{ + SpeechRequest: speechReq.ToBifrostSpeechRequest(), + }, nil + } + return nil, errors.New("invalid speech request type") + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + StreamConfig: &StreamConfig{ + SpeechStreamResponseConverter: func(resp *schemas.BifrostSpeechStreamResponse) (interface{}, error) { + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + }, + PreCallback: AzureEndpointPreHook(handlerStore), + }) + } + + // Audio transcription endpoint + for _, path := range []string{ + "/v1/audio/transcriptions", + "/audio/transcriptions", + "/openai/deployments/{deployment-id}/audio/transcriptions", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "POST", + GetRequestTypeInstance: func() interface{} { + return &openai.OpenAITranscriptionRequest{} + }, + RequestParser: parseTranscriptionMultipartRequest, // Handle multipart form parsing + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if transcriptionReq, ok := req.(*openai.OpenAITranscriptionRequest); ok { + return &schemas.BifrostRequest{ + TranscriptionRequest: transcriptionReq.ToBifrostTranscriptionRequest(), + }, nil + } + return nil, errors.New("invalid transcription request type") + }, + TranscriptionResponseConverter: func(resp *schemas.BifrostTranscriptionResponse) (interface{}, error) { + if resp.ExtraFields.Provider == schemas.OpenAI { + if resp.ExtraFields.RawResponse != nil { + return resp.ExtraFields.RawResponse, nil + } + } + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + StreamConfig: &StreamConfig{ + TranscriptionStreamResponseConverter: func(resp *schemas.BifrostTranscriptionStreamResponse) (interface{}, error) { + return resp, nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + }, + PreCallback: AzureEndpointPreHook(handlerStore), + }) + } + + return routes +} + +func CreateOpenAIListModelsRouteConfigs(pathPrefix string, handlerStore lib.HandlerStore) []RouteConfig { + var routes []RouteConfig + + // Models endpoint + for _, path := range []string{ + "/v1/models", + "/models", + "/openai/deployments/{deployment-id}/models", + } { + routes = append(routes, RouteConfig{ + Type: RouteConfigTypeOpenAI, + Path: pathPrefix + path, + Method: "GET", + GetRequestTypeInstance: func() interface{} { + return &schemas.BifrostListModelsRequest{} + }, + RequestConverter: func(req interface{}) (*schemas.BifrostRequest, error) { + if listModelsReq, ok := req.(*schemas.BifrostListModelsRequest); ok { + return &schemas.BifrostRequest{ + ListModelsRequest: listModelsReq, + }, nil + } + return nil, errors.New("invalid request type") + }, + ListModelsResponseConverter: func(resp *schemas.BifrostListModelsResponse) (interface{}, error) { + return openai.ToOpenAIListModelsResponse(resp), nil + }, + ErrorConverter: func(err *schemas.BifrostError) interface{} { + return err + }, + PreCallback: setQueryParamsAndAzureEndpointPreHook(handlerStore), + }) + } + + return routes +} + +// setQueryParamsAndAzureEndpointPreHook creates a combined pre-callback for OpenAI list models +// that handles both Azure endpoint preprocessing and query parameter extraction +func setQueryParamsAndAzureEndpointPreHook(handlerStore lib.HandlerStore) PreRequestCallback { + azureHook := AzureEndpointPreHook(handlerStore) + + return func(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error { + // First run the Azure endpoint pre-hook if needed + if azureHook != nil { + if err := azureHook(ctx, bifrostCtx, req); err != nil { + return err + } + } + + // Then extract query parameters for list models + if listModelsReq, ok := req.(*schemas.BifrostListModelsRequest); ok { + // Set provider to OpenAI (may be overridden by Azure hook) + if listModelsReq.Provider == "" { + listModelsReq.Provider = schemas.OpenAI + } + + return nil + } + + return nil + } +} + +// NewOpenAIRouter creates a new OpenAIRouter with the given bifrost client. +func NewOpenAIRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *OpenAIRouter { + return &OpenAIRouter{ + GenericRouter: NewGenericRouter(client, handlerStore, append(CreateOpenAIRouteConfigs("/openai", handlerStore), CreateOpenAIListModelsRouteConfigs("/openai", handlerStore)...), logger), + } +} + +// parseTranscriptionMultipartRequest is a RequestParser that handles multipart/form-data for transcription requests +func parseTranscriptionMultipartRequest(ctx *fasthttp.RequestCtx, req interface{}) error { + transcriptionReq, ok := req.(*openai.OpenAITranscriptionRequest) + if !ok { + return errors.New("invalid request type for transcription") + } + + // Parse multipart form + form, err := ctx.MultipartForm() + if err != nil { + return err + } + + // Extract model (required) + modelValues := form.Value["model"] + if len(modelValues) == 0 || modelValues[0] == "" { + return errors.New("model field is required") + } + transcriptionReq.Model = modelValues[0] + + // Extract file (required) + fileHeaders := form.File["file"] + if len(fileHeaders) == 0 { + return errors.New("file field is required") + } + + fileHeader := fileHeaders[0] + file, err := fileHeader.Open() + if err != nil { + return err + } + defer file.Close() + + // Read file data + fileData := make([]byte, fileHeader.Size) + if _, err := file.Read(fileData); err != nil { + return err + } + transcriptionReq.File = fileData + + // Extract optional parameters + if languageValues := form.Value["language"]; len(languageValues) > 0 && languageValues[0] != "" { + language := languageValues[0] + transcriptionReq.TranscriptionParameters.Language = &language + } + + if promptValues := form.Value["prompt"]; len(promptValues) > 0 && promptValues[0] != "" { + prompt := promptValues[0] + transcriptionReq.TranscriptionParameters.Prompt = &prompt + } + + if responseFormatValues := form.Value["response_format"]; len(responseFormatValues) > 0 && responseFormatValues[0] != "" { + responseFormat := responseFormatValues[0] + transcriptionReq.TranscriptionParameters.ResponseFormat = &responseFormat + } + + if streamValues := form.Value["stream"]; len(streamValues) > 0 && streamValues[0] != "" { + stream, err := strconv.ParseBool(streamValues[0]) + if err != nil { + return errors.New("invalid stream value") + } + transcriptionReq.Stream = &stream + } + + return nil +} diff --git a/transports/bifrost-http/integrations/router.go b/transports/bifrost-http/integrations/router.go new file mode 100644 index 000000000..b1c979493 --- /dev/null +++ b/transports/bifrost-http/integrations/router.go @@ -0,0 +1,844 @@ +// Package integrations provides a generic router framework for handling different LLM provider APIs. +// +// CENTRALIZED STREAMING ARCHITECTURE: +// +// This package implements a centralized streaming approach where all stream handling logic +// is consolidated in the GenericRouter, eliminating the need for provider-specific StreamHandler +// implementations. The key components are: +// +// 1. StreamConfig: Defines streaming configuration for each route, including: +// - ResponseConverter: Converts BifrostResponse to provider-specific streaming format +// - ErrorConverter: Converts BifrostError to provider-specific streaming error format +// +// 2. Centralized Stream Processing: The GenericRouter handles all streaming logic: +// - SSE header management +// - Stream channel processing +// - Error handling and conversion +// - Response formatting and flushing +// - Stream closure (handled automatically by provider implementation) +// +// 3. Provider-Specific Type Conversion: Integration types.go files only handle type conversion: +// - Derive{Provider}StreamFromBifrostResponse: Convert responses to streaming format +// - Derive{Provider}StreamFromBifrostError: Convert errors to streaming error format +// +// BENEFITS: +// - Eliminates code duplication across provider-specific stream handlers +// - Centralizes streaming logic for consistency and maintainability +// - Separates concerns: routing logic vs type conversion +// - Automatic stream closure management by provider implementations +// - Consistent error handling across all providers +// +// USAGE EXAMPLE: +// +// routes := []RouteConfig{ +// { +// Path: "/openai/chat/completions", +// Method: "POST", +// // ... other configs ... +// StreamConfig: &StreamConfig{ +// ResponseConverter: func(resp *schemas.BifrostResponse) (interface{}, error) { +// return DeriveOpenAIStreamFromBifrostResponse(resp), nil +// }, +// ErrorConverter: func(err *schemas.BifrostError) interface{} { +// return DeriveOpenAIStreamFromBifrostError(err) +// }, +// }, +// }, +// } +package integrations + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + + "bufio" + + "github.com/bytedance/sonic" + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// ExtensionRouter defines the interface that all integration routers must implement +// to register their routes with the main HTTP router. +type ExtensionRouter interface { + RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) +} + +// StreamingRequest interface for requests that support streaming +type StreamingRequest interface { + IsStreamingRequested() bool +} + +// RequestConverter is a function that converts integration-specific requests to Bifrost format. +// It takes the parsed request object and returns a BifrostRequest ready for processing. +type RequestConverter func(req interface{}) (*schemas.BifrostRequest, error) + +// ListModelsResponseConverter is a function that converts BifrostListModelsResponse to integration-specific format. +// It takes a BifrostListModelsResponse and returns the format expected by the specific integration. +type ListModelsResponseConverter func(*schemas.BifrostListModelsResponse) (interface{}, error) + +// TextResponseConverter is a function that converts BifrostTextCompletionResponse to integration-specific format. +// It takes a BifrostTextCompletionResponse and returns the format expected by the specific integration. +type TextResponseConverter func(*schemas.BifrostTextCompletionResponse) (interface{}, error) + +// ChatResponseConverter is a function that converts BifrostChatResponse to integration-specific format. +// It takes a BifrostChatResponse and returns the format expected by the specific integration. +type ChatResponseConverter func(*schemas.BifrostChatResponse) (interface{}, error) + +// ResponsesResponseConverter is a function that converts BifrostResponsesResponse to integration-specific format. +// It takes a BifrostResponsesResponse and returns the format expected by the specific integration. +type ResponsesResponseConverter func(*schemas.BifrostResponsesResponse) (interface{}, error) + +// EmbeddingResponseConverter is a function that converts BifrostEmbeddingResponse to integration-specific format. +// It takes a BifrostEmbeddingResponse and returns the format expected by the specific integration. +type EmbeddingResponseConverter func(*schemas.BifrostEmbeddingResponse) (interface{}, error) + +// TranscriptionResponseConverter is a function that converts BifrostTranscriptionResponse to integration-specific format. +// It takes a BifrostTranscriptionResponse and returns the format expected by the specific integration. +type TranscriptionResponseConverter func(*schemas.BifrostTranscriptionResponse) (interface{}, error) + +// TextStreamResponseConverter is a function that converts BifrostTextCompletionResponse to integration-specific streaming format. +// It takes a BifrostTextCompletionResponse and returns the streaming format expected by the specific integration. +type TextStreamResponseConverter func(*schemas.BifrostTextCompletionResponse) (interface{}, error) + +// ChatStreamResponseConverter is a function that converts BifrostChatResponse to integration-specific streaming format. +// It takes a BifrostChatResponse and returns the streaming format expected by the specific integration. +type ChatStreamResponseConverter func(*schemas.BifrostChatResponse) (interface{}, error) + +// ResponsesStreamResponseConverter is a function that converts BifrostResponsesStreamResponse to integration-specific streaming format. +// It takes a BifrostResponsesStreamResponse and returns the streaming format expected by the specific integration. +type ResponsesStreamResponseConverter func(*schemas.BifrostResponsesStreamResponse) (interface{}, error) + +// SpeechStreamResponseConverter is a function that converts BifrostSpeechStreamResponse to integration-specific streaming format. +// It takes a BifrostSpeechStreamResponse and returns the streaming format expected by the specific integration. +type SpeechStreamResponseConverter func(*schemas.BifrostSpeechStreamResponse) (interface{}, error) + +// TranscriptionStreamResponseConverter is a function that converts BifrostTranscriptionStreamResponse to integration-specific streaming format. +// It takes a BifrostTranscriptionStreamResponse and returns the streaming format expected by the specific integration. +type TranscriptionStreamResponseConverter func(*schemas.BifrostTranscriptionStreamResponse) (interface{}, error) + +// ErrorConverter is a function that converts BifrostError to integration-specific format. +// It takes a BifrostError and returns the format expected by the specific integration. +type ErrorConverter func(*schemas.BifrostError) interface{} + +// StreamErrorConverter is a function that converts BifrostError to integration-specific streaming error format. +// It takes a BifrostError and returns the streaming error format expected by the specific integration. +type StreamErrorConverter func(*schemas.BifrostError) interface{} + +// RequestParser is a function that handles custom request body parsing. +// It replaces the default JSON parsing when configured (e.g., for multipart/form-data). +// The parser should populate the provided request object from the fasthttp context. +// If it returns an error, the request processing stops. +type RequestParser func(ctx *fasthttp.RequestCtx, req interface{}) error + +// PreRequestCallback is called after parsing the request but before processing through Bifrost. +// It can be used to modify the request object (e.g., extract model from URL parameters) +// or perform validation. If it returns an error, the request processing stops. +// It can also modify the bifrost context based on the request context before it is given to Bifrost. +type PreRequestCallback func(ctx *fasthttp.RequestCtx, bifrostCtx *context.Context, req interface{}) error + +// PostRequestCallback is called after processing the request but before sending the response. +// It can be used to modify the response or perform additional logging/metrics. +// If it returns an error, an error response is sent instead of the success response. +type PostRequestCallback func(ctx *fasthttp.RequestCtx, req interface{}, resp interface{}) error + +// StreamConfig defines streaming-specific configuration for an integration +// +// SSE FORMAT BEHAVIOR: +// +// The ResponseConverter and ErrorConverter functions in StreamConfig can return either: +// +// 1. OBJECTS (interface{} that's not a string): +// - Will be JSON marshaled and sent as standard SSE: data: {json}\n\n +// - Use this for most providers (OpenAI, Google, etc.) +// - Example: return map[string]interface{}{"delta": {"content": "hello"}} +// - Result: data: {"delta":{"content":"hello"}}\n\n +// +// 2. STRINGS: +// - Will be sent directly as-is without any modification +// - Use this for providers requiring custom SSE event types (Anthropic, etc.) +// - Example: return "event: content_block_delta\ndata: {\"type\":\"text\"}\n\n" +// - Result: event: content_block_delta +// data: {"type":"text"} +// +// Choose the appropriate return type based on your provider's SSE specification. +type StreamConfig struct { + TextStreamResponseConverter TextStreamResponseConverter // Function to convert BifrostTextCompletionResponse to streaming format + ChatStreamResponseConverter ChatStreamResponseConverter // Function to convert BifrostChatResponse to streaming format + ResponsesStreamResponseConverter ResponsesStreamResponseConverter // Function to convert BifrostResponsesResponse to streaming format + SpeechStreamResponseConverter SpeechStreamResponseConverter // Function to convert BifrostSpeechResponse to streaming format + TranscriptionStreamResponseConverter TranscriptionStreamResponseConverter // Function to convert BifrostTranscriptionResponse to streaming format + ErrorConverter StreamErrorConverter // Function to convert BifrostError to streaming error format +} + +type RouteConfigType string + +const ( + RouteConfigTypeOpenAI RouteConfigType = "openai" + RouteConfigTypeAnthropic RouteConfigType = "anthropic" + RouteConfigTypeGenAI RouteConfigType = "genai" +) + +// RouteConfig defines the configuration for a single route in an integration. +// It specifies the path, method, and handlers for request/response conversion. +type RouteConfig struct { + Type RouteConfigType // Type of the route + Path string // HTTP path pattern (e.g., "/openai/v1/chat/completions") + Method string // HTTP method (POST, GET, PUT, DELETE) + GetRequestTypeInstance func() interface{} // Factory function to create request instance (SHOULD NOT BE NIL) + RequestParser RequestParser // Optional: custom request parsing (e.g., multipart/form-data) + RequestConverter RequestConverter // Function to convert request to BifrostRequest (SHOULD NOT BE NIL) + ListModelsResponseConverter ListModelsResponseConverter // Function to convert BifrostListModelsResponse to integration format (SHOULD NOT BE NIL) + TextResponseConverter TextResponseConverter // Function to convert BifrostTextCompletionResponse to integration format (SHOULD NOT BE NIL) + ChatResponseConverter ChatResponseConverter // Function to convert BifrostChatResponse to integration format (SHOULD NOT BE NIL) + ResponsesResponseConverter ResponsesResponseConverter // Function to convert BifrostResponsesResponse to integration format (SHOULD NOT BE NIL) + EmbeddingResponseConverter EmbeddingResponseConverter // Function to convert BifrostEmbeddingResponse to integration format (SHOULD NOT BE NIL) + TranscriptionResponseConverter TranscriptionResponseConverter // Function to convert BifrostTranscriptionResponse to integration format (SHOULD NOT BE NIL) + ErrorConverter ErrorConverter // Function to convert BifrostError to integration format (SHOULD NOT BE NIL) + StreamConfig *StreamConfig // Optional: Streaming configuration (if nil, streaming not supported) + PreCallback PreRequestCallback // Optional: called after parsing but before Bifrost processing + PostCallback PostRequestCallback // Optional: called after request processing +} + +// GenericRouter provides a reusable router implementation for all integrations. +// It handles the common flow of: parse request β†’ convert to Bifrost β†’ execute β†’ convert response. +// Integration-specific logic is handled through the RouteConfig callbacks and converters. +type GenericRouter struct { + client *bifrost.Bifrost // Bifrost client for executing requests + handlerStore lib.HandlerStore // Config provider for the router + routes []RouteConfig // List of route configurations + logger schemas.Logger // Logger for the router +} + +// NewGenericRouter creates a new generic router with the given bifrost client and route configurations. +// Each integration should create their own routes and pass them to this constructor. +func NewGenericRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, routes []RouteConfig, logger schemas.Logger) *GenericRouter { + return &GenericRouter{ + client: client, + handlerStore: handlerStore, + routes: routes, + logger: logger, + } +} + +// RegisterRoutes registers all configured routes on the given fasthttp router. +// This method implements the ExtensionRouter interface. +func (g *GenericRouter) RegisterRoutes(r *router.Router, middlewares ...lib.BifrostHTTPMiddleware) { + for _, route := range g.routes { + // Validate route configuration at startup to fail fast + method := strings.ToUpper(route.Method) + + if route.GetRequestTypeInstance == nil { + g.logger.Warn("route configuration is invalid: GetRequestTypeInstance cannot be nil for route " + route.Path) + continue + } + + // Test that GetRequestTypeInstance returns a valid instance + if testInstance := route.GetRequestTypeInstance(); testInstance == nil { + g.logger.Warn("route configuration is invalid: GetRequestTypeInstance returned nil for route " + route.Path) + continue + } + + // For list models endpoints, verify ListModelsResponseConverter is set + if method == fasthttp.MethodGet && route.ListModelsResponseConverter == nil { + g.logger.Warn("route configuration is invalid: ListModelsResponseConverter cannot be nil for GET route " + route.Path) + continue + } + + if route.RequestConverter == nil { + g.logger.Warn("route configuration is invalid: RequestConverter cannot be nil for route " + route.Path) + continue + } + + if route.ErrorConverter == nil { + g.logger.Warn("route configuration is invalid: ErrorConverter cannot be nil for route " + route.Path) + continue + } + + handler := g.createHandler(route) + switch method { + case fasthttp.MethodPost: + r.POST(route.Path, lib.ChainMiddlewares(handler, middlewares...)) + case fasthttp.MethodGet: + r.GET(route.Path, lib.ChainMiddlewares(handler, middlewares...)) + case fasthttp.MethodPut: + r.PUT(route.Path, lib.ChainMiddlewares(handler, middlewares...)) + case fasthttp.MethodDelete: + r.DELETE(route.Path, lib.ChainMiddlewares(handler, middlewares...)) + default: + r.POST(route.Path, lib.ChainMiddlewares(handler, middlewares...)) // Default to POST + } + } +} + +// createHandler creates a fasthttp handler for the given route configuration. +// The handler follows this flow: +// 1. Parse JSON request body into the configured request type (for methods that expect bodies) +// 2. Execute pre-callback (if configured) for request modification/validation +// 3. Convert request to BifrostRequest using the configured converter +// 4. Execute the request through Bifrost (streaming or non-streaming) +// 5. Execute post-callback (if configured) for response modification +// 6. Convert and send the response using the configured response converter +func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + method := string(ctx.Method()) + + // Parse request body into the integration-specific request type + // Note: config validation is performed at startup in RegisterRoutes + req := config.GetRequestTypeInstance() + var rawBody []byte + + // Parse request body based on configuration + if method != fasthttp.MethodGet { + if config.RequestParser != nil { + // Use custom parser (e.g., for multipart/form-data) + if err := config.RequestParser(ctx, req); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to parse request")) + return + } + } else { + // Use default JSON parsing + rawBody = ctx.Request.Body() + if len(rawBody) > 0 { + if err := sonic.Unmarshal(rawBody, req); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "Invalid JSON")) + return + } + } + } + } + + // Execute the request through Bifrost + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, g.handlerStore.ShouldAllowDirectKeys()) + + // Set send back raw response flag for all integration requests + *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeySendBackRawResponse, true) + + // Execute pre-request callback if configured + // This is typically used for extracting data from URL parameters + // or performing request validation after parsing + if config.PreCallback != nil { + if err := config.PreCallback(ctx, bifrostCtx, req); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute pre-request callback: "+err.Error())) + return + } + } + + // Convert the integration-specific request to Bifrost format + bifrostReq, err := config.RequestConverter(req) + if err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to convert request to Bifrost format")) + return + } + if bifrostReq == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Invalid request")) + return + } + if sendRawRequestBody, ok := (*bifrostCtx).Value(schemas.BifrostContextKeyUseRawRequestBody).(bool); ok && sendRawRequestBody { + bifrostReq.SetRawRequestBody(rawBody) + } + + // Extract and parse fallbacks from the request if present + if err := g.extractAndParseFallbacks(req, bifrostReq); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to parse fallbacks: "+err.Error())) + return + } + + // Check if streaming is requested + isStreaming := false + if streamingReq, ok := req.(StreamingRequest); ok { + isStreaming = streamingReq.IsStreamingRequested() + } + + if ctx.UserValue(string(schemas.BifrostContextKeyDirectKey)) != nil { + key, ok := ctx.UserValue(string(schemas.BifrostContextKeyDirectKey)).(schemas.Key) + if ok { + *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeyDirectKey, key) + } + } + + if isStreaming { + g.handleStreamingRequest(ctx, config, bifrostReq, bifrostCtx, cancel) + } else { + defer cancel() // Ensure cleanup on function exit + g.handleNonStreamingRequest(ctx, config, req, bifrostReq, bifrostCtx) + } + } +} + +// handleNonStreamingRequest handles regular (non-streaming) requests +func (g *GenericRouter) handleNonStreamingRequest(ctx *fasthttp.RequestCtx, config RouteConfig, req interface{}, bifrostReq *schemas.BifrostRequest, bifrostCtx *context.Context) { + // Use the cancellable context from ConvertToBifrostContext + // While we can't detect client disconnects until we try to write, having a cancellable context + // allows providers that check ctx.Done() to cancel early if needed. This is less critical than + // streaming requests (where we actively detect write errors), but still provides a mechanism + // for providers to respect cancellation. + requestCtx := *bifrostCtx + + var response interface{} + var err error + + switch { + case bifrostReq.ListModelsRequest != nil: + listModelsResponse, bifrostErr := g.client.ListModelsRequest(requestCtx, bifrostReq.ListModelsRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + if config.PostCallback != nil { + if err := config.PostCallback(ctx, req, listModelsResponse); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute post-request callback")) + return + } + } + + if listModelsResponse == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Bifrost response is nil after post-request callback")) + return + } + + response, err = config.ListModelsResponseConverter(listModelsResponse) + case bifrostReq.TextCompletionRequest != nil: + textCompletionResponse, bifrostErr := g.client.TextCompletionRequest(requestCtx, bifrostReq.TextCompletionRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + // Execute post-request callback if configured + // This is typically used for response modification or additional processing + if config.PostCallback != nil { + if err := config.PostCallback(ctx, req, textCompletionResponse); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute post-request callback")) + return + } + } + + if textCompletionResponse == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Bifrost response is nil after post-request callback")) + return + } + + // Convert Bifrost response to integration-specific format and send + response, err = config.TextResponseConverter(textCompletionResponse) + case bifrostReq.ChatRequest != nil: + chatResponse, bifrostErr := g.client.ChatCompletionRequest(requestCtx, bifrostReq.ChatRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + // Execute post-request callback if configured + // This is typically used for response modification or additional processing + if config.PostCallback != nil { + if err := config.PostCallback(ctx, req, chatResponse); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute post-request callback")) + return + } + } + + if chatResponse == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Bifrost response is nil after post-request callback")) + return + } + + // Convert Bifrost response to integration-specific format and send + response, err = config.ChatResponseConverter(chatResponse) + case bifrostReq.ResponsesRequest != nil: + responsesResponse, bifrostErr := g.client.ResponsesRequest(requestCtx, bifrostReq.ResponsesRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + // Execute post-request callback if configured + // This is typically used for response modification or additional processing + if config.PostCallback != nil { + if err := config.PostCallback(ctx, req, responsesResponse); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute post-request callback")) + return + } + } + + if responsesResponse == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Bifrost response is nil after post-request callback")) + return + } + + // Convert Bifrost response to integration-specific format and send + response, err = config.ResponsesResponseConverter(responsesResponse) + case bifrostReq.EmbeddingRequest != nil: + embeddingResponse, bifrostErr := g.client.EmbeddingRequest(requestCtx, bifrostReq.EmbeddingRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + // Execute post-request callback if configured + // This is typically used for response modification or additional processing + if config.PostCallback != nil { + if err := config.PostCallback(ctx, req, embeddingResponse); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute post-request callback")) + return + } + } + + if embeddingResponse == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Bifrost response is nil after post-request callback")) + return + } + + // Convert Bifrost response to integration-specific format and send + response, err = config.EmbeddingResponseConverter(embeddingResponse) + case bifrostReq.SpeechRequest != nil: + speechResponse, bifrostErr := g.client.SpeechRequest(requestCtx, bifrostReq.SpeechRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + ctx.Response.Header.Set("Content-Type", "audio/mpeg") + ctx.Response.Header.Set("Content-Disposition", "attachment; filename=speech.mp3") + ctx.Response.Header.Set("Content-Length", strconv.Itoa(len(speechResponse.Audio))) + ctx.Response.SetBody(speechResponse.Audio) + return + case bifrostReq.TranscriptionRequest != nil: + transcriptionResponse, bifrostErr := g.client.TranscriptionRequest(requestCtx, bifrostReq.TranscriptionRequest) + if bifrostErr != nil { + g.sendError(ctx, config.ErrorConverter, bifrostErr) + return + } + + // Execute post-request callback if configured + // This is typically used for response modification or additional processing + if config.PostCallback != nil { + if err := config.PostCallback(ctx, req, transcriptionResponse); err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to execute post-request callback")) + return + } + } + + if transcriptionResponse == nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Bifrost response is nil after post-request callback")) + return + } + + // Convert Bifrost response to integration-specific format and send + response, err = config.TranscriptionResponseConverter(transcriptionResponse) + default: + g.sendError(ctx, config.ErrorConverter, newBifrostError(nil, "Invalid request type")) + return + } + + if err != nil { + g.sendError(ctx, config.ErrorConverter, newBifrostError(err, "failed to encode response")) + return + } + + g.sendSuccess(ctx, config.ErrorConverter, response) +} + +// handleStreamingRequest handles streaming requests using Server-Sent Events (SSE) +func (g *GenericRouter) handleStreamingRequest(ctx *fasthttp.RequestCtx, config RouteConfig, bifrostReq *schemas.BifrostRequest, bifrostCtx *context.Context, cancel context.CancelFunc) { + // Set common SSE headers + ctx.SetContentType("text/event-stream") + ctx.Response.Header.Set("Cache-Control", "no-cache") + ctx.Response.Header.Set("Connection", "keep-alive") + ctx.Response.Header.Set("Access-Control-Allow-Origin", "*") + + // Use the cancellable context from ConvertToBifrostContext + // ctx.Done() never fires here in practice: fasthttp.RequestCtx.Done only closes when the whole server shuts down, not when an individual connection drops. + // As a result we'll leave the provider stream running until it naturally completes, even if the client went away (write error, network drop, etc.). + // That keeps goroutines and upstream tokens alive long after the SSE writer has exited. + // + // We now get a cancellable context from ConvertToBifrostContext so we can cancel the upstream stream immediately when the client disconnects. + streamCtx := *bifrostCtx + + var stream chan *schemas.BifrostStream + var bifrostErr *schemas.BifrostError + + // Handle different request types + if bifrostReq.TextCompletionRequest != nil { + stream, bifrostErr = g.client.TextCompletionStreamRequest(streamCtx, bifrostReq.TextCompletionRequest) + } else if bifrostReq.ChatRequest != nil { + stream, bifrostErr = g.client.ChatCompletionStreamRequest(streamCtx, bifrostReq.ChatRequest) + } else if bifrostReq.ResponsesRequest != nil { + stream, bifrostErr = g.client.ResponsesStreamRequest(streamCtx, bifrostReq.ResponsesRequest) + } else if bifrostReq.SpeechRequest != nil { + stream, bifrostErr = g.client.SpeechStreamRequest(streamCtx, bifrostReq.SpeechRequest) + } else if bifrostReq.TranscriptionRequest != nil { + stream, bifrostErr = g.client.TranscriptionStreamRequest(streamCtx, bifrostReq.TranscriptionRequest) + } + + // Get the streaming channel from Bifrost + if bifrostErr != nil { + // Send error in SSE format and cancel stream context since we're not proceeding + cancel() + g.sendStreamError(ctx, config, bifrostErr) + return + } + + // Check if streaming is configured for this route + if config.StreamConfig == nil { + // Cancel stream context since we're not proceeding, and close the stream channel to prevent goroutine leaks + cancel() + // Drain the stream channel to prevent goroutine leaks + go func() { + for range stream { + } + }() + g.sendStreamError(ctx, config, newBifrostError(nil, "streaming is not supported for this integration")) + return + } + + // Handle streaming using the centralized approach + // Pass cancel function so it can be called when the writer exits (errors, completion, etc.) + g.handleStreaming(ctx, config, stream, cancel) +} + +// handleStreaming processes a stream of BifrostResponse objects and sends them as Server-Sent Events (SSE). +// It handles both successful responses and errors in the streaming format. +// +// SSE FORMAT HANDLING: +// +// By default, all responses and errors are sent in the standard SSE format: +// +// data: {"response": "content"}\n\n +// +// However, some providers (like Anthropic) require custom SSE event formats with explicit event types: +// +// event: content_block_delta +// data: {"type": "content_block_delta", "delta": {...}} +// +// event: message_stop +// data: {"type": "message_stop"} +// +// STREAMCONFIG CONVERTER BEHAVIOR: +// +// The StreamConfig.ResponseConverter and StreamConfig.ErrorConverter functions can return: +// +// 1. OBJECTS (default behavior): +// - Return any Go struct/map/interface{} +// - Will be JSON marshaled and wrapped as: data: {json}\n\n +// - Example: return map[string]interface{}{"content": "hello"} +// - Result: data: {"content":"hello"}\n\n +// +// 2. STRINGS (custom SSE format): +// - Return a complete SSE string with custom event types and formatting +// - Will be sent directly without any wrapping or modification +// - Example: return "event: content_block_delta\ndata: {\"type\":\"text\"}\n\n" +// - Result: event: content_block_delta +// data: {"type":"text"} +// +// IMPLEMENTATION GUIDELINES: +// +// For standard providers (OpenAI, etc.): Return objects from converters +// For custom SSE providers (Anthropic, etc.): Return pre-formatted SSE strings +// +// When returning strings, ensure they: +// - Include proper event: lines (if needed) +// - Include data: lines with JSON content +// - End with \n\n for proper SSE formatting +// - Follow the provider's specific SSE event specification +// +// CONTEXT CANCELLATION: +// +// The cancel function is called ONLY when client disconnects are detected via write errors. +// Bifrost handles cleanup internally for normal completion and errors, so we only cancel +// upstream streams when write errors indicate the client has disconnected. +func (g *GenericRouter) handleStreaming(ctx *fasthttp.RequestCtx, config RouteConfig, streamChan chan *schemas.BifrostStream, cancel context.CancelFunc) { + // Use streaming response writer + ctx.Response.SetBodyStreamWriter(func(w *bufio.Writer) { + defer w.Flush() + + includeEventType := false + + // Process streaming responses + for chunk := range streamChan { + if chunk == nil { + continue + } + + if chunk.BifrostResponsesStreamResponse != nil || + (chunk.BifrostError != nil && chunk.BifrostError.ExtraFields.RequestType == schemas.ResponsesStreamRequest) { + includeEventType = true + } + + // Note: We no longer check ctx.Done() here because fasthttp.RequestCtx.Done() + // only closes when the whole server shuts down, not when an individual client disconnects. + // Client disconnects are detected via write errors, which trigger the defer cancel() above. + + // Handle errors + if chunk.BifrostError != nil { + var errorResponse interface{} + var errorJSON []byte + var err error + + // Use stream error converter if available, otherwise fallback to regular error converter + if config.StreamConfig != nil && config.StreamConfig.ErrorConverter != nil { + errorResponse = config.StreamConfig.ErrorConverter(chunk.BifrostError) + } else if config.ErrorConverter != nil { + errorResponse = config.ErrorConverter(chunk.BifrostError) + } else { + // Default error response + errorResponse = map[string]interface{}{ + "error": map[string]interface{}{ + "type": "internal_error", + "message": "An error occurred while processing your request", + }, + } + } + + // Check if the error converter returned a raw SSE string or JSON object + if sseErrorString, ok := errorResponse.(string); ok { + // CUSTOM SSE FORMAT: The converter returned a complete SSE string + // This is used by providers like Anthropic that need custom event types + // Example: "event: error\ndata: {...}\n\n" + if _, err := fmt.Fprint(w, sseErrorString); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } else { + // STANDARD SSE FORMAT: The converter returned an object + // This will be JSON marshaled and wrapped as "data: {json}\n\n" + // Used by most providers (OpenAI, Google, etc.) + errorJSON, err = sonic.Marshal(errorResponse) + if err != nil { + // Fallback to basic error if marshaling fails + basicError := map[string]interface{}{ + "error": map[string]interface{}{ + "type": "internal_error", + "message": "An error occurred while processing your request", + }, + } + if errorJSON, err = sonic.Marshal(basicError); err != nil { + cancel() // Can't send error (client likely disconnected), cancel upstream stream + return + } + } + + // Send error as SSE data + if _, err := fmt.Fprintf(w, "data: %s\n\n", errorJSON); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + + // Flush and return on error + if err := w.Flush(); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + return // End stream on error, Bifrost handles cleanup internally + } else { + // Handle successful responses + // Convert response to integration-specific streaming format + var convertedResponse interface{} + var err error + + switch { + case chunk.BifrostTextCompletionResponse != nil: + convertedResponse, err = config.StreamConfig.TextStreamResponseConverter(chunk.BifrostTextCompletionResponse) + case chunk.BifrostResponsesStreamResponse != nil: + convertedResponse, err = config.StreamConfig.ResponsesStreamResponseConverter(chunk.BifrostResponsesStreamResponse) + case chunk.BifrostChatResponse != nil: + convertedResponse, err = config.StreamConfig.ChatStreamResponseConverter(chunk.BifrostChatResponse) + case chunk.BifrostSpeechStreamResponse != nil: + convertedResponse, err = config.StreamConfig.SpeechStreamResponseConverter(chunk.BifrostSpeechStreamResponse) + case chunk.BifrostTranscriptionStreamResponse != nil: + convertedResponse, err = config.StreamConfig.TranscriptionStreamResponseConverter(chunk.BifrostTranscriptionStreamResponse) + default: + requestType := safeGetRequestType(chunk) + convertedResponse, err = nil, fmt.Errorf("no response converter found for request type: %s", requestType) + } + + if err != nil { + // Log conversion error but continue processing + log.Printf("Failed to convert streaming response: %v", err) + continue + } + + // Check if the converter returned a raw SSE string or JSON object + if sseString, ok := convertedResponse.(string); ok { + // CUSTOM SSE FORMAT: The converter returned a complete SSE string + // This is used by providers like Anthropic that need custom event types + // Example: "event: content_block_delta\ndata: {...}\n\n" + if _, err := fmt.Fprint(w, sseString); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } else { + // Handle different streaming formats based on request type + if includeEventType { + // OPENAI RESPONSES FORMAT: Use event: and data: lines for OpenAI responses API compatibility + eventType := "" + if chunk.BifrostResponsesStreamResponse != nil { + eventType = string(chunk.BifrostResponsesStreamResponse.Type) + } + + // Send event line if available + if eventType != "" { + if _, err := fmt.Fprintf(w, "event: %s\n", eventType); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + + // Send data line + responseJSON, err := sonic.Marshal(convertedResponse) + if err != nil { + // Log JSON marshaling error but continue processing + log.Printf("Failed to marshal streaming response: %v", err) + continue + } + + if _, err := fmt.Fprintf(w, "data: %s\n\n", responseJSON); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } else { + // STANDARD SSE FORMAT: The converter returned an object + // This will be JSON marshaled and wrapped as "data: {json}\n\n" + // Used by most providers (OpenAI chat/completions, Google, etc.) + responseJSON, err := sonic.Marshal(convertedResponse) + if err != nil { + // Log JSON marshaling error but continue processing + log.Printf("Failed to marshal streaming response: %v", err) + continue + } + + // Send as SSE data + if _, err := fmt.Fprintf(w, "data: %s\n\n", responseJSON); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + } + + // Flush immediately to send the chunk + if err := w.Flush(); err != nil { + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + } + + // Send [DONE] marker only for non-responses APIs (OpenAI responses API doesn't use [DONE]) + if !includeEventType && config.Type != RouteConfigTypeGenAI { + if _, err := fmt.Fprint(w, "data: [DONE]\n\n"); err != nil { + log.Printf("Failed to write SSE done marker: %v", err) + cancel() // Client disconnected (write error), cancel upstream stream + return + } + } + // Note: OpenAI responses API doesn't use [DONE] marker, it ends when the stream closes + // Stream completed normally, Bifrost handles cleanup internally + }) +} diff --git a/transports/bifrost-http/integrations/utils.go b/transports/bifrost-http/integrations/utils.go new file mode 100644 index 000000000..9929fa9d8 --- /dev/null +++ b/transports/bifrost-http/integrations/utils.go @@ -0,0 +1,321 @@ +package integrations + +import ( + "bytes" + "fmt" + "log" + "reflect" + "strings" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +var availableIntegrations = []string{ + "openai", + "anthropic", + "genai", + "litellm", + "langchain", +} + +// newBifrostError wraps a standard error into a BifrostError with IsBifrostError set to false. +// This helper function reduces code duplication when handling non-Bifrost errors. +func newBifrostError(err error, message string) *schemas.BifrostError { + if err == nil { + return &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: message, + }, + } + } + + return &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{ + Message: message, + Error: err, + }, + } +} + +// safeGetRequestType safely obtains the request type from a BifrostStream chunk. +// It checks multiple sources in order of preference: +// 1. Response ExtraFields if any response is available +// 2. BifrostError ExtraFields if error is available and not nil +// 3. Falls back to "unknown" if no source is available +func safeGetRequestType(chunk *schemas.BifrostStream) string { + if chunk == nil { + return "unknown" + } + + // Try to get RequestType from response ExtraFields (preferred source) + switch { + case chunk.BifrostTextCompletionResponse != nil: + return string(chunk.BifrostTextCompletionResponse.ExtraFields.RequestType) + case chunk.BifrostChatResponse != nil: + return string(chunk.BifrostChatResponse.ExtraFields.RequestType) + case chunk.BifrostResponsesStreamResponse != nil: + return string(chunk.BifrostResponsesStreamResponse.ExtraFields.RequestType) + case chunk.BifrostSpeechStreamResponse != nil: + return string(chunk.BifrostSpeechStreamResponse.ExtraFields.RequestType) + case chunk.BifrostTranscriptionStreamResponse != nil: + return string(chunk.BifrostTranscriptionStreamResponse.ExtraFields.RequestType) + } + + // Try to get RequestType from error ExtraFields (fallback) + if chunk.BifrostError != nil && chunk.BifrostError.ExtraFields.RequestType != "" { + return string(chunk.BifrostError.ExtraFields.RequestType) + } + + // Final fallback + return "unknown" +} + +// extractHeadersFromRequest extracts headers from the request and returns them as a map. +// It uses the fasthttp.RequestCtx.Header.All() method to iterate over all headers. +func extractHeadersFromRequest(ctx *fasthttp.RequestCtx) map[string][]string { + headers := make(map[string][]string) + + for key, value := range ctx.Request.Header.All() { + keyStr := string(key) + headers[keyStr] = append(headers[keyStr], string(value)) + } + + return headers +} + +// extractExactPath returns the request path *after* the integration prefix, +// preserving the original query string exactly as sent by the client. +// +// Example: +// +// /openai/v1/chat/completions?model=gpt-4o -> v1/chat/completions?model=gpt-4o +func extractExactPath(ctx *fasthttp.RequestCtx) string { + // ctx.Path() returns only the path (no query) as a []byte backed by fasthttp’s internal buffers. + // Treat it as read-only; don’t append to it directly. + path := ctx.Path() // e.g. "/openai/v1/chat/completions" + + // Strip the integration prefix only if it’s at the start. + for _, integration := range availableIntegrations { + if bytes.HasPrefix(path, []byte("/"+integration+"/")) { + path = path[len("/"+integration+"/"):] + break + } + } + + // Raw query string as sent by client (unparsed, preserves ordering/duplicates/encoding). + q := ctx.URI().QueryString() // e.g. "model=gpt-4o&stream=true" + + if len(q) == 0 { + // No query β†’ just return the (possibly trimmed) path. + return string(path) + } + + // --- Build "?" efficiently and safely --- + // + // Why not do: return string(path) + "?" + string(q) ? + // - That allocates multiple temporary strings and may copy data more than necessary. + // + // Why not append into 'path' directly? + // - 'path' may alias fasthttp’s internal buffers; mutating/expanding it could corrupt request state. + // + // We instead allocate a new buffer with exact capacity and copy into it, + // staying in []byte until the final string conversion (1 allocation for the new slice). + out := make([]byte, 0, len(path)+1+len(q)) // pre-size: path + "?" + query + out = append(out, path...) // copy path bytes + out = append(out, '?') // separator + out = append(out, q...) // copy raw query bytes + + return string(out) +} + +// sendStreamError sends an error in streaming format using the stream error converter if available +func (g *GenericRouter) sendStreamError(ctx *fasthttp.RequestCtx, config RouteConfig, bifrostErr *schemas.BifrostError) { + var errorResponse interface{} + + // Use stream error converter if available, otherwise fallback to regular error converter + if config.StreamConfig != nil && config.StreamConfig.ErrorConverter != nil { + errorResponse = config.StreamConfig.ErrorConverter(bifrostErr) + } else { + errorResponse = config.ErrorConverter(bifrostErr) + } + + errorJSON, err := sonic.Marshal(map[string]interface{}{ + "error": errorResponse, + }) + if err != nil { + log.Printf("Failed to marshal error for SSE: %v", err) + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + return + } + + if _, err := fmt.Fprintf(ctx, "data: %s\n\n", errorJSON); err != nil { + log.Printf("Failed to write SSE error: %v", err) + } +} + +// sendError sends an error response with the appropriate status code and JSON body. +// It handles different error types (string, error interface, or arbitrary objects). +func (g *GenericRouter) sendError(ctx *fasthttp.RequestCtx, errorConverter ErrorConverter, bifrostErr *schemas.BifrostError) { + if bifrostErr.StatusCode != nil { + ctx.SetStatusCode(*bifrostErr.StatusCode) + } else { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + } + ctx.SetContentType("application/json") + + errorBody, err := sonic.Marshal(errorConverter(bifrostErr)) + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + ctx.SetBodyString(fmt.Sprintf("failed to encode error response: %v", err)) + return + } + + ctx.SetBody(errorBody) +} + +// sendSuccess sends a successful response with HTTP 200 status and JSON body. +func (g *GenericRouter) sendSuccess(ctx *fasthttp.RequestCtx, errorConverter ErrorConverter, response interface{}) { + ctx.SetStatusCode(fasthttp.StatusOK) + ctx.SetContentType("application/json") + + responseBody, err := sonic.Marshal(response) + if err != nil { + g.sendError(ctx, errorConverter, newBifrostError(err, "failed to encode response")) + return + } + + ctx.SetBody(responseBody) +} + +// extractAndParseFallbacks extracts fallbacks from the integration request and adds them to the BifrostRequest +func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *schemas.BifrostRequest) error { + // Check if the request has a fallbacks field ([]string) + fallbacks, err := g.extractFallbacksFromRequest(req) + if err != nil { + return fmt.Errorf("failed to extract fallbacks: %w", err) + } + + if len(fallbacks) == 0 { + return nil // No fallbacks to process + } + + provider, _, _ := bifrostReq.GetRequestFields() + + // Parse fallbacks from strings to Fallback structs + parsedFallbacks := make([]schemas.Fallback, 0, len(fallbacks)) + for _, fallbackStr := range fallbacks { + if fallbackStr == "" { + continue // Skip empty strings + } + + // Use ParseModelString to extract provider and model + provider, model := schemas.ParseModelString(fallbackStr, provider) + + parsedFallback := schemas.Fallback{ + Provider: provider, + Model: model, + } + parsedFallbacks = append(parsedFallbacks, parsedFallback) + } + + if len(parsedFallbacks) == 0 { + return nil // No valid fallbacks found + } + + // Add fallbacks to the main BifrostRequest + bifrostReq.SetFallbacks(parsedFallbacks) + + // Also add fallbacks to the specific request type if it exists + switch bifrostReq.RequestType { + case schemas.TextCompletionRequest, schemas.TextCompletionStreamRequest: + if bifrostReq.TextCompletionRequest != nil { + bifrostReq.TextCompletionRequest.Fallbacks = parsedFallbacks + } + case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest: + if bifrostReq.ChatRequest != nil { + bifrostReq.ChatRequest.Fallbacks = parsedFallbacks + } + case schemas.ResponsesRequest, schemas.ResponsesStreamRequest: + if bifrostReq.ResponsesRequest != nil { + bifrostReq.ResponsesRequest.Fallbacks = parsedFallbacks + } + case schemas.EmbeddingRequest: + if bifrostReq.EmbeddingRequest != nil { + bifrostReq.EmbeddingRequest.Fallbacks = parsedFallbacks + } + case schemas.SpeechRequest, schemas.SpeechStreamRequest: + if bifrostReq.SpeechRequest != nil { + bifrostReq.SpeechRequest.Fallbacks = parsedFallbacks + } + case schemas.TranscriptionRequest, schemas.TranscriptionStreamRequest: + if bifrostReq.TranscriptionRequest != nil { + bifrostReq.TranscriptionRequest.Fallbacks = parsedFallbacks + } + } + + return nil +} + +// extractFallbacksFromRequest uses reflection to extract fallbacks field from any request type +func (g *GenericRouter) extractFallbacksFromRequest(req interface{}) ([]string, error) { + if req == nil { + return nil, nil + } + + // Try to use reflection to find a "fallbacks" field + reqValue := reflect.ValueOf(req) + if reqValue.Kind() == reflect.Ptr { + reqValue = reqValue.Elem() + } + + if reqValue.Kind() != reflect.Struct { + return nil, nil // Not a struct, no fallbacks + } + + // Look for the "fallbacks" field + fallbacksField := reqValue.FieldByName("fallbacks") + if !fallbacksField.IsValid() { + return nil, nil // No fallbacks field found + } + + // Handle different types of fallbacks field + switch fallbacksField.Kind() { + case reflect.Slice: + if fallbacksField.Type().Elem().Kind() == reflect.String { + // []string case + fallbacks := make([]string, fallbacksField.Len()) + for i := 0; i < fallbacksField.Len(); i++ { + fallbacks[i] = fallbacksField.Index(i).String() + } + return fallbacks, nil + } + case reflect.String: + // Single string case - treat as one fallback + return []string{fallbacksField.String()}, nil + } + + return nil, nil +} + +// isAnthropicAPIKeyAuth checks if the request uses standard API key authentication. +// Returns true for API key auth (x-api-key header), false for OAuth (Bearer sk-ant-oat*). +// This is required for Claude Code specifically, which may use OAuth authentication. +// Default behavior is to assume API mode when neither x-api-key nor OAuth token is present. +func isAnthropicAPIKeyAuth(ctx *fasthttp.RequestCtx) bool { + // If x-api-key header is present - this is definitely API mode + if apiKey := string(ctx.Request.Header.Peek("x-api-key")); apiKey != "" { + return true + } + // Check for OAuth token in Authorization header + if authHeader := string(ctx.Request.Header.Peek("Authorization")); authHeader != "" { + if strings.HasPrefix(strings.ToLower(authHeader), "bearer sk-ant-oat") { + return false // OAuth mode, NOT API + } + } + // Default to API mode + return true +} diff --git a/transports/bifrost-http/lib/account.go b/transports/bifrost-http/lib/account.go new file mode 100644 index 000000000..59c6f579d --- /dev/null +++ b/transports/bifrost-http/lib/account.go @@ -0,0 +1,115 @@ +// Package lib provides core functionality for the Bifrost HTTP service, +// including context propagation, header management, and integration with monitoring systems. +package lib + +import ( + "context" + "fmt" + + "github.com/maximhq/bifrost/core/schemas" +) + +// BaseAccount implements the Account interface for Bifrost. +// It manages provider configurations using a in-memory store for persistent storage. +// All data processing (environment variables, key configs) is done upfront in the store. +type BaseAccount struct { + store *Config // store for in-memory configuration +} + +// NewBaseAccount creates a new BaseAccount with the given store +func NewBaseAccount(store *Config) *BaseAccount { + return &BaseAccount{ + store: store, + } +} + +// GetConfiguredProviders returns a list of all configured providers. +// Implements the Account interface. +func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { + if baseAccount.store == nil { + return nil, fmt.Errorf("store not initialized") + } + + return baseAccount.store.GetAllProviders() +} + +// GetKeysForProvider returns the API keys configured for a specific provider. +// Keys are already processed (environment variables resolved) by the store. +// Implements the Account interface. +func (baseAccount *BaseAccount) GetKeysForProvider(ctx *context.Context, providerKey schemas.ModelProvider) ([]schemas.Key, error) { + if baseAccount.store == nil { + return nil, fmt.Errorf("store not initialized") + } + + config, err := baseAccount.store.GetProviderConfigRaw(providerKey) + if err != nil { + return nil, err + } + + keys := config.Keys + + if baseAccount.store.ClientConfig.EnableGovernance { + if v := (*ctx).Value(schemas.BifrostContextKey("bf-governance-include-only-keys")); v != nil { + if includeOnlyKeys, ok := v.([]string); ok { + if len(includeOnlyKeys) == 0 { + // header present but empty means "no keys allowed" + keys = nil + } else { + set := make(map[string]struct{}, len(includeOnlyKeys)) + for _, id := range includeOnlyKeys { + set[id] = struct{}{} + } + filtered := make([]schemas.Key, 0, len(keys)) + for _, key := range keys { + if _, ok := set[key.ID]; ok { + filtered = append(filtered, key) + } + } + keys = filtered + } + } + } + } + + return keys, nil +} + +// GetConfigForProvider returns the complete configuration for a specific provider. +// Configuration is already fully processed (environment variables, key configs) by the store. +// Implements the Account interface. +func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { + if baseAccount.store == nil { + return nil, fmt.Errorf("store not initialized") + } + + config, err := baseAccount.store.GetProviderConfigRaw(providerKey) + if err != nil { + return nil, err + } + + providerConfig := &schemas.ProviderConfig{} + + if config.ProxyConfig != nil { + providerConfig.ProxyConfig = config.ProxyConfig + } + + if config.NetworkConfig != nil { + providerConfig.NetworkConfig = *config.NetworkConfig + } else { + providerConfig.NetworkConfig = schemas.DefaultNetworkConfig + } + + if config.ConcurrencyAndBufferSize != nil { + providerConfig.ConcurrencyAndBufferSize = *config.ConcurrencyAndBufferSize + } else { + providerConfig.ConcurrencyAndBufferSize = schemas.DefaultConcurrencyAndBufferSize + } + + providerConfig.SendBackRawResponse = config.SendBackRawResponse + + if config.CustomProviderConfig != nil { + providerConfig.CustomProviderConfig = config.CustomProviderConfig + } + + return providerConfig, nil +} diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go new file mode 100644 index 000000000..d9d3eadc7 --- /dev/null +++ b/transports/bifrost-http/lib/config.go @@ -0,0 +1,2712 @@ +// Package lib provides core functionality for the Bifrost HTTP service, +// including context propagation, header management, and integration with monitoring systems. +package lib + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/encrypt" + "github.com/maximhq/bifrost/framework/logstore" + "github.com/maximhq/bifrost/framework/modelcatalog" + "github.com/maximhq/bifrost/framework/vectorstore" + "github.com/maximhq/bifrost/plugins/semanticcache" + "gorm.io/gorm" +) + +// HandlerStore provides access to runtime configuration values for handlers. +// This interface allows handlers to access only the configuration they need +// without depending on the entire ConfigStore, improving testability and decoupling. +type HandlerStore interface { + // ShouldAllowDirectKeys returns whether direct API keys in headers are allowed + ShouldAllowDirectKeys() bool +} + +// ConfigData represents the configuration data for the Bifrost HTTP transport. +// It contains the client configuration, provider configurations, MCP configuration, +// vector store configuration, config store configuration, and logs store configuration. +type ConfigData struct { + Client *configstore.ClientConfig `json:"client"` + EncryptionKey string `json:"encryption_key"` + AuthConfig *configstore.AuthConfig `json:"auth_config,omitempty"` + Providers map[string]configstore.ProviderConfig `json:"providers"` + FrameworkConfig *framework.FrameworkConfig `json:"framework,omitempty"` + MCP *schemas.MCPConfig `json:"mcp,omitempty"` + Governance *configstore.GovernanceConfig `json:"governance,omitempty"` + VectorStoreConfig *vectorstore.Config `json:"vector_store,omitempty"` + ConfigStoreConfig *configstore.Config `json:"config_store,omitempty"` + LogsStoreConfig *logstore.Config `json:"logs_store,omitempty"` + Plugins []*schemas.PluginConfig `json:"plugins,omitempty"` +} + +// UnmarshalJSON umarshals the ConfigData from JSON using internal unmarshallers +// for VectorStoreConfig, ConfigStoreConfig, and LogsStoreConfig to ensure proper +// type safety and configuration parsing. +func (cd *ConfigData) UnmarshalJSON(data []byte) error { + // First, unmarshal into a temporary struct to get all fields except the complex configs + type TempConfigData struct { + FrameworkConfig json.RawMessage `json:"framework,omitempty"` + Client *configstore.ClientConfig `json:"client"` + EncryptionKey string `json:"encryption_key"` + AuthConfig *configstore.AuthConfig `json:"auth_config,omitempty"` + Providers map[string]configstore.ProviderConfig `json:"providers"` + MCP *schemas.MCPConfig `json:"mcp,omitempty"` + Governance *configstore.GovernanceConfig `json:"governance,omitempty"` + VectorStoreConfig json.RawMessage `json:"vector_store,omitempty"` + ConfigStoreConfig json.RawMessage `json:"config_store,omitempty"` + LogsStoreConfig json.RawMessage `json:"logs_store,omitempty"` + Plugins []*schemas.PluginConfig `json:"plugins,omitempty"` + } + + var temp TempConfigData + if err := json.Unmarshal(data, &temp); err != nil { + return fmt.Errorf("failed to unmarshal config data: %w", err) + } + + // Set simple fields + cd.Client = temp.Client + cd.EncryptionKey = temp.EncryptionKey + cd.AuthConfig = temp.AuthConfig + cd.Providers = temp.Providers + cd.MCP = temp.MCP + cd.Governance = temp.Governance + cd.Plugins = temp.Plugins + + // Parse VectorStoreConfig using its internal unmarshaler + if len(temp.VectorStoreConfig) > 0 { + var vectorStoreConfig vectorstore.Config + if err := json.Unmarshal(temp.VectorStoreConfig, &vectorStoreConfig); err != nil { + return fmt.Errorf("failed to unmarshal vector store config: %w", err) + } + cd.VectorStoreConfig = &vectorStoreConfig + } + + // Parse FrameworkConfig using its internal unmarshaler + if len(temp.FrameworkConfig) > 0 { + var frameworkConfig framework.FrameworkConfig + if err := json.Unmarshal(temp.FrameworkConfig, &frameworkConfig); err != nil { + return fmt.Errorf("failed to unmarshal framework config: %w", err) + } + cd.FrameworkConfig = &frameworkConfig + } + + // Parse ConfigStoreConfig using its internal unmarshaler + if len(temp.ConfigStoreConfig) > 0 { + var configStoreConfig configstore.Config + if err := json.Unmarshal(temp.ConfigStoreConfig, &configStoreConfig); err != nil { + return fmt.Errorf("failed to unmarshal config store config: %w", err) + } + cd.ConfigStoreConfig = &configStoreConfig + } + + // Parse LogsStoreConfig using its internal unmarshaler + if len(temp.LogsStoreConfig) > 0 { + var logsStoreConfig logstore.Config + if err := json.Unmarshal(temp.LogsStoreConfig, &logsStoreConfig); err != nil { + return fmt.Errorf("failed to unmarshal logs store config: %w", err) + } + cd.LogsStoreConfig = &logsStoreConfig + } + return nil +} + +// Config represents a high-performance in-memory configuration store for Bifrost. +// It provides thread-safe access to provider configurations with database persistence. +// +// Features: +// - Pure in-memory storage for ultra-fast access +// - Environment variable processing for API keys and key-level configurations +// - Thread-safe operations with read-write mutexes +// - Real-time configuration updates via HTTP API +// - Automatic database persistence for all changes +// - Support for provider-specific key configurations (Azure, Vertex, Bedrock) +// - Lock-free plugin reads via atomic.Pointer for minimal hot-path latency +type Config struct { + Mu sync.RWMutex // Exported for direct access from handlers (governance plugin) + muMCP sync.RWMutex + client *bifrost.Bifrost + + configPath string + + // Stores + ConfigStore configstore.ConfigStore + VectorStore vectorstore.VectorStore + LogsStore logstore.LogStore + + // In-memory storage + ClientConfig configstore.ClientConfig + Providers map[schemas.ModelProvider]configstore.ProviderConfig + MCPConfig *schemas.MCPConfig + GovernanceConfig *configstore.GovernanceConfig + FrameworkConfig *framework.FrameworkConfig + + // Track which keys come from environment variables + EnvKeys map[string][]configstore.EnvKeyInfo + + // Plugin configs - atomic for lock-free reads with CAS updates + Plugins atomic.Pointer[[]schemas.Plugin] + + // Plugin configs from config file/database + PluginConfigs []*schemas.PluginConfig + + // Pricing manager + PricingManager *modelcatalog.ModelCatalog +} + +var DefaultClientConfig = configstore.ClientConfig{ + DropExcessRequests: false, + PrometheusLabels: []string{}, + InitialPoolSize: schemas.DefaultInitialPoolSize, + EnableLogging: true, + DisableContentLogging: false, + EnableGovernance: true, + EnforceGovernanceHeader: false, + AllowDirectKeys: false, + AllowedOrigins: []string{}, + MaxRequestBodySizeMB: 100, + EnableLiteLLMFallbacks: false, +} + +// initializeEncryption initializes the encryption key +func (c *Config) initializeEncryption(configKey string) error { + encryptionKey := "" + if configKey != "" { + if strings.HasPrefix(configKey, "env.") { + var err error + if encryptionKey, _, err = c.processEnvValue(configKey); err != nil { + return fmt.Errorf("failed to process encryption key: %w", err) + } + } else { + logger.Warn("encryption_key should reference an environment variable (env.VAR_NAME) rather than storing the key directly in the config file") + encryptionKey = configKey + } + } + if encryptionKey == "" { + if os.Getenv("BIFROST_ENCRYPTION_KEY") != "" { + encryptionKey = os.Getenv("BIFROST_ENCRYPTION_KEY") + } + } + encrypt.Init(encryptionKey, logger) + return nil +} + +// LoadConfig loads initial configuration from a JSON config file into memory +// with full preprocessing including environment variable resolution and key config parsing. +// All processing is done upfront to ensure zero latency when retrieving data. +// +// If the config file doesn't exist, the system starts with default configuration +// and users can add providers dynamically via the HTTP API. +// +// This method handles: +// - JSON config file parsing +// - Environment variable substitution for API keys (env.VARIABLE_NAME) +// - Key-level config processing for Azure, Vertex, and Bedrock (Endpoint, APIVersion, ProjectID, Region, AuthCredentials) +// - Case conversion for provider names (e.g., "OpenAI" -> "openai") +// - In-memory storage for ultra-fast access during request processing +// - Graceful handling of missing config files +func LoadConfig(ctx context.Context, configDirPath string) (*Config, error) { + // Initialize separate database connections for optimal performance at scale + configFilePath := filepath.Join(configDirPath, "config.json") + configDBPath := filepath.Join(configDirPath, "config.db") + logsDBPath := filepath.Join(configDirPath, "logs.db") + // Initialize config + config := &Config{ + configPath: configFilePath, + EnvKeys: make(map[string][]configstore.EnvKeyInfo), + Providers: make(map[schemas.ModelProvider]configstore.ProviderConfig), + Plugins: atomic.Pointer[[]schemas.Plugin]{}, + } + // Getting absolute path for config file + absConfigFilePath, err := filepath.Abs(configFilePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for config file: %w", err) + } + // Check if config file exists + data, err := os.ReadFile(configFilePath) + if err != nil { + // If config file doesn't exist, we will directly use the config store (create one if it doesn't exist) + if os.IsNotExist(err) { + logger.Info("config file not found at path: %s, initializing with default values", absConfigFilePath) + // Initializing with default values + config.ConfigStore, err = configstore.NewConfigStore(ctx, &configstore.Config{ + Enabled: true, + Type: configstore.ConfigStoreTypeSQLite, + Config: &configstore.SQLiteConfig{ + Path: configDBPath, + }, + }, logger) + if err != nil { + return nil, fmt.Errorf("failed to initialize config store: %w", err) + } + // Checking if client config already exist + clientConfig, err := config.ConfigStore.GetClientConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get client config: %w", err) + } + if clientConfig == nil { + clientConfig = &DefaultClientConfig + } else { + // For backward compatibility, we need to handle cases where config is already present but max request body size is not set + if clientConfig.MaxRequestBodySizeMB == 0 { + clientConfig.MaxRequestBodySizeMB = DefaultClientConfig.MaxRequestBodySizeMB + } + } + err = config.ConfigStore.UpdateClientConfig(ctx, clientConfig) + if err != nil { + return nil, fmt.Errorf("failed to update client config: %w", err) + } + config.ClientConfig = *clientConfig + // Checking if log store config already exist + logStoreConfig, err := config.ConfigStore.GetLogsStoreConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get logs store config: %w", err) + } + // Still consider the back + if logStoreConfig == nil { + logStoreConfig = &logstore.Config{ + Enabled: true, + Type: logstore.LogStoreTypeSQLite, + Config: &logstore.SQLiteConfig{ + Path: logsDBPath, + }, + } + } + // Initializing logs store + config.LogsStore, err = logstore.NewLogStore(ctx, logStoreConfig, logger) + if err != nil { + if logStoreConfig.Type == logstore.LogStoreTypeSQLite && os.IsNotExist(err) && logStoreConfig.Config.(*logstore.SQLiteConfig).Path != logsDBPath { + logger.Warn("failed to locate logstore file at path: %s: %v. Creating new one at path: %s", logStoreConfig.Config, err, logsDBPath) + // Then we will try to create a new one + logStoreConfig = &logstore.Config{ + Enabled: true, + Type: logstore.LogStoreTypeSQLite, + Config: &logstore.SQLiteConfig{ + Path: logsDBPath, + }, + } + config.LogsStore, err = logstore.NewLogStore(ctx, logStoreConfig, logger) + if err != nil { + return nil, fmt.Errorf("failed to initialize logs store: %v", err) + } + } else { + return nil, fmt.Errorf("failed to initialize logs store: %v", err) + } + } + // Checking if path is present and accessible or not + logger.Info("logs store initialized.") + err = config.ConfigStore.UpdateLogsStoreConfig(ctx, logStoreConfig) + if err != nil { + return nil, fmt.Errorf("failed to update logs store config: %w", err) + } + // No providers in database, auto-detect from environment + providers, err := config.ConfigStore.GetProvidersConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get providers config: %w", err) + } + if providers == nil { + config.autoDetectProviders(ctx) + providers = config.Providers + // Store providers config in database + err = config.ConfigStore.UpdateProvidersConfig(ctx, providers) + if err != nil { + return nil, fmt.Errorf("failed to update providers config: %w", err) + } + } else { + processedProviders := make(map[schemas.ModelProvider]configstore.ProviderConfig) + for providerKey, dbProvider := range providers { + provider := schemas.ModelProvider(providerKey) + // Convert database keys to schemas.Key + keys := make([]schemas.Key, len(dbProvider.Keys)) + for i, dbKey := range dbProvider.Keys { + keys[i] = schemas.Key{ + ID: dbKey.ID, // Key ID is passed in dbKey, not ID + Name: dbKey.Name, + Value: dbKey.Value, + Models: dbKey.Models, + Weight: dbKey.Weight, + AzureKeyConfig: dbKey.AzureKeyConfig, + VertexKeyConfig: dbKey.VertexKeyConfig, + BedrockKeyConfig: dbKey.BedrockKeyConfig, + } + + } + providerConfig := configstore.ProviderConfig{ + Keys: keys, + NetworkConfig: dbProvider.NetworkConfig, + ConcurrencyAndBufferSize: dbProvider.ConcurrencyAndBufferSize, + ProxyConfig: dbProvider.ProxyConfig, + SendBackRawResponse: dbProvider.SendBackRawResponse, + CustomProviderConfig: dbProvider.CustomProviderConfig, + } + if err := ValidateCustomProvider(providerConfig, provider); err != nil { + logger.Warn("invalid custom provider config for %s: %v", provider, err) + continue + } + processedProviders[provider] = providerConfig + } + config.Providers = processedProviders + } + // Loading governance config + var governanceConfig *configstore.GovernanceConfig + if config.ConfigStore != nil { + governanceConfig, err = config.ConfigStore.GetGovernanceConfig(ctx) + if err != nil { + logger.Warn("failed to get governance config from store: %v", err) + } + } + if governanceConfig != nil { + config.GovernanceConfig = governanceConfig + } + // Updating auth config if present in config + // Checking if MCP config already exists + mcpConfig, err := config.ConfigStore.GetMCPConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get MCP config: %w", err) + } + if mcpConfig == nil { + if err := config.processMCPEnvVars(); err != nil { + logger.Warn("failed to process MCP env vars: %v", err) + } + if config.ConfigStore != nil && config.MCPConfig != nil { + for _, clientConfig := range config.MCPConfig.ClientConfigs { + if err := config.ConfigStore.CreateMCPClientConfig(ctx, clientConfig, config.EnvKeys); err != nil { + logger.Warn("failed to create MCP client config: %v", err) + continue + } + } + // Refresh from store to ensure parity with persisted state + if mcpConfig, err = config.ConfigStore.GetMCPConfig(ctx); err != nil { + return nil, fmt.Errorf("failed to get MCP config after update: %w", err) + } + config.MCPConfig = mcpConfig + } + } else { + // Use the saved config from the store + config.MCPConfig = mcpConfig + } + // Checking if plugins already exist + plugins, err := config.ConfigStore.GetPlugins(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get plugins: %w", err) + } + if plugins == nil { + config.PluginConfigs = []*schemas.PluginConfig{} + } else { + config.PluginConfigs = make([]*schemas.PluginConfig, len(plugins)) + for i, plugin := range plugins { + pluginConfig := &schemas.PluginConfig{ + Name: plugin.Name, + Enabled: plugin.Enabled, + Config: plugin.Config, + Path: plugin.Path, + } + if plugin.Name == semanticcache.PluginName { + if err := config.AddProviderKeysToSemanticCacheConfig(pluginConfig); err != nil { + logger.Warn("failed to add provider keys to semantic cache config: %v", err) + } + } + config.PluginConfigs[i] = pluginConfig + } + } + // Load environment variable tracking + var dbEnvKeys map[string][]configstore.EnvKeyInfo + if dbEnvKeys, err = config.ConfigStore.GetEnvKeys(ctx); err != nil { + return nil, err + } + config.EnvKeys = make(map[string][]configstore.EnvKeyInfo) + for envVar, dbEnvKey := range dbEnvKeys { + for _, dbEnvKey := range dbEnvKey { + config.EnvKeys[envVar] = append(config.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: dbEnvKey.EnvVar, + Provider: dbEnvKey.Provider, + KeyType: dbEnvKey.KeyType, + ConfigPath: dbEnvKey.ConfigPath, + KeyID: dbEnvKey.KeyID, + }) + } + } + err = config.ConfigStore.UpdateEnvKeys(ctx, config.EnvKeys) + if err != nil { + return nil, fmt.Errorf("failed to update env keys: %w", err) + } + // Fetching framework config if present + frameworkConfig, err := config.ConfigStore.GetFrameworkConfig(ctx) + if err != nil { + logger.Warn("failed to get framework config from store: %v", err) + } + pricingConfig := &modelcatalog.Config{} + if frameworkConfig != nil && frameworkConfig.PricingURL != nil { + pricingConfig.PricingURL = frameworkConfig.PricingURL + } else { + pricingConfig.PricingURL = bifrost.Ptr(modelcatalog.DefaultPricingURL) + } + if frameworkConfig != nil && frameworkConfig.PricingSyncInterval != nil && *frameworkConfig.PricingSyncInterval > 0 { + syncDuration := time.Duration(*frameworkConfig.PricingSyncInterval) * time.Second + pricingConfig.PricingSyncInterval = &syncDuration + } else { + pricingConfig.PricingSyncInterval = bifrost.Ptr(modelcatalog.DefaultPricingSyncInterval) + } + // Updating DB with latest config + configID := uint(0) + if frameworkConfig != nil { + configID = frameworkConfig.ID + } + var durationSec int64 + if pricingConfig.PricingSyncInterval != nil { + durationSec = int64((*pricingConfig.PricingSyncInterval).Seconds()) + } else { + d := modelcatalog.DefaultPricingSyncInterval + durationSec = int64(d.Seconds()) + } + logger.Debug("updating framework config with duration: %d", durationSec) + err = config.ConfigStore.UpdateFrameworkConfig(ctx, &configstoreTables.TableFrameworkConfig{ + ID: configID, + PricingURL: pricingConfig.PricingURL, + PricingSyncInterval: bifrost.Ptr(durationSec), + }) + if err != nil { + return nil, fmt.Errorf("failed to update framework config: %w", err) + } + config.FrameworkConfig = &framework.FrameworkConfig{ + Pricing: pricingConfig, + } + // Initializing pricing manager + pricingManager, err := modelcatalog.Init(ctx, pricingConfig, config.ConfigStore, logger) + if err != nil { + logger.Warn("failed to initialize pricing manager: %v", err) + } + config.PricingManager = pricingManager + // We check the encryption key is present in the environment variables + encryptionKey := "" + if os.Getenv("BIFROST_ENCRYPTION_KEY") != "" { + encryptionKey = os.Getenv("BIFROST_ENCRYPTION_KEY") + } + if err := config.initializeEncryption(encryptionKey); err != nil { + return nil, fmt.Errorf("failed to initialize encryption: %w", err) + } + return config, nil + } + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // If config file exists, we will use it to only bootstrap config tables. + + logger.Info("loading configuration from: %s", absConfigFilePath) + + var configData ConfigData + if err := json.Unmarshal(data, &configData); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Initializing config store + if configData.ConfigStoreConfig != nil && configData.ConfigStoreConfig.Enabled { + config.ConfigStore, err = configstore.NewConfigStore(ctx, configData.ConfigStoreConfig, logger) + if err != nil { + return nil, err + } + logger.Info("config store initialized") + } + + // Initializing log store + if configData.LogsStoreConfig != nil && configData.LogsStoreConfig.Enabled { + config.LogsStore, err = logstore.NewLogStore(ctx, configData.LogsStoreConfig, logger) + if err != nil { + return nil, err + } + logger.Info("logs store initialized") + } + + // Initializing vector store + if configData.VectorStoreConfig != nil && configData.VectorStoreConfig.Enabled { + logger.Info("connecting to vectorstore") + // Checking type of the store + config.VectorStore, err = vectorstore.NewVectorStore(ctx, configData.VectorStoreConfig, logger) + if err != nil { + logger.Fatal("failed to connect to vector store: %v", err) + } + if config.ConfigStore != nil { + err = config.ConfigStore.UpdateVectorStoreConfig(ctx, configData.VectorStoreConfig) + if err != nil { + logger.Warn("failed to update vector store config: %v", err) + } + } + } + + // From now on, config store gets the priority if enabled and we find data + // if we don't find any data in the store, then we resort to config file + + //NOTE: We follow a standard practice here to first look in store -> not present then use config file -> if present in config file then update store. + + // 1. Check for Client Config + + var clientConfig *configstore.ClientConfig + if config.ConfigStore != nil { + clientConfig, err = config.ConfigStore.GetClientConfig(ctx) + if err != nil { + logger.Warn("failed to get client config from store: %v", err) + } + } + + if clientConfig != nil { + config.ClientConfig = *clientConfig + + // For backward compatibility, we need to handle cases where config is already present but max request body size is not set + if config.ClientConfig.MaxRequestBodySizeMB == 0 { + config.ClientConfig.MaxRequestBodySizeMB = DefaultClientConfig.MaxRequestBodySizeMB + } + } else { + logger.Debug("client config not found in store, using config file") + // Process core configuration if present, otherwise use defaults + if configData.Client != nil { + config.ClientConfig = *configData.Client + + // For backward compatibility, we need to handle cases where config is already present but max request body size is not set + if config.ClientConfig.MaxRequestBodySizeMB == 0 { + config.ClientConfig.MaxRequestBodySizeMB = DefaultClientConfig.MaxRequestBodySizeMB + } + } else { + config.ClientConfig = DefaultClientConfig + } + + if config.ConfigStore != nil { + logger.Debug("updating client config in store") + err = config.ConfigStore.UpdateClientConfig(ctx, &config.ClientConfig) + if err != nil { + logger.Warn("failed to update client config: %v", err) + } + } + } + + // 2. Check for Providers + + var processedProviders map[schemas.ModelProvider]configstore.ProviderConfig + if config.ConfigStore != nil { + logger.Debug("getting providers config from store") + processedProviders, err = config.ConfigStore.GetProvidersConfig(ctx) + if err != nil { + logger.Warn("failed to get providers config from store: %v", err) + } + } + + if processedProviders != nil { + config.Providers = processedProviders + } else { + // If we don't have any data in the store, we will process the data from the config file + logger.Debug("no providers config found in store, processing from config file") + processedProviders = make(map[schemas.ModelProvider]configstore.ProviderConfig) + // Process provider configurations + if configData.Providers != nil { + // Process each provider configuration + for providerName, cfg := range configData.Providers { + newEnvKeys := make(map[string]struct{}) + provider := schemas.ModelProvider(strings.ToLower(providerName)) + + // Process environment variables in keys (including key-level configs) + for i, key := range cfg.Keys { + if key.ID == "" { + cfg.Keys[i].ID = uuid.NewString() + } + + // Process API key value + processedValue, envVar, err := config.processEnvValue(key.Value) + if err != nil { + config.cleanupEnvKeys(provider, "", newEnvKeys) + if strings.Contains(err.Error(), "not found") { + logger.Info("%s: %v", provider, err) + } else { + logger.Warn("failed to process env vars in keys for %s: %v", provider, err) + } + continue + } + cfg.Keys[i].Value = processedValue + + // Track environment key if it came from env + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + config.EnvKeys[envVar] = append(config.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "api_key", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s]", provider, key.ID), + KeyID: key.ID, + }) + } + + // Process Azure key config if present + if key.AzureKeyConfig != nil { + if err := config.processAzureKeyConfigEnvVars(&cfg.Keys[i], provider, newEnvKeys); err != nil { + config.cleanupEnvKeys(provider, "", newEnvKeys) + logger.Warn("failed to process Azure key config env vars for %s: %v", provider, err) + continue + } + } + + // Process Vertex key config if present + if key.VertexKeyConfig != nil { + if err := config.processVertexKeyConfigEnvVars(&cfg.Keys[i], provider, newEnvKeys); err != nil { + config.cleanupEnvKeys(provider, "", newEnvKeys) + logger.Warn("failed to process Vertex key config env vars for %s: %v", provider, err) + continue + } + } + + // Process Bedrock key config if present + if key.BedrockKeyConfig != nil { + if err := config.processBedrockKeyConfigEnvVars(&cfg.Keys[i], provider, newEnvKeys); err != nil { + config.cleanupEnvKeys(provider, "", newEnvKeys) + logger.Warn("failed to process Bedrock key config env vars for %s: %v", provider, err) + continue + } + } + } + processedProviders[provider] = cfg + } + // Store processed configurations in memory + config.Providers = processedProviders + } else { + config.autoDetectProviders(ctx) + } + if config.ConfigStore != nil { + logger.Debug("updating providers config in store") + err = config.ConfigStore.UpdateProvidersConfig(ctx, processedProviders) + if err != nil { + logger.Warn("failed to update providers config: %v", err) + } + if err := config.ConfigStore.UpdateEnvKeys(ctx, config.EnvKeys); err != nil { + logger.Warn("failed to update env keys: %v", err) + } + } + } + + // 3. Check for MCP Config + + var mcpConfig *schemas.MCPConfig + if config.ConfigStore != nil { + logger.Debug("getting MCP config from store") + mcpConfig, err = config.ConfigStore.GetMCPConfig(ctx) + if err != nil { + logger.Warn("failed to get MCP config from store: %v", err) + } + } + + if mcpConfig != nil { + config.MCPConfig = mcpConfig + } else if configData.MCP != nil { + // If MCP config is not present in the store, we will use the config file + logger.Debug("no MCP config found in store, processing from config file") + config.MCPConfig = configData.MCP + if err := config.processMCPEnvVars(); err != nil { + logger.Warn("failed to process MCP env vars: %v", err) + } + if config.ConfigStore != nil && config.MCPConfig != nil { + logger.Debug("updating MCP config in store") + for _, clientConfig := range config.MCPConfig.ClientConfigs { + if err := config.ConfigStore.CreateMCPClientConfig(ctx, clientConfig, config.EnvKeys); err != nil { + logger.Warn("failed to create MCP client config: %v", err) + continue + } + } + } + } + + // 4. Check for Governance Config + + var governanceConfig *configstore.GovernanceConfig + if config.ConfigStore != nil { + logger.Debug("getting governance config from store") + governanceConfig, err = config.ConfigStore.GetGovernanceConfig(ctx) + if err != nil { + logger.Warn("failed to get governance config from store: %v", err) + } + } + + if governanceConfig != nil { + config.GovernanceConfig = governanceConfig + } else if configData.Governance != nil { + logger.Debug("no governance config found in store, processing from config file") + config.GovernanceConfig = configData.Governance + + if config.ConfigStore != nil { + logger.Debug("updating governance config in store") + if err := config.ConfigStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Create budgets + for _, budget := range config.GovernanceConfig.Budgets { + if err := config.ConfigStore.CreateBudget(ctx, &budget, tx); err != nil { + return fmt.Errorf("failed to create budget %s: %w", budget.ID, err) + } + } + + // Create rate limits + for _, rateLimit := range config.GovernanceConfig.RateLimits { + if err := config.ConfigStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { + return fmt.Errorf("failed to create rate limit %s: %w", rateLimit.ID, err) + } + } + + // Create customers + for _, customer := range config.GovernanceConfig.Customers { + if err := config.ConfigStore.CreateCustomer(ctx, &customer, tx); err != nil { + return fmt.Errorf("failed to create customer %s: %w", customer.ID, err) + } + } + + // Create teams + for _, team := range config.GovernanceConfig.Teams { + if err := config.ConfigStore.CreateTeam(ctx, &team, tx); err != nil { + return fmt.Errorf("failed to create team %s: %w", team.ID, err) + } + } + + // Create virtual keys + for _, virtualKey := range config.GovernanceConfig.VirtualKeys { + // Look up existing provider keys by key_id and populate the Keys field + var existingKeys []configstoreTables.TableKey + for _, keyRef := range virtualKey.Keys { + if keyRef.KeyID != "" { + var existingKey configstoreTables.TableKey + if err := tx.Where("key_id = ?", keyRef.KeyID).First(&existingKey).Error; err != nil { + if err == gorm.ErrRecordNotFound { + logger.Warn("referenced key %s not found for virtual key %s", keyRef.KeyID, virtualKey.ID) + continue + } + return fmt.Errorf("failed to lookup key %s for virtual key %s: %w", keyRef.KeyID, virtualKey.ID, err) + } + existingKeys = append(existingKeys, existingKey) + } + } + virtualKey.Keys = existingKeys + + if err := config.ConfigStore.CreateVirtualKey(ctx, &virtualKey, tx); err != nil { + return fmt.Errorf("failed to create virtual key %s: %w", virtualKey.ID, err) + } + } + + return nil + }); err != nil { + logger.Warn("failed to update governance config: %v", err) + } + } + } + + if configData.AuthConfig != nil { + if config.ConfigStore != nil { + configStoreAuthConfig, err := config.ConfigStore.GetAuthConfig(ctx) + if err == nil && configStoreAuthConfig == nil { + // Adding this config + if err := config.ConfigStore.UpdateAuthConfig(ctx, configData.AuthConfig); err != nil { + logger.Warn("failed to update auth config: %v", err) + } + } + } else if governanceConfig != nil && governanceConfig.AuthConfig == nil { + // Adding this config + governanceConfig.AuthConfig = configData.AuthConfig + // Resolving username and password if the value contains env.VAR_NAME + if configData.AuthConfig.AdminUserName != "" { + if configData.AuthConfig.AdminUserName, _, err = config.processEnvValue(configData.AuthConfig.AdminUserName); err != nil { + logger.Warn("failed to resolve username: %v", err) + } + } + if configData.AuthConfig.AdminPassword != "" { + if configData.AuthConfig.AdminPassword, _, err = config.processEnvValue(configData.AuthConfig.AdminPassword); err != nil { + logger.Warn("failed to resolve password: %v", err) + } + } + } + } + + // 5. Check for Plugins + + if config.ConfigStore != nil { + logger.Debug("getting plugins from store") + plugins, err := config.ConfigStore.GetPlugins(ctx) + if err != nil { + logger.Warn("failed to get plugins from store: %v", err) + } + if plugins != nil { + config.PluginConfigs = make([]*schemas.PluginConfig, len(plugins)) + for i, plugin := range plugins { + pluginConfig := &schemas.PluginConfig{ + Name: plugin.Name, + Enabled: plugin.Enabled, + Config: plugin.Config, + Path: plugin.Path, + } + if plugin.Name == semanticcache.PluginName { + if err := config.AddProviderKeysToSemanticCacheConfig(pluginConfig); err != nil { + logger.Warn("failed to add provider keys to semantic cache config: %v", err) + } + } + config.PluginConfigs[i] = pluginConfig + } + } + } + + // First we are loading plugins from the db + if len(configData.Plugins) > 0 { + logger.Debug("no plugins found in store, processing from config file") + if len(config.PluginConfigs) == 0 { + config.PluginConfigs = configData.Plugins + } else { + // Here we will append new plugins to the config.PluginConfigs + for _, plugin := range configData.Plugins { + if !slices.ContainsFunc(config.PluginConfigs, func(p *schemas.PluginConfig) bool { + return p.Name == plugin.Name + }) { + config.PluginConfigs = append(config.PluginConfigs, plugin) + } + } + } + + for i, plugin := range config.PluginConfigs { + if plugin.Name == semanticcache.PluginName { + if err := config.AddProviderKeysToSemanticCacheConfig(plugin); err != nil { + logger.Warn("failed to add provider keys to semantic cache config: %v", err) + } + config.PluginConfigs[i] = plugin + } + } + + if config.ConfigStore != nil { + logger.Debug("updating plugins in store") + for _, plugin := range config.PluginConfigs { + pluginConfigCopy, err := DeepCopy(plugin.Config) + if err != nil { + logger.Warn("failed to deep copy plugin config, skipping database update: %v", err) + continue + } + + pluginConfig := &configstoreTables.TablePlugin{ + Name: plugin.Name, + Enabled: plugin.Enabled, + Config: pluginConfigCopy, + Path: plugin.Path, + } + if plugin.Name == semanticcache.PluginName { + if err := config.RemoveProviderKeysFromSemanticCacheConfig(pluginConfig); err != nil { + logger.Warn("failed to remove provider keys from semantic cache config: %v", err) + } + } + if err := config.ConfigStore.CreatePlugin(ctx, pluginConfig); err != nil { + logger.Warn("failed to update plugin: %v", err) + } + } + } + } + + // 6. Check for Env Keys in config store + + // Initialize env keys + if config.ConfigStore != nil { + envKeys, err := config.ConfigStore.GetEnvKeys(ctx) + if err != nil { + logger.Warn("failed to get env keys from store: %v", err) + } + config.EnvKeys = envKeys + } + + if config.EnvKeys == nil { + config.EnvKeys = make(map[string][]configstore.EnvKeyInfo) + } + + // Initializing pricing manager + pricingConfig := &modelcatalog.Config{} + if config.ConfigStore != nil { + frameworkConfig, err := config.ConfigStore.GetFrameworkConfig(ctx) + if err != nil { + logger.Warn("failed to get framework config from store: %v", err) + } + if frameworkConfig != nil && frameworkConfig.PricingURL != nil { + pricingConfig.PricingURL = frameworkConfig.PricingURL + } + if frameworkConfig != nil && frameworkConfig.PricingSyncInterval != nil { + syncDuration := time.Duration(*frameworkConfig.PricingSyncInterval) * time.Second + pricingConfig.PricingSyncInterval = &syncDuration + } + } else if configData.FrameworkConfig != nil && configData.FrameworkConfig.Pricing != nil { + pricingConfig.PricingURL = configData.FrameworkConfig.Pricing.PricingURL + syncDuration := time.Duration(*configData.FrameworkConfig.Pricing.PricingSyncInterval) * time.Second + pricingConfig.PricingSyncInterval = &syncDuration + } + // Updating framework config + config.FrameworkConfig = &framework.FrameworkConfig{ + Pricing: pricingConfig, + } + // Creating pricing manager + pricingManager, err := modelcatalog.Init(ctx, pricingConfig, config.ConfigStore, logger) + if err != nil { + logger.Warn("failed to initialize pricing manager: %v", err) + } + config.PricingManager = pricingManager + + // Initializing encryption + var encryptionKey string + if configData.EncryptionKey != "" { + if strings.HasPrefix(configData.EncryptionKey, "env.") { + if encryptionKey, _, err = config.processEnvValue(configData.EncryptionKey); err != nil { + return nil, fmt.Errorf("failed to process encryption key: %w", err) + } + } else { + logger.Warn("encryption_key should reference an environment variable (env.VAR_NAME) rather than storing the key directly in the config file") + encryptionKey = configData.EncryptionKey + } + } + if encryptionKey == "" { + // We will try to read from the default environment variable + if os.Getenv("BIFROST_ENCRYPTION_KEY") != "" { + encryptionKey = os.Getenv("BIFROST_ENCRYPTION_KEY") + } + } + if err := config.initializeEncryption(encryptionKey); err != nil { + return nil, fmt.Errorf("failed to initialize encryption: %w", err) + } + // Done initializing encryption + return config, nil +} + +// GetRawConfigString returns the raw configuration string. +func (c *Config) GetRawConfigString() string { + data, err := os.ReadFile(c.configPath) + if err != nil { + return "{}" + } + return string(data) +} + +// processEnvValue checks and replaces environment variable references in configuration values. +// Returns the processed value and the environment variable name if it was an env reference. +// Supports the "env.VARIABLE_NAME" syntax for referencing environment variables. +// This enables secure configuration management without hardcoding sensitive values. +// +// Examples: +// - "env.OPENAI_API_KEY" -> actual value from OPENAI_API_KEY environment variable +// - "sk-1234567890" -> returned as-is (no env prefix) +func (c *Config) processEnvValue(value string) (string, string, error) { + v := strings.TrimSpace(value) + if !strings.HasPrefix(v, "env.") { + return value, "", nil // do not trim non-env values + } + envKey := strings.TrimSpace(strings.TrimPrefix(v, "env.")) + if envKey == "" { + return "", "", fmt.Errorf("environment variable name missing in %q", value) + } + if envValue, ok := os.LookupEnv(envKey); ok { + return envValue, envKey, nil + } + return "", envKey, fmt.Errorf("environment variable %s not found", envKey) +} + +// GetProviderConfigRaw retrieves the raw, unredacted provider configuration from memory. +// This method is for internal use only, particularly by the account implementation. +// +// Performance characteristics: +// - Memory access: ultra-fast direct memory access +// - No database I/O or JSON parsing overhead +// - Thread-safe with read locks for concurrent access +// +// Returns a copy of the configuration to prevent external modifications. +func (c *Config) GetProviderConfigRaw(provider schemas.ModelProvider) (*configstore.ProviderConfig, error) { + c.Mu.RLock() + defer c.Mu.RUnlock() + + config, exists := c.Providers[provider] + if !exists { + return nil, ErrNotFound + } + + // Return direct reference for maximum performance - this is used by Bifrost core + // CRITICAL: Never modify the returned data as it's shared + return &config, nil +} + +// HandlerStore interface implementation + +// ShouldAllowDirectKeys returns whether direct API keys in headers are allowed +// Note: This method doesn't use locking for performance. In rare cases during +// config updates, it may return stale data, but this is acceptable since bool +// reads are atomic and won't cause panics. +func (c *Config) ShouldAllowDirectKeys() bool { + return c.ClientConfig.AllowDirectKeys +} + +// GetLoadedPlugins returns the current snapshot of loaded plugins. +// This method is lock-free and safe for concurrent access from hot paths. +// It returns the plugin slice from the atomic pointer, which is safe to iterate +// even if plugins are being updated concurrently. +func (c *Config) GetLoadedPlugins() []schemas.Plugin { + if plugins := c.Plugins.Load(); plugins != nil { + return *plugins + } + return nil +} + +// AddLoadedPlugin adds a plugin to the loaded plugins list. +// This method is lock-free and safe for concurrent access from hot paths. +// It iterates through the plugin slice (typically 5-10 plugins, ~50ns overhead). +// For small plugin counts, this is faster than maintaining a separate map. +func (c *Config) AddLoadedPlugin(plugin schemas.Plugin) error { + for { + oldPlugins := c.Plugins.Load() + if oldPlugins == nil { + // Initialize with the new plugin + newPlugins := []schemas.Plugin{plugin} + if c.Plugins.CompareAndSwap(oldPlugins, &newPlugins) { + return nil + } + continue + } + newPlugins := make([]schemas.Plugin, len(*oldPlugins)) + copy(newPlugins, *oldPlugins) + // Checking if the plugin is already loaded + for i, p := range *oldPlugins { + if p.GetName() == plugin.GetName() { + // Removing the plugin from the list + newPlugins = append(newPlugins[:i], newPlugins[i+1:]...) + break + } + } + newPlugins = append(newPlugins, plugin) + if c.Plugins.CompareAndSwap(oldPlugins, &newPlugins) { + return nil + } + } +} + +// IsPluginLoaded checks if a plugin with the given name is currently loaded. +// This method is lock-free and safe for concurrent access from hot paths. +// It iterates through the plugin slice (typically 5-10 plugins, ~50ns overhead). +// For small plugin counts, this is faster than maintaining a separate map. +func (c *Config) IsPluginLoaded(name string) bool { + plugins := c.Plugins.Load() + if plugins == nil { + return false + } + for _, p := range *plugins { + if p.GetName() == name { + return true + } + } + return false +} + +// GetProviderConfigRedacted retrieves a provider configuration with sensitive values redacted. +// This method is intended for external API responses and logging. +// +// The returned configuration has sensitive values redacted: +// - API keys are redacted using RedactKey() +// - Values from environment variables show the original env var name (env.VAR_NAME) +// +// Returns a new copy with redacted values that is safe to expose externally. +func (c *Config) GetProviderConfigRedacted(provider schemas.ModelProvider) (*configstore.ProviderConfig, error) { + c.Mu.RLock() + defer c.Mu.RUnlock() + + config, exists := c.Providers[provider] + if !exists { + return nil, ErrNotFound + } + + // Create a map for quick lookup of env vars for this provider + envVarsByPath := make(map[string]string) + for envVar, infos := range c.EnvKeys { + for _, info := range infos { + if info.Provider == provider { + envVarsByPath[info.ConfigPath] = envVar + } + } + } + + // Create redacted config with same structure but redacted values + redactedConfig := configstore.ProviderConfig{ + NetworkConfig: config.NetworkConfig, + ConcurrencyAndBufferSize: config.ConcurrencyAndBufferSize, + ProxyConfig: config.ProxyConfig, + SendBackRawResponse: config.SendBackRawResponse, + CustomProviderConfig: config.CustomProviderConfig, + } + + // Create redacted keys + redactedConfig.Keys = make([]schemas.Key, len(config.Keys)) + for i, key := range config.Keys { + redactedConfig.Keys[i] = schemas.Key{ + ID: key.ID, + Name: key.Name, + Models: key.Models, // Copy slice reference - read-only so safe + Weight: key.Weight, + } + + // Redact API key value + path := fmt.Sprintf("providers.%s.keys[%s]", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + redactedConfig.Keys[i].Value = "env." + envVar + } else if !strings.HasPrefix(key.Value, "env.") { + redactedConfig.Keys[i].Value = RedactKey(key.Value) + } + + // Redact Azure key config if present + if key.AzureKeyConfig != nil { + azureConfig := &schemas.AzureKeyConfig{ + Deployments: key.AzureKeyConfig.Deployments, + } + + // Redact Endpoint + path = fmt.Sprintf("providers.%s.keys[%s].azure_key_config.endpoint", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + azureConfig.Endpoint = "env." + envVar + } else if !strings.HasPrefix(key.AzureKeyConfig.Endpoint, "env.") { + azureConfig.Endpoint = key.AzureKeyConfig.Endpoint + } + + // Redact APIVersion if present + if key.AzureKeyConfig.APIVersion != nil { + path = fmt.Sprintf("providers.%s.keys[%s].azure_key_config.api_version", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + azureConfig.APIVersion = bifrost.Ptr("env." + envVar) + } else { + // APIVersion is not sensitive, keep as-is + azureConfig.APIVersion = key.AzureKeyConfig.APIVersion + } + } + + redactedConfig.Keys[i].AzureKeyConfig = azureConfig + } + + // Redact Vertex key config if present + if key.VertexKeyConfig != nil { + vertexConfig := &schemas.VertexKeyConfig{} + + // Redact ProjectID + path = fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.project_id", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + vertexConfig.ProjectID = "env." + envVar + } else if !strings.HasPrefix(key.VertexKeyConfig.ProjectID, "env.") { + vertexConfig.ProjectID = RedactKey(key.VertexKeyConfig.ProjectID) + } + + // Region is not sensitive, handle env vars only + path = fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.region", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + vertexConfig.Region = "env." + envVar + } else { + vertexConfig.Region = key.VertexKeyConfig.Region + } + + // Redact AuthCredentials + path = fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.auth_credentials", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + vertexConfig.AuthCredentials = "env." + envVar + } else if !strings.HasPrefix(key.VertexKeyConfig.AuthCredentials, "env.") { + vertexConfig.AuthCredentials = RedactKey(key.VertexKeyConfig.AuthCredentials) + } + + redactedConfig.Keys[i].VertexKeyConfig = vertexConfig + } + + // Redact Bedrock key config if present + if key.BedrockKeyConfig != nil { + bedrockConfig := &schemas.BedrockKeyConfig{ + Deployments: key.BedrockKeyConfig.Deployments, + } + + // Redact AccessKey + path = fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.access_key", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + bedrockConfig.AccessKey = "env." + envVar + } else if !strings.HasPrefix(key.BedrockKeyConfig.AccessKey, "env.") { + bedrockConfig.AccessKey = RedactKey(key.BedrockKeyConfig.AccessKey) + } + + // Redact SecretKey + path = fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.secret_key", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + bedrockConfig.SecretKey = "env." + envVar + } else if !strings.HasPrefix(key.BedrockKeyConfig.SecretKey, "env.") { + bedrockConfig.SecretKey = RedactKey(key.BedrockKeyConfig.SecretKey) + } + + // Redact SessionToken + path = fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.session_token", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + bedrockConfig.SessionToken = bifrost.Ptr("env." + envVar) + } else { + bedrockConfig.SessionToken = key.BedrockKeyConfig.SessionToken + } + + // Redact Region + path = fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.region", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + bedrockConfig.Region = bifrost.Ptr("env." + envVar) + } else { + bedrockConfig.Region = key.BedrockKeyConfig.Region + } + + // Redact ARN + path = fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.arn", provider, key.ID) + if envVar, ok := envVarsByPath[path]; ok { + bedrockConfig.ARN = bifrost.Ptr("env." + envVar) + } else { + bedrockConfig.ARN = key.BedrockKeyConfig.ARN + } + + redactedConfig.Keys[i].BedrockKeyConfig = bedrockConfig + } + } + + return &redactedConfig, nil +} + +// GetAllProviders returns all configured provider names. +func (c *Config) GetAllProviders() ([]schemas.ModelProvider, error) { + c.Mu.RLock() + defer c.Mu.RUnlock() + + providers := make([]schemas.ModelProvider, 0, len(c.Providers)) + for provider := range c.Providers { + providers = append(providers, provider) + } + + return providers, nil +} + +// AddProvider adds a new provider configuration to memory with full environment variable +// processing. This method is called when new providers are added via the HTTP API. +// +// The method: +// - Validates that the provider doesn't already exist +// - Processes environment variables in API keys, and key-level configs +// - Stores the processed configuration in memory +// - Updates metadata and timestamps +func (c *Config) AddProvider(ctx context.Context, provider schemas.ModelProvider, config configstore.ProviderConfig) error { + c.Mu.Lock() + defer c.Mu.Unlock() + + // Check if provider already exists + if _, exists := c.Providers[provider]; exists { + return fmt.Errorf("provider %s already exists", provider) + } + + // Validate CustomProviderConfig if present + if err := ValidateCustomProvider(config, provider); err != nil { + return err + } + newEnvKeys := make(map[string]struct{}) + + // Process environment variables in keys (including key-level configs) + for i, key := range config.Keys { + if key.ID == "" { + config.Keys[i].ID = uuid.NewString() + } + + // Process API key value + processedValue, envVar, err := c.processEnvValue(key.Value) + if err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process env var in key: %w", err) + } + config.Keys[i].Value = processedValue + + // Track environment key if it came from env + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "api_key", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s]", provider, config.Keys[i].ID), + KeyID: config.Keys[i].ID, + }) + } + + // Process Azure key config if present + if key.AzureKeyConfig != nil { + if err := c.processAzureKeyConfigEnvVars(&config.Keys[i], provider, newEnvKeys); err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process Azure key config env vars: %w", err) + } + } + + // Process Vertex key config if present + if key.VertexKeyConfig != nil { + if err := c.processVertexKeyConfigEnvVars(&config.Keys[i], provider, newEnvKeys); err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process Vertex key config env vars: %w", err) + } + } + + // Process Bedrock key config if present + if key.BedrockKeyConfig != nil { + if err := c.processBedrockKeyConfigEnvVars(&config.Keys[i], provider, newEnvKeys); err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process Bedrock key config env vars: %w", err) + } + } + } + + c.Providers[provider] = config + + if c.ConfigStore != nil { + if err := c.ConfigStore.AddProvider(ctx, provider, config, c.EnvKeys); err != nil { + if errors.Is(err, configstore.ErrNotFound) { + return ErrNotFound + } + return fmt.Errorf("failed to update provider config in store: %w", err) + } + if err := c.ConfigStore.UpdateEnvKeys(ctx, c.EnvKeys); err != nil { + if errors.Is(err, configstore.ErrNotFound) { + return ErrNotFound + } + logger.Warn("failed to update env keys: %v", err) + } + } + + logger.Info("added provider: %s", provider) + return nil +} + +// UpdateProviderConfig updates a provider configuration in memory with full environment +// variable processing. This method is called when provider configurations are modified +// via the HTTP API and ensures all data processing is done upfront. +// +// The method: +// - Processes environment variables in API keys, and key-level configs +// - Stores the processed configuration in memory +// - Updates metadata and timestamps +// - Thread-safe operation with write locks +// +// Note: Environment variable cleanup for deleted/updated keys is now handled automatically +// by the mergeKeys function before this method is called. +// +// Parameters: +// - provider: The provider to update +// - config: The new configuration +func (c *Config) UpdateProviderConfig(ctx context.Context, provider schemas.ModelProvider, config configstore.ProviderConfig) error { + c.Mu.Lock() + defer c.Mu.Unlock() + + // Get existing configuration for validation + existingConfig, exists := c.Providers[provider] + if !exists { + return ErrNotFound + } + + // Validate CustomProviderConfig if present, ensuring immutable fields are not changed + if err := ValidateCustomProviderUpdate(config, existingConfig, provider); err != nil { + return err + } + // Track new environment variables being added + newEnvKeys := make(map[string]struct{}) + + // Process environment variables in keys (including key-level configs) + for i, key := range config.Keys { + if key.ID == "" { + config.Keys[i].ID = uuid.NewString() + } + + // Process API key value + processedValue, envVar, err := c.processEnvValue(key.Value) + if err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) // Clean up only new vars on failure + return fmt.Errorf("failed to process env var in key: %w", err) + } + config.Keys[i].Value = processedValue + + // Track environment key if it came from env + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "api_key", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s]", provider, config.Keys[i].ID), + KeyID: config.Keys[i].ID, + }) + } + + // Process Azure key config if present + if key.AzureKeyConfig != nil { + if err := c.processAzureKeyConfigEnvVars(&config.Keys[i], provider, newEnvKeys); err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process Azure key config env vars: %w", err) + } + } + + // Process Vertex key config if present + if key.VertexKeyConfig != nil { + if err := c.processVertexKeyConfigEnvVars(&config.Keys[i], provider, newEnvKeys); err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process Vertex key config env vars: %w", err) + } + } + + // Process Bedrock key config if present + if key.BedrockKeyConfig != nil { + if err := c.processBedrockKeyConfigEnvVars(&config.Keys[i], provider, newEnvKeys); err != nil { + c.cleanupEnvKeys(provider, "", newEnvKeys) + return fmt.Errorf("failed to process Bedrock key config env vars: %w", err) + } + } + } + + c.Providers[provider] = config + + if c.ConfigStore != nil { + if err := c.ConfigStore.UpdateProvider(ctx, provider, config, c.EnvKeys); err != nil { + return fmt.Errorf("failed to update provider config in store: %w", err) + } + if err := c.ConfigStore.UpdateEnvKeys(ctx, c.EnvKeys); err != nil { + logger.Warn("failed to update env keys: %v", err) + } + } + + logger.Info("Updated configuration for provider: %s", provider) + return nil +} + +// RemoveProvider removes a provider configuration from memory. +func (c *Config) RemoveProvider(ctx context.Context, provider schemas.ModelProvider) error { + c.Mu.Lock() + defer c.Mu.Unlock() + + if _, exists := c.Providers[provider]; !exists { + return ErrNotFound + } + + delete(c.Providers, provider) + c.cleanupEnvKeys(provider, "", nil) + + if c.ConfigStore != nil { + if err := c.ConfigStore.DeleteProvider(ctx, provider); err != nil { + return fmt.Errorf("failed to update provider config in store: %w", err) + } + if err := c.ConfigStore.UpdateEnvKeys(ctx, c.EnvKeys); err != nil { + logger.Warn("failed to update env keys: %v", err) + } + } + + logger.Info("Removed provider: %s", provider) + return nil +} + +// GetAllKeys returns the redacted keys +func (c *Config) GetAllKeys() ([]configstoreTables.TableKey, error) { + c.Mu.RLock() + defer c.Mu.RUnlock() + + keys := make([]configstoreTables.TableKey, 0) + for providerKey, provider := range c.Providers { + for _, key := range provider.Keys { + keys = append(keys, configstoreTables.TableKey{ + KeyID: key.ID, + Name: key.Name, + Value: "", + Models: key.Models, + Weight: key.Weight, + Provider: string(providerKey), + }) + } + } + + return keys, nil +} + +// processMCPEnvVars processes environment variables in the MCP configuration. +// This method handles the MCP config structures and processes environment +// variables in their fields, ensuring type safety and proper field handling. +// +// Supported fields that are processed: +// - ConnectionString in each MCP ClientConfig +// +// Returns an error if any required environment variable is missing. +// This approach ensures type safety while supporting environment variable substitution. +func (c *Config) processMCPEnvVars() error { + if c.MCPConfig == nil { + return nil + } + + var missingEnvVars []string + + // Process each client config + for i, clientConfig := range c.MCPConfig.ClientConfigs { + // Process ConnectionString if present + if clientConfig.ConnectionString != nil { + newValue, envVar, err := c.processEnvValue(*clientConfig.ConnectionString) + if err != nil { + logger.Warn("failed to process env vars in MCP client %s: %v", clientConfig.Name, err) + missingEnvVars = append(missingEnvVars, envVar) + continue + } + if envVar != "" { + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: "", + KeyType: "connection_string", + ConfigPath: fmt.Sprintf("mcp.client_configs.%s.connection_string", clientConfig.ID), + KeyID: "", // Empty for MCP connection strings + }) + } + c.MCPConfig.ClientConfigs[i].ConnectionString = &newValue + } + + // Process Headers if present + if clientConfig.Headers != nil { + for header, value := range clientConfig.Headers { + newValue, envVar, err := c.processEnvValue(value) + if err != nil { + logger.Warn("failed to process env vars in MCP client %s: %v", clientConfig.Name, err) + missingEnvVars = append(missingEnvVars, envVar) + continue + } + if envVar != "" { + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: "", + KeyType: "mcp_header", + ConfigPath: fmt.Sprintf("mcp.client_configs.%s.headers.%s", clientConfig.ID, header), + KeyID: "", // Empty for MCP headers + }) + } + clientConfig.Headers[header] = newValue + } + } + c.MCPConfig.ClientConfigs[i].Headers = clientConfig.Headers + } + + if len(missingEnvVars) > 0 { + return fmt.Errorf("missing environment variables: %v", missingEnvVars) + } + + return nil +} + +// SetBifrostClient sets the Bifrost client in the store. +// This is used to allow the store to access the Bifrost client. +// This is useful for the MCP handler to access the Bifrost client. +func (c *Config) SetBifrostClient(client *bifrost.Bifrost) { + c.muMCP.Lock() + defer c.muMCP.Unlock() + + c.client = client +} + +// GetMCPClient gets an MCP client configuration from the configuration. +// This method is called when an MCP client is reconnected via the HTTP API. +// +// Parameters: +// - id: ID of the client to get +// +// Returns: +// - *schemas.MCPClientConfig: The MCP client configuration (not redacted) +// - error: Any retrieval error +func (c *Config) GetMCPClient(id string) (*schemas.MCPClientConfig, error) { + c.muMCP.RLock() + defer c.muMCP.RUnlock() + + if c.client == nil { + return nil, fmt.Errorf("bifrost client not set") + } + + if c.MCPConfig == nil { + return nil, fmt.Errorf("no MCP config found") + } + + for _, clientConfig := range c.MCPConfig.ClientConfigs { + if clientConfig.ID == id { + return &clientConfig, nil + } + } + + return nil, fmt.Errorf("MCP client '%s' not found", id) +} + +// AddMCPClient adds a new MCP client to the configuration. +// This method is called when a new MCP client is added via the HTTP API. +// +// The method: +// - Validates that the MCP client doesn't already exist +// - Processes environment variables in the MCP client configuration +// - Stores the processed configuration in memory +func (c *Config) AddMCPClient(ctx context.Context, clientConfig schemas.MCPClientConfig) error { + if c.client == nil { + return fmt.Errorf("bifrost client not set") + } + + c.muMCP.Lock() + defer c.muMCP.Unlock() + + if c.MCPConfig == nil { + c.MCPConfig = &schemas.MCPConfig{} + } + + // Generate a unique ID for the client if not provided + if clientConfig.ID == "" { + clientConfig.ID = uuid.NewString() + } + + // Track new environment variables + newEnvKeys := make(map[string]struct{}) + + c.MCPConfig.ClientConfigs = append(c.MCPConfig.ClientConfigs, clientConfig) + + // Process environment variables in the new client config + if clientConfig.ConnectionString != nil { + processedValue, envVar, err := c.processEnvValue(*clientConfig.ConnectionString) + if err != nil { + c.MCPConfig.ClientConfigs = c.MCPConfig.ClientConfigs[:len(c.MCPConfig.ClientConfigs)-1] + return fmt.Errorf("failed to process env var in connection string: %w", err) + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: "", + KeyType: "connection_string", + ConfigPath: fmt.Sprintf("mcp.client_configs.%s.connection_string", clientConfig.ID), + KeyID: "", // Empty for MCP connection strings + }) + } + c.MCPConfig.ClientConfigs[len(c.MCPConfig.ClientConfigs)-1].ConnectionString = &processedValue + } + + // Process Headers if present + if clientConfig.Headers != nil { + for header, value := range clientConfig.Headers { + newValue, envVar, err := c.processEnvValue(value) + if err != nil { + return fmt.Errorf("failed to process env var in header: %w", err) + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: "", + KeyType: "mcp_header", + ConfigPath: fmt.Sprintf("mcp.client_configs.%s.headers.%s", clientConfig.ID, header), + KeyID: "", // Empty for MCP headers + }) + } + c.MCPConfig.ClientConfigs[len(c.MCPConfig.ClientConfigs)-1].Headers[header] = newValue + } + } + + // Config with processed env vars + if err := c.client.AddMCPClient(c.MCPConfig.ClientConfigs[len(c.MCPConfig.ClientConfigs)-1]); err != nil { + c.MCPConfig.ClientConfigs = c.MCPConfig.ClientConfigs[:len(c.MCPConfig.ClientConfigs)-1] + c.cleanupEnvKeys("", clientConfig.ID, newEnvKeys) + return fmt.Errorf("failed to add MCP client: %w", err) + } + + if c.ConfigStore != nil { + if err := c.ConfigStore.CreateMCPClientConfig(ctx, clientConfig, c.EnvKeys); err != nil { + return fmt.Errorf("failed to create MCP client config in store: %w", err) + } + if err := c.ConfigStore.UpdateEnvKeys(ctx, c.EnvKeys); err != nil { + logger.Warn("failed to update env keys: %v", err) + } + } + + return nil +} + +// RemoveMCPClient removes an MCP client from the configuration. +// This method is called when an MCP client is removed via the HTTP API. +// +// The method: +// - Validates that the MCP client exists +// - Removes the MCP client from the configuration +// - Removes the MCP client from the Bifrost client +func (c *Config) RemoveMCPClient(ctx context.Context, id string) error { + if c.client == nil { + return fmt.Errorf("bifrost client not set") + } + + c.muMCP.Lock() + defer c.muMCP.Unlock() + + if c.MCPConfig == nil { + return fmt.Errorf("no MCP config found") + } + + // Check if client is registered in Bifrost (can be not registered if client initialization failed) + if clients, err := c.client.GetMCPClients(); err == nil && len(clients) > 0 { + for _, client := range clients { + if client.Config.ID == id { + if err := c.client.RemoveMCPClient(id); err != nil { + return fmt.Errorf("failed to remove MCP client: %w", err) + } + break + } + } + } + + for i, clientConfig := range c.MCPConfig.ClientConfigs { + if clientConfig.ID == id { + c.MCPConfig.ClientConfigs = append(c.MCPConfig.ClientConfigs[:i], c.MCPConfig.ClientConfigs[i+1:]...) + break + } + } + + c.cleanupEnvKeys("", id, nil) + + if c.ConfigStore != nil { + if err := c.ConfigStore.DeleteMCPClientConfig(ctx, id); err != nil { + return fmt.Errorf("failed to delete MCP client config from store: %w", err) + } + if err := c.ConfigStore.UpdateEnvKeys(ctx, c.EnvKeys); err != nil { + logger.Warn("failed to update env keys: %v", err) + } + } + + return nil +} + +// EditMCPClient edits an MCP client configuration. +// This allows for dynamic MCP client management at runtime with proper env var handling. +// +// Parameters: +// - id: ID of the client to edit +// - updatedConfig: Updated MCP client configuration +func (c *Config) EditMCPClient(ctx context.Context, id string, updatedConfig schemas.MCPClientConfig) error { + if c.client == nil { + return fmt.Errorf("bifrost client not set") + } + + c.muMCP.Lock() + defer c.muMCP.Unlock() + + if c.MCPConfig == nil { + return fmt.Errorf("no MCP config found") + } + + // Find the existing client config + var oldConfig schemas.MCPClientConfig + var found bool + var configIndex int + for i, clientConfig := range c.MCPConfig.ClientConfigs { + if clientConfig.ID == id { + oldConfig = clientConfig + configIndex = i + found = true + break + } + } + + if !found { + return fmt.Errorf("MCP client '%s' not found", id) + } + + // Track new environment variables being added + newEnvKeys := make(map[string]struct{}) + + // Create a copy of updatedConfig to process env vars + processedConfig := updatedConfig + + // Process Headers if present + if processedConfig.Headers != nil { + processedHeaders := make(map[string]string) + + // Track which headers are in the new config + newHeaders := make(map[string]bool) + for header := range processedConfig.Headers { + newHeaders[header] = true + } + + // Clean up env vars for headers that are being removed + if oldConfig.Headers != nil { + for oldHeader := range oldConfig.Headers { + if !newHeaders[oldHeader] { + c.cleanupOldMCPEnvVar(id, "mcp_header", oldHeader) + } + } + } + + // Process each header value + for header, value := range processedConfig.Headers { + newValue, envVar, err := c.processEnvValue(value) + if err != nil { + // Clean up any env vars we added before the error + c.cleanupEnvKeys("", id, newEnvKeys) + return fmt.Errorf("failed to process env var in header %s: %w", header, err) + } + + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + // Remove old env var entry for this specific header if it exists + c.cleanupOldMCPEnvVar(id, "mcp_header", header) + // Add new env var entry + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: "", + KeyType: "mcp_header", + ConfigPath: fmt.Sprintf("mcp.client_configs.%s.headers.%s", id, header), + KeyID: "", + }) + } else { + // If new value is not an env var but old one might have been, clean up + c.cleanupOldMCPEnvVar(id, "mcp_header", header) + } + + processedHeaders[header] = newValue + } + processedConfig.Headers = processedHeaders + } else if oldConfig.Headers != nil { + // If headers are being removed entirely, clean up all old header env vars + for oldHeader := range oldConfig.Headers { + c.cleanupOldMCPEnvVar(id, "mcp_header", oldHeader) + } + } + + // Update the in-memory config with the processed values + c.MCPConfig.ClientConfigs[configIndex].Name = processedConfig.Name + c.MCPConfig.ClientConfigs[configIndex].Headers = processedConfig.Headers + c.MCPConfig.ClientConfigs[configIndex].ToolsToExecute = processedConfig.ToolsToExecute + + // Check if client is registered in Bifrost (can be not registered if client initialization failed) + if clients, err := c.client.GetMCPClients(); err == nil && len(clients) > 0 { + for _, client := range clients { + if client.Config.ID == id { + // Give the PROCESSED config (with actual env var values) to bifrost client + if err := c.client.EditMCPClient(id, processedConfig); err != nil { + // Rollback in-memory changes + c.MCPConfig.ClientConfigs[configIndex] = oldConfig + // Clean up any new env vars we added + c.cleanupEnvKeys("", id, newEnvKeys) + return fmt.Errorf("failed to edit MCP client: %w", err) + } + break + } + } + } + + // Persist changes to config store + if c.ConfigStore != nil { + if err := c.ConfigStore.UpdateMCPClientConfig(ctx, id, updatedConfig, c.EnvKeys); err != nil { + return fmt.Errorf("failed to update MCP client config in store: %w", err) + } + if err := c.ConfigStore.UpdateEnvKeys(ctx, c.EnvKeys); err != nil { + logger.Warn("failed to update env keys: %v", err) + } + } + + return nil +} + +// RedactMCPClientConfig creates a redacted copy of an MCP client configuration. +// Connection strings are either redacted or replaced with their environment variable names. +func (c *Config) RedactMCPClientConfig(config schemas.MCPClientConfig) schemas.MCPClientConfig { + // Create a copy with basic fields + configCopy := schemas.MCPClientConfig{ + ID: config.ID, + Name: config.Name, + ConnectionType: config.ConnectionType, + ConnectionString: config.ConnectionString, + StdioConfig: config.StdioConfig, + ToolsToExecute: append([]string{}, config.ToolsToExecute...), + } + + // Handle connection string if present + if config.ConnectionString != nil { + connStr := *config.ConnectionString + + // Check if this value came from an env var + for envVar, infos := range c.EnvKeys { + for _, info := range infos { + if info.Provider == "" && info.KeyType == "connection_string" && info.ConfigPath == fmt.Sprintf("mcp.client_configs.%s.connection_string", config.ID) { + connStr = "env." + envVar + break + } + } + } + + // If not from env var, redact it + if !strings.HasPrefix(connStr, "env.") { + connStr = RedactKey(connStr) + } + configCopy.ConnectionString = &connStr + + } + + // Redact Header values if present + if config.Headers != nil { + configCopy.Headers = make(map[string]string, len(config.Headers)) + for header, value := range config.Headers { + headerValue := value + + // Check if this header value came from an env var + for envVar, infos := range c.EnvKeys { + for _, info := range infos { + if info.Provider == "" && info.KeyType == "mcp_header" && info.ConfigPath == fmt.Sprintf("mcp.client_configs.%s.headers.%s", config.ID, header) { + headerValue = "env." + envVar + break + } + } + } + + // If not from env var, redact it + if !strings.HasPrefix(headerValue, "env.") { + headerValue = RedactKey(headerValue) + } + configCopy.Headers[header] = headerValue + } + } + + return configCopy +} + +// RedactKey redacts sensitive key values by showing only the first and last 4 characters +func RedactKey(key string) string { + if key == "" { + return "" + } + + // If key is 8 characters or less, just return all asterisks + if len(key) <= 8 { + return strings.Repeat("*", len(key)) + } + + // Show first 4 and last 4 characters, replace middle with asterisks + prefix := key[:4] + suffix := key[len(key)-4:] + middle := strings.Repeat("*", 24) + + return prefix + middle + suffix +} + +// IsRedacted checks if a key value is redacted, either by being an environment variable +// reference (env.VAR_NAME) or containing the exact redaction pattern from RedactKey. +func IsRedacted(key string) bool { + if key == "" { + return false + } + + // Check if it's an environment variable reference + if strings.HasPrefix(key, "env.") { + return true + } + + if len(key) <= 8 { + return strings.Count(key, "*") == len(key) + } + + // Check for exact redaction pattern: 4 chars + 24 asterisks + 4 chars + if len(key) == 32 { + middle := key[4:28] + if middle == strings.Repeat("*", 24) { + return true + } + } + + return false +} + +// cleanupEnvKeys removes environment variable entries from the store based on the given criteria. +// If envVarsToRemove is nil, it removes all env vars for the specified provider/client. +// If envVarsToRemove is provided, it only removes those specific env vars. +// +// Parameters: +// - provider: Provider name to clean up (empty string for MCP clients) +// - mcpClientID: MCP client ID to clean up (empty string for providers) +// - envVarsToRemove: Optional map of specific env vars to remove (nil to remove all) +func (c *Config) cleanupEnvKeys(provider schemas.ModelProvider, mcpClientID string, envVarsToRemove map[string]struct{}) { + // If envVarsToRemove is provided, only clean those specific vars + if envVarsToRemove != nil { + for envVar := range envVarsToRemove { + c.cleanupEnvVar(envVar, provider, mcpClientID) + } + return + } + + // If envVarsToRemove is nil, clean all vars for the provider/client + for envVar := range c.EnvKeys { + c.cleanupEnvVar(envVar, provider, mcpClientID) + } +} + +// cleanupEnvVar removes entries for a specific environment variable based on provider/client. +// This is a helper function to avoid duplicating the filtering logic. +func (c *Config) cleanupEnvVar(envVar string, provider schemas.ModelProvider, mcpClientID string) { + infos := c.EnvKeys[envVar] + if len(infos) == 0 { + return + } + + // Keep entries that don't match the provider/client we're cleaning up + filteredInfos := make([]configstore.EnvKeyInfo, 0, len(infos)) + for _, info := range infos { + shouldKeep := false + if provider != "" { + shouldKeep = info.Provider != provider + } else if mcpClientID != "" { + shouldKeep = info.Provider != "" || !strings.HasPrefix(info.ConfigPath, fmt.Sprintf("mcp.client_configs.%s", mcpClientID)) + } + if shouldKeep { + filteredInfos = append(filteredInfos, info) + } + } + + if len(filteredInfos) == 0 { + delete(c.EnvKeys, envVar) + } else { + c.EnvKeys[envVar] = filteredInfos + } +} + +// cleanupOldMCPEnvVar removes a specific env var entry for an MCP client field. +// This is used when updating MCP client fields that may have had env vars. +// +// Parameters: +// - mcpClientID: The ID of the MCP client +// - keyType: The type of field ("connection_string", "mcp_header") +// - headerName: The header name (only used for "mcp_header" keyType) +func (c *Config) cleanupOldMCPEnvVar(mcpClientID string, keyType string, headerName string) { + for envVar, infos := range c.EnvKeys { + filteredInfos := make([]configstore.EnvKeyInfo, 0, len(infos)) + + for _, info := range infos { + shouldKeep := true + // Only consider MCP-related entries (Provider is empty for MCP) + if info.Provider == "" && string(info.KeyType) == keyType { + if keyType == "mcp_header" && headerName != "" { + // For headers, match by client ID and header name + // ConfigPath format: mcp.client_configs..headers.
+ if strings.Contains(info.ConfigPath, fmt.Sprintf(".headers.%s", headerName)) && + strings.Contains(info.ConfigPath, mcpClientID) { + shouldKeep = false + } + } + } + + if shouldKeep { + filteredInfos = append(filteredInfos, info) + } + } + + if len(filteredInfos) == 0 { + delete(c.EnvKeys, envVar) + } else { + c.EnvKeys[envVar] = filteredInfos + } + } +} + +// CleanupEnvKeysForKeys removes environment variable entries for specific keys that are being deleted. +// This function targets key-specific environment variables based on key IDs. +// +// Parameters: +// - provider: Provider name the keys belong to +// - keysToDelete: List of keys being deleted (uses their IDs to identify env vars to clean up) +func (c *Config) CleanupEnvKeysForKeys(provider schemas.ModelProvider, keysToDelete []schemas.Key) { + // Create a set of key IDs to delete for efficient lookup + keyIDsToDelete := make(map[string]bool) + for _, key := range keysToDelete { + keyIDsToDelete[key.ID] = true + } + + // Iterate through all environment variables and remove entries for deleted keys + for envVar, infos := range c.EnvKeys { + filteredInfos := make([]configstore.EnvKeyInfo, 0, len(infos)) + + for _, info := range infos { + // Keep entries that either: + // 1. Don't belong to this provider, OR + // 2. Don't have a KeyID (MCP), OR + // 3. Have a KeyID that's not being deleted + shouldKeep := info.Provider != provider || + info.KeyID == "" || + !keyIDsToDelete[info.KeyID] + + if shouldKeep { + filteredInfos = append(filteredInfos, info) + } + } + + // Update or delete the environment variable entry + if len(filteredInfos) == 0 { + delete(c.EnvKeys, envVar) + } else { + c.EnvKeys[envVar] = filteredInfos + } + } +} + +// CleanupEnvKeysForUpdatedKeys removes environment variable entries for keys that are being updated +// but only for fields where the environment variable reference has actually changed. +// This function is called after the merge to compare final values with original values. +// +// Parameters: +// - provider: Provider name the keys belong to +// - keysToUpdate: List of keys being updated +// - oldKeys: List of original keys before update +// - mergedKeys: List of final merged keys after update +func (c *Config) CleanupEnvKeysForUpdatedKeys(provider schemas.ModelProvider, keysToUpdate []schemas.Key, oldKeys []schemas.Key, mergedKeys []schemas.Key) { + // Create maps for efficient lookup + keysToUpdateMap := make(map[string]schemas.Key) + for _, key := range keysToUpdate { + keysToUpdateMap[key.ID] = key + } + + oldKeysMap := make(map[string]schemas.Key) + for _, key := range oldKeys { + oldKeysMap[key.ID] = key + } + + mergedKeysMap := make(map[string]schemas.Key) + for _, key := range mergedKeys { + mergedKeysMap[key.ID] = key + } + + // Iterate through all environment variables and remove entries only for fields that are changing + for envVar, infos := range c.EnvKeys { + filteredInfos := make([]configstore.EnvKeyInfo, 0, len(infos)) + + for _, info := range infos { + // Keep entries that either: + // 1. Don't belong to this provider, OR + // 2. Don't have a KeyID (MCP), OR + // 3. Have a KeyID that's not being updated, OR + // 4. Have a KeyID that's being updated but the env var reference hasn't changed + shouldKeep := info.Provider != provider || + info.KeyID == "" || + keysToUpdateMap[info.KeyID].ID == "" || + !c.isEnvVarReferenceChanging(mergedKeysMap[info.KeyID], oldKeysMap[info.KeyID], info.ConfigPath) + + if shouldKeep { + filteredInfos = append(filteredInfos, info) + } + } + + // Update or delete the environment variable entry + if len(filteredInfos) == 0 { + delete(c.EnvKeys, envVar) + } else { + c.EnvKeys[envVar] = filteredInfos + } + } +} + +// isEnvVarReferenceChanging checks if an environment variable reference is changing between old and merged key +func (c *Config) isEnvVarReferenceChanging(mergedKey, oldKey schemas.Key, configPath string) bool { + // Extract the field name from the config path + // e.g., "providers.vertex.keys[123].vertex_key_config.project_id" -> "project_id" + pathParts := strings.Split(configPath, ".") + if len(pathParts) < 2 { + return false + } + fieldName := pathParts[len(pathParts)-1] + + // Get the old and merged values for this field + oldValue := c.getFieldValue(oldKey, fieldName) + mergedValue := c.getFieldValue(mergedKey, fieldName) + + // If either value is an env var reference, check if they're different + oldIsEnvVar := strings.HasPrefix(oldValue, "env.") + mergedIsEnvVar := strings.HasPrefix(mergedValue, "env.") + + // If both are env vars, check if they reference the same variable + if oldIsEnvVar && mergedIsEnvVar { + return oldValue != mergedValue + } + + // If one is env var and other isn't, or both are different types, it's changing + return oldIsEnvVar != mergedIsEnvVar || oldValue != mergedValue +} + +// getFieldValue extracts the value of a specific field from a key based on the field name +func (c *Config) getFieldValue(key schemas.Key, fieldName string) string { + switch fieldName { + case "project_id": + if key.VertexKeyConfig != nil { + return key.VertexKeyConfig.ProjectID + } + case "region": + if key.VertexKeyConfig != nil { + return key.VertexKeyConfig.Region + } + case "auth_credentials": + if key.VertexKeyConfig != nil { + return key.VertexKeyConfig.AuthCredentials + } + case "endpoint": + if key.AzureKeyConfig != nil { + return key.AzureKeyConfig.Endpoint + } + case "api_version": + if key.AzureKeyConfig != nil && key.AzureKeyConfig.APIVersion != nil { + return *key.AzureKeyConfig.APIVersion + } + case "access_key": + if key.BedrockKeyConfig != nil { + return key.BedrockKeyConfig.AccessKey + } + case "secret_key": + if key.BedrockKeyConfig != nil { + return key.BedrockKeyConfig.SecretKey + } + case "session_token": + if key.BedrockKeyConfig != nil && key.BedrockKeyConfig.SessionToken != nil { + return *key.BedrockKeyConfig.SessionToken + } + default: + // For the main API key value + if fieldName == "value" || strings.Contains(fieldName, "key") { + return key.Value + } + } + return "" +} + +// autoDetectProviders automatically detects common environment variables and sets up providers +// when no configuration file exists. This enables zero-config startup when users have set +// standard environment variables like OPENAI_API_KEY, ANTHROPIC_API_KEY, etc. +// +// Supported environment variables: +// - OpenAI: OPENAI_API_KEY, OPENAI_KEY +// - Anthropic: ANTHROPIC_API_KEY, ANTHROPIC_KEY +// - Mistral: MISTRAL_API_KEY, MISTRAL_KEY +// +// For each detected provider, it creates a default configuration with: +// - The detected API key with weight 1.0 +// - Empty models list (provider will use default models) +// - Default concurrency and buffer size settings +func (c *Config) autoDetectProviders(ctx context.Context) { + // Define common environment variable patterns for each provider + providerEnvVars := map[schemas.ModelProvider][]string{ + schemas.OpenAI: {"OPENAI_API_KEY", "OPENAI_KEY"}, + schemas.Anthropic: {"ANTHROPIC_API_KEY", "ANTHROPIC_KEY"}, + schemas.Mistral: {"MISTRAL_API_KEY", "MISTRAL_KEY"}, + } + + detectedCount := 0 + + for provider, envVars := range providerEnvVars { + for _, envVar := range envVars { + if apiKey := os.Getenv(envVar); apiKey != "" { + // Generate a unique ID for the auto-detected key + keyID := uuid.NewString() + + // Create default provider configuration + providerConfig := configstore.ProviderConfig{ + Keys: []schemas.Key{ + { + ID: keyID, + Name: fmt.Sprintf("%s_auto_detected", envVar), + Value: apiKey, + Models: []string{}, // Empty means all supported models + Weight: 1.0, + }, + }, + ConcurrencyAndBufferSize: &schemas.DefaultConcurrencyAndBufferSize, + } + + // Add to providers map + c.Providers[provider] = providerConfig + + // Track the environment variable + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "api_key", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s]", provider, keyID), + KeyID: keyID, + }) + + logger.Info("auto-detected %s provider from environment variable %s", provider, envVar) + detectedCount++ + break // Only use the first found env var for each provider + } + } + } + + if detectedCount > 0 { + logger.Info("auto-configured %d provider(s) from environment variables", detectedCount) + if c.ConfigStore != nil { + if err := c.ConfigStore.UpdateProvidersConfig(ctx, c.Providers); err != nil { + logger.Error("failed to update providers in store: %v", err) + } + } + } +} + +// processAzureKeyConfigEnvVars processes environment variables in Azure key configuration +func (c *Config) processAzureKeyConfigEnvVars(key *schemas.Key, provider schemas.ModelProvider, newEnvKeys map[string]struct{}) error { + azureConfig := key.AzureKeyConfig + + // Process Endpoint + processedEndpoint, envVar, err := c.processEnvValue(azureConfig.Endpoint) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "azure_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].azure_key_config.endpoint", provider, key.ID), + KeyID: key.ID, + }) + } + azureConfig.Endpoint = processedEndpoint + + // Process APIVersion if present + if azureConfig.APIVersion != nil { + processedAPIVersion, envVar, err := c.processEnvValue(*azureConfig.APIVersion) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "azure_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].azure_key_config.api_version", provider, key.ID), + KeyID: key.ID, + }) + } + azureConfig.APIVersion = &processedAPIVersion + } + + return nil +} + +// processVertexKeyConfigEnvVars processes environment variables in Vertex key configuration +func (c *Config) processVertexKeyConfigEnvVars(key *schemas.Key, provider schemas.ModelProvider, newEnvKeys map[string]struct{}) error { + vertexConfig := key.VertexKeyConfig + + // Process ProjectID + processedProjectID, envVar, err := c.processEnvValue(vertexConfig.ProjectID) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "vertex_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.project_id", provider, key.ID), + KeyID: key.ID, + }) + } + vertexConfig.ProjectID = processedProjectID + + // Process Region + processedRegion, envVar, err := c.processEnvValue(vertexConfig.Region) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "vertex_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.region", provider, key.ID), + KeyID: key.ID, + }) + } + vertexConfig.Region = processedRegion + + // Process AuthCredentials + processedAuthCredentials, envVar, err := c.processEnvValue(vertexConfig.AuthCredentials) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "vertex_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].vertex_key_config.auth_credentials", provider, key.ID), + KeyID: key.ID, + }) + } + vertexConfig.AuthCredentials = processedAuthCredentials + + return nil +} + +// processBedrockKeyConfigEnvVars processes environment variables in Bedrock key configuration +func (c *Config) processBedrockKeyConfigEnvVars(key *schemas.Key, provider schemas.ModelProvider, newEnvKeys map[string]struct{}) error { + bedrockConfig := key.BedrockKeyConfig + + // Process AccessKey + processedAccessKey, envVar, err := c.processEnvValue(bedrockConfig.AccessKey) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "bedrock_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.access_key", provider, key.ID), + KeyID: key.ID, + }) + } + bedrockConfig.AccessKey = processedAccessKey + + // Process SecretKey + processedSecretKey, envVar, err := c.processEnvValue(bedrockConfig.SecretKey) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "bedrock_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.secret_key", provider, key.ID), + KeyID: key.ID, + }) + } + bedrockConfig.SecretKey = processedSecretKey + + // Process SessionToken if present + if bedrockConfig.SessionToken != nil { + processedSessionToken, envVar, err := c.processEnvValue(*bedrockConfig.SessionToken) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "bedrock_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.session_token", provider, key.ID), + KeyID: key.ID, + }) + } + bedrockConfig.SessionToken = &processedSessionToken + } + + // Process Region if present + if bedrockConfig.Region != nil { + processedRegion, envVar, err := c.processEnvValue(*bedrockConfig.Region) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "bedrock_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.region", provider, key.ID), + KeyID: key.ID, + }) + } + bedrockConfig.Region = &processedRegion + } + + // Process ARN if present + if bedrockConfig.ARN != nil { + processedARN, envVar, err := c.processEnvValue(*bedrockConfig.ARN) + if err != nil { + return err + } + if envVar != "" { + newEnvKeys[envVar] = struct{}{} + c.EnvKeys[envVar] = append(c.EnvKeys[envVar], configstore.EnvKeyInfo{ + EnvVar: envVar, + Provider: provider, + KeyType: "bedrock_config", + ConfigPath: fmt.Sprintf("providers.%s.keys[%s].bedrock_key_config.arn", provider, key.ID), + KeyID: key.ID, + }) + } + bedrockConfig.ARN = &processedARN + } + + return nil +} + +// GetVectorStoreConfigRedacted retrieves the vector store configuration with password redacted for safe external exposure +func (c *Config) GetVectorStoreConfigRedacted(ctx context.Context) (*vectorstore.Config, error) { + var err error + var vectorStoreConfig *vectorstore.Config + if c.ConfigStore != nil { + vectorStoreConfig, err = c.ConfigStore.GetVectorStoreConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get vector store config: %w", err) + } + } + if vectorStoreConfig == nil { + return nil, nil + } + if vectorStoreConfig.Type == vectorstore.VectorStoreTypeWeaviate { + weaviateConfig, ok := vectorStoreConfig.Config.(*vectorstore.WeaviateConfig) + if !ok { + return nil, fmt.Errorf("failed to cast vector store config to weaviate config") + } + // Create a copy to avoid modifying the original + redactedWeaviateConfig := *weaviateConfig + // Redact password if it exists + if redactedWeaviateConfig.APIKey != "" { + redactedWeaviateConfig.APIKey = RedactKey(redactedWeaviateConfig.APIKey) + } + redactedVectorStoreConfig := *vectorStoreConfig + redactedVectorStoreConfig.Config = &redactedWeaviateConfig + return &redactedVectorStoreConfig, nil + } + return nil, nil +} + +// ValidateCustomProvider validates the custom provider configuration +func ValidateCustomProvider(config configstore.ProviderConfig, provider schemas.ModelProvider) error { + if config.CustomProviderConfig == nil { + return nil + } + + if bifrost.IsStandardProvider(provider) { + return fmt.Errorf("custom provider validation failed: cannot be created on standard providers: %s", provider) + } + + cpc := config.CustomProviderConfig + + // Validate base provider type + if cpc.BaseProviderType == "" { + return fmt.Errorf("custom provider validation failed: base_provider_type is required") + } + + // Check if base provider is a supported base provider + if !bifrost.IsSupportedBaseProvider(cpc.BaseProviderType) { + return fmt.Errorf("custom provider validation failed: unsupported base_provider_type: %s", cpc.BaseProviderType) + } + + // Reject Bedrock providers with IsKeyLess=true + if cpc.BaseProviderType == schemas.Bedrock && cpc.IsKeyLess { + return fmt.Errorf("custom provider validation failed: Bedrock providers cannot be keyless (is_key_less=true)") + } + + return nil +} + +// ValidateCustomProviderUpdate validates that immutable fields in CustomProviderConfig are not changed during updates +func ValidateCustomProviderUpdate(newConfig, existingConfig configstore.ProviderConfig, provider schemas.ModelProvider) error { + // If neither config has CustomProviderConfig, no validation needed + if newConfig.CustomProviderConfig == nil && existingConfig.CustomProviderConfig == nil { + return nil + } + + // If new config doesn't have CustomProviderConfig but existing does, return an error + if newConfig.CustomProviderConfig == nil { + return fmt.Errorf("custom_provider_config cannot be removed after creation for provider %s", provider) + } + + // If existing config doesn't have CustomProviderConfig but new one does, that's fine (adding it) + if existingConfig.CustomProviderConfig == nil { + return ValidateCustomProvider(newConfig, provider) + } + + // Both configs have CustomProviderConfig, validate immutable fields + newCPC := newConfig.CustomProviderConfig + existingCPC := existingConfig.CustomProviderConfig + + // CustomProviderKey is internally set and immutable, no validation needed + + // Check if BaseProviderType is being changed + if newCPC.BaseProviderType != existingCPC.BaseProviderType { + return fmt.Errorf("provider %s: base_provider_type cannot be changed from %s to %s after creation", + provider, existingCPC.BaseProviderType, newCPC.BaseProviderType) + } + + // Validate the new config (this will catch Bedrock+IsKeyLess configurations) + if err := ValidateCustomProvider(newConfig, provider); err != nil { + return err + } + + return nil +} + +func (c *Config) AddProviderKeysToSemanticCacheConfig(config *schemas.PluginConfig) error { + if config.Name != semanticcache.PluginName { + return nil + } + + // Check if config.Config exists + if config.Config == nil { + return fmt.Errorf("semantic_cache plugin config is nil") + } + + // Type assert config.Config to map[string]interface{} + configMap, ok := config.Config.(map[string]interface{}) + if !ok { + return fmt.Errorf("semantic_cache plugin config must be a map, got %T", config.Config) + } + + // Check if provider key exists and is a string + providerVal, exists := configMap["provider"] + if !exists { + return fmt.Errorf("semantic_cache plugin missing required 'provider' field") + } + + provider, ok := providerVal.(string) + if !ok { + return fmt.Errorf("semantic_cache plugin 'provider' field must be a string, got %T", providerVal) + } + + if provider == "" { + return fmt.Errorf("semantic_cache plugin 'provider' field cannot be empty") + } + + keys, err := c.GetProviderConfigRaw(schemas.ModelProvider(provider)) + if err != nil { + return fmt.Errorf("failed to get provider config for %s: %w", provider, err) + } + + configMap["keys"] = keys.Keys + + return nil +} + +func (c *Config) RemoveProviderKeysFromSemanticCacheConfig(config *configstoreTables.TablePlugin) error { + if config.Name != semanticcache.PluginName { + return nil + } + + // Check if config.Config exists + if config.Config == nil { + return fmt.Errorf("semantic_cache plugin config is nil") + } + + // Type assert config.Config to map[string]interface{} + configMap, ok := config.Config.(map[string]interface{}) + if !ok { + return fmt.Errorf("semantic_cache plugin config must be a map, got %T", config.Config) + } + + configMap["keys"] = []schemas.Key{} + + config.Config = configMap + + return nil +} + +func DeepCopy[T any](in T) (T, error) { + var out T + b, err := json.Marshal(in) + if err != nil { + return out, err + } + err = json.Unmarshal(b, &out) + return out, err +} diff --git a/transports/bifrost-http/lib/ctx.go b/transports/bifrost-http/lib/ctx.go new file mode 100644 index 000000000..431f19f85 --- /dev/null +++ b/transports/bifrost-http/lib/ctx.go @@ -0,0 +1,277 @@ +// Package lib provides core functionality for the Bifrost HTTP service, +// including context propagation, header management, and integration with monitoring systems. +// +// This package handles the conversion of FastHTTP request contexts to Bifrost contexts, +// ensuring that important metadata and tracking information is preserved across the system. +// It supports propagation of both Prometheus metrics and Maxim tracing data through HTTP headers. +package lib + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/plugins/maxim" + "github.com/maximhq/bifrost/plugins/semanticcache" + "github.com/valyala/fasthttp" +) + +// ConvertToBifrostContext converts a FastHTTP RequestCtx to a Bifrost context, +// preserving important header values for monitoring and tracing purposes. +// +// The function processes several types of special headers: +// 1. Prometheus Headers (x-bf-prom-*): +// - All headers prefixed with 'x-bf-prom-' are copied to the context +// - The prefix is stripped and the remainder becomes the context key +// - Example: 'x-bf-prom-latency' becomes 'latency' in the context +// +// 2. Maxim Tracing Headers (x-bf-maxim-*): +// - Specifically handles 'x-bf-maxim-traceID' and 'x-bf-maxim-generationID' +// - These headers enable trace correlation across service boundaries +// - Values are stored using Maxim's context keys for consistency +// +// 3. MCP Headers (x-bf-mcp-*): +// - Specifically handles 'x-bf-mcp-include-clients' and 'x-bf-mcp-include-tools' (include-only filtering) +// - These headers enable MCP client and tool filtering +// - Values are stored using MCP context keys for consistency +// +// 4. Governance Headers: +// - x-bf-vk: Virtual key for governance (required for governance to work) +// - x-bf-team: Team identifier for team-based governance rules +// - x-bf-user: User identifier for user-based governance rules +// - x-bf-customer: Customer identifier for customer-based governance rules +// +// 5. API Key Headers: +// - Authorization: Bearer token format only (e.g., "Bearer sk-...") - OpenAI style +// - x-api-key: Direct API key value - Anthropic style +// - Keys are extracted and stored in the context using schemas.BifrostContextKey +// - This enables explicit key usage for requests via headers +// +// 6. Cancellable Context: +// - Creates a cancellable context that can be used to cancel upstream requests when clients disconnect +// - This is critical for streaming requests where write errors indicate client disconnects +// - Also useful for non-streaming requests to allow provider-level cancellation + +// Parameters: +// - ctx: The FastHTTP request context containing the original headers +// - allowDirectKeys: Whether to allow direct API key usage from headers +// +// Returns: +// - *context.Context: A new cancellable context.Context containing the propagated values +// - context.CancelFunc: Function to cancel the context (should be called when request completes) +// +// Example Usage: +// +// fastCtx := &fasthttp.RequestCtx{...} +// bifrostCtx, cancel := ConvertToBifrostContext(fastCtx, true) +// defer cancel() // Ensure cleanup +// // bifrostCtx now contains any prometheus and maxim header values + +func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, allowDirectKeys bool) (*context.Context, context.CancelFunc) { + // Create cancellable context for all requests + // This enables proper cleanup when clients disconnect or requests are cancelled + baseCtx := context.Background() + bifrostCtx, cancel := context.WithCancel(baseCtx) + + // First, check if x-request-id header exists + requestID := string(ctx.Request.Header.Peek("x-request-id")) + if requestID == "" { + requestID = uuid.New().String() + } + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKeyRequestID, requestID) + + // Initialize tags map for collecting maxim tags + maximTags := make(map[string]string) + + // Then process other headers + ctx.Request.Header.All()(func(key, value []byte) bool { + keyStr := strings.ToLower(string(key)) + if labelName, ok := strings.CutPrefix(keyStr, "x-bf-prom-"); ok { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + return true + } + // Checking for maxim headers + if labelName, ok := strings.CutPrefix(keyStr, "x-bf-maxim-"); ok { + switch labelName { + case string(maxim.GenerationIDKey): + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + case string(maxim.TraceIDKey): + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + case string(maxim.SessionIDKey): + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + case string(maxim.TraceNameKey): + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + case string(maxim.GenerationNameKey): + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + case string(maxim.LogRepoIDKey): + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(labelName), string(value)) + default: + // apart from these all headers starting with x-bf-maxim- are keys for tags + // collect them in the maximTags map + maximTags[labelName] = string(value) + } + return true + } + // MCP control headers (include-only filtering) + if labelName, ok := strings.CutPrefix(keyStr, "x-bf-mcp-"); ok { + switch labelName { + case "include-clients": + fallthrough + case "include-tools": + // Parse comma-separated values into []string + valueStr := string(value) + var parsedValues []string + if valueStr != "" { + // Split by comma and trim whitespace + for _, v := range strings.Split(valueStr, ",") { + if trimmed := strings.TrimSpace(v); trimmed != "" { + parsedValues = append(parsedValues, trimmed) + } + } + } + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey("mcp-"+labelName), parsedValues) + return true + } + } + // Handle governance headers (x-bf-team, x-bf-user, x-bf-customer) + if keyStr == "x-bf-team" || keyStr == "x-bf-user" || keyStr == "x-bf-customer" { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(keyStr), string(value)) + return true + } + // Handle virtual key header (x-bf-vk, authorization, x-api-key headers) + if keyStr == string(schemas.BifrostContextKeyVirtualKey) { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(keyStr), string(value)) + return true + } + if keyStr == "authorization" { + valueStr := string(value) + // Only accept Bearer token format: "Bearer ..." + if strings.HasPrefix(strings.ToLower(valueStr), "bearer ") { + authHeaderValue := strings.TrimSpace(valueStr[7:]) // Remove "Bearer " prefix + if authHeaderValue != "" && strings.HasPrefix(strings.ToLower(authHeaderValue), governance.VirtualKeyPrefix) { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKeyVirtualKey, authHeaderValue) + return true + } + } + } + if keyStr == "x-api-key" && strings.HasPrefix(strings.ToLower(string(value)), governance.VirtualKeyPrefix) { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKeyVirtualKey, string(value)) + return true + } + // Handle cache key header (x-bf-cache-key) + if keyStr == "x-bf-cache-key" { + bifrostCtx = context.WithValue(bifrostCtx, semanticcache.CacheKey, string(value)) + return true + } + // Handle cache TTL header (x-bf-cache-ttl) + if keyStr == "x-bf-cache-ttl" { + valueStr := string(value) + var ttlDuration time.Duration + var err error + + // First try to parse as duration (e.g., "30s", "5m", "1h") + if ttlDuration, err = time.ParseDuration(valueStr); err != nil { + // If that fails, try to parse as plain number and treat as seconds + if seconds, parseErr := strconv.Atoi(valueStr); parseErr == nil && seconds > 0 { + ttlDuration = time.Duration(seconds) * time.Second + err = nil // Reset error since we successfully parsed as seconds + } + } + + if err == nil { + bifrostCtx = context.WithValue(bifrostCtx, semanticcache.CacheTTLKey, ttlDuration) + } + // If both parsing attempts fail, we silently ignore the header and use default TTL + return true + } + // Cache threshold header + if keyStr == "x-bf-cache-threshold" { + threshold, err := strconv.ParseFloat(string(value), 64) + if err == nil { + // Clamp threshold to the inclusive range [0.0, 1.0] + if threshold < 0.0 { + threshold = 0.0 + } else if threshold > 1.0 { + threshold = 1.0 + } + bifrostCtx = context.WithValue(bifrostCtx, semanticcache.CacheThresholdKey, threshold) + } + // If parsing fails, silently ignore the header (no context value set) + return true + } + // Cache type header + if keyStr == "x-bf-cache-type" { + bifrostCtx = context.WithValue(bifrostCtx, semanticcache.CacheTypeKey, semanticcache.CacheType(string(value))) + return true + } + // Cache no store header + if keyStr == "x-bf-cache-no-store" { + if valueStr := string(value); valueStr == "true" { + bifrostCtx = context.WithValue(bifrostCtx, semanticcache.CacheNoStoreKey, true) + } + return true + } + // Send back raw response header + if keyStr == "x-bf-send-back-raw-response" { + if valueStr := string(value); valueStr == "true" { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKeySendBackRawResponse, true) + } + return true + } + return true + }) + + // Store the collected maxim tags in the context + if len(maximTags) > 0 { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey(maxim.TagsKey), maximTags) + } + + if allowDirectKeys { + // Extract API key from Authorization header (Bearer format) or x-api-key header + var apiKey string + + // TODO: fix plugin data leak + // Check Authorization header (Bearer format only - OpenAI style) + authHeader := string(ctx.Request.Header.Peek("Authorization")) + if authHeader != "" { + // Only accept Bearer token format: "Bearer ..." + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + authHeaderValue := strings.TrimSpace(authHeader[7:]) // Remove "Bearer " prefix + if authHeaderValue != "" && !strings.HasPrefix(strings.ToLower(authHeaderValue), governance.VirtualKeyPrefix) { + apiKey = authHeaderValue + } + } else { + apiKey = authHeader + } + } + + // Check x-api-key header if no valid Authorization header found (Anthropic style) + if apiKey == "" { + xAPIKey := string(ctx.Request.Header.Peek("x-api-key")) + if xAPIKey != "" && !strings.HasPrefix(strings.ToLower(xAPIKey), governance.VirtualKeyPrefix) { + apiKey = strings.TrimSpace(xAPIKey) + } + } + + // If we found an API key, create a Key object and store it in context + if apiKey != "" { + key := schemas.Key{ + ID: "header-provided", // Identifier for header-provided keys + Value: apiKey, + Models: []string{}, // Empty models list - will be validated by provider + Weight: 1.0, // Default weight + } + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKeyDirectKey, key) + } + } + // Adding fallback context + if ctx.UserValue(schemas.BifrostContextKey("x-litellm-fallback")) != nil { + bifrostCtx = context.WithValue(bifrostCtx, schemas.BifrostContextKey("x-litellm-fallback"), "true") + } + + return &bifrostCtx, cancel +} diff --git a/transports/bifrost-http/lib/errors.go b/transports/bifrost-http/lib/errors.go new file mode 100644 index 000000000..e2e37d0b3 --- /dev/null +++ b/transports/bifrost-http/lib/errors.go @@ -0,0 +1,5 @@ +package lib + +import "errors" + +var ErrNotFound = errors.New("not found") diff --git a/transports/bifrost-http/lib/lib.go b/transports/bifrost-http/lib/lib.go new file mode 100644 index 000000000..4669aca21 --- /dev/null +++ b/transports/bifrost-http/lib/lib.go @@ -0,0 +1,12 @@ +package lib + +import ( + "github.com/maximhq/bifrost/core/schemas" +) + +var logger schemas.Logger + +// SetLogger sets the logger for the application. +func SetLogger(l schemas.Logger) { + logger = l +} diff --git a/transports/bifrost-http/lib/middleware.go b/transports/bifrost-http/lib/middleware.go new file mode 100644 index 000000000..c1657c6aa --- /dev/null +++ b/transports/bifrost-http/lib/middleware.go @@ -0,0 +1,24 @@ +package lib + +import "github.com/valyala/fasthttp" + +// BifrostHTTPMiddleware is a middleware function for the Bifrost HTTP transport +// It follows the standard pattern: receives the next handler and returns a new handler +type BifrostHTTPMiddleware func(next fasthttp.RequestHandler) fasthttp.RequestHandler + +// ChainMiddlewares chains multiple middlewares together +// Middlewares are applied in order: the first middleware wraps the second, etc. +// This allows earlier middlewares to short-circuit by not calling next(ctx) +func ChainMiddlewares(handler fasthttp.RequestHandler, middlewares ...BifrostHTTPMiddleware) fasthttp.RequestHandler { + // If no middlewares, return the original handler + if len(middlewares) == 0 { + return handler + } + // Build the chain from right to left (last middleware wraps the handler) + // This ensures execution order is left to right (first middleware executes first) + chained := handler + for i := len(middlewares) - 1; i >= 0; i-- { + chained = middlewares[i](chained) + } + return chained +} diff --git a/transports/bifrost-http/main.go b/transports/bifrost-http/main.go new file mode 100644 index 000000000..85b11d744 --- /dev/null +++ b/transports/bifrost-http/main.go @@ -0,0 +1,153 @@ +// Package main provides an HTTP service using FastHTTP that exposes endpoints +// for text and chat completions using various AI model providers (OpenAI, Anthropic, Bedrock, Mistral, Ollama, etc.). +// +// The HTTP service provides the following main endpoints: +// - /v1/completions: For text completion requests +// - /v1/chat/completions: For chat completion requests +// - /v1/mcp/tool/execute: For MCP tool execution requests +// - /providers/*: For provider configuration management +// +// Configuration is handled through a JSON config file, high-performance ConfigStore, and environment variables: +// - Use -app-dir flag to specify the application data directory (contains config.json and logs) +// - Use -port flag to specify the server port (default: 8080) +// - When no config file exists, common environment variables are auto-detected (OPENAI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY) +// +// ConfigStore Features: +// - Pure in-memory storage for ultra-fast config access +// - Environment variable processing for secure configuration management +// - Real-time configuration updates via HTTP API +// - Explicit persistence control via POST /config/save endpoint +// - Provider-specific key config support (Azure, Bedrock, Vertex) +// - Thread-safe operations with concurrent request handling +// - Statistics and monitoring endpoints for operational insights +// +// Performance Optimizations: +// - Configuration data is processed once during startup and stored in memory +// - Ultra-fast memory access eliminates I/O overhead on every request +// - All environment variable processing done upfront during configuration loading +// - Thread-safe concurrent access with read-write mutex protection +// +// Example usage: +// +// go run main.go -app-dir ./data -port 8080 -host 0.0.0.0 +// after setting provider API keys like OPENAI_API_KEY in the environment. +// +// To bind to all interfaces for container usage, set BIFROST_HOST=0.0.0.0 or use -host 0.0.0.0 +// +// Integration Support: +// Bifrost supports multiple AI provider integrations through dedicated HTTP endpoints. +// Each integration exposes API-compatible endpoints that accept the provider's native request format, +// automatically convert it to Bifrost's unified format, process it, and return the expected response format. +// +// Integration endpoints follow the pattern: /{provider}/{provider_api_path} +// Examples: +// - OpenAI: POST /openai/v1/chat/completions (accepts OpenAI ChatCompletion requests) +// - GenAI: POST /genai/v1beta/models/{model} (accepts Google GenAI requests) +// - Anthropic: POST /anthropic/v1/messages (accepts Anthropic Messages requests) +// +// This allows clients to use their existing integration code without modification while benefiting +// from Bifrost's unified model routing, fallbacks, monitoring capabilities, and high-performance configuration management. +// +// NOTE: Streaming is supported for chat completions via Server-Sent Events (SSE) +package main + +import ( + "context" + "embed" + "flag" + "fmt" + "os" + "strings" + + bifrost "github.com/maximhq/bifrost/core" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/transports/bifrost-http/handlers" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + bifrostServer "github.com/maximhq/bifrost/transports/bifrost-http/server" +) + +//go:embed all:ui +var uiContent embed.FS + +var Version string + +var logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo) +var server *bifrostServer.BifrostHTTPServer + +// init initializes command line flags (but does not parse them). +// Flag parsing is deferred to main() to avoid conflicts with test flags. +// It sets up the following flags: +// - host: Host to bind the server to (default: localhost, can be overridden with BIFROST_HOST env var) +// - port: Server port (default: 8080) +// - app-dir: Application data directory (default: current directory) +// - log-level: Logger level (debug, info, warn, error). Default is info. +// - log-style: Logger output type (json or pretty). Default is JSON. + +func init() { + if Version == "" { + Version = "v1.0.0" + } + // Set default host from environment variable or use localhost + defaultHost := os.Getenv("BIFROST_HOST") + if defaultHost == "" { + defaultHost = bifrostServer.DefaultHost + } + // Initializing server + server = bifrostServer.NewBifrostHTTPServer(Version, uiContent) + // Updating server properties from flags + flag.StringVar(&server.Port, "port", bifrostServer.DefaultPort, "Port to run the server on") + flag.StringVar(&server.Host, "host", defaultHost, "Host to bind the server to (default: localhost, override with BIFROST_HOST env var)") + flag.StringVar(&server.AppDir, "app-dir", bifrostServer.DefaultAppDir, "Application data directory (contains config.json and logs)") + flag.StringVar(&server.LogLevel, "log-level", bifrostServer.DefaultLogLevel, "Logger level (debug, info, warn, error). Default is info.") + flag.StringVar(&server.LogOutputStyle, "log-style", bifrostServer.DefaultLogOutputStyle, "Logger output type (json or pretty). Default is JSON.") +} + +// main is the entry point of the application. +func main() { + // Parse command line flags + flag.Parse() + + // Printing version + versionLine := fmt.Sprintf("β•‘%s%s%sβ•‘", strings.Repeat(" ", (61-2-len(Version))/2), Version, strings.Repeat(" ", (61-2-len(Version)+1)/2)) + // Welcome to bifrost! + fmt.Printf(` +╔═══════════════════════════════════════════════════════════╗ +β•‘ β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘ +β•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β• β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β•‘ +β•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘ +β•‘ β•šβ•β•β•β•β•β• β•šβ•β•β•šβ•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β• β•šβ•β• β•‘ +β•‘ β•‘ +║═══════════════════════════════════════════════════════════║ +%s +║═══════════════════════════════════════════════════════════║ +β•‘ The Fastest LLM Gateway β•‘ +║═══════════════════════════════════════════════════════════║ +β•‘ https://github.com/maximhq/bifrost β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +`, versionLine) + + // Configure logger from flags + logger.SetOutputType(schemas.LoggerOutputType(server.LogOutputStyle)) + logger.SetLevel(schemas.LogLevel(server.LogLevel)) + // Setting up logger + lib.SetLogger(logger) + bifrostServer.SetLogger(logger) + handlers.SetLogger(logger) + + ctx := context.Background() + err := server.Bootstrap(ctx) + if err != nil { + logger.Error("failed to bootstrap server: %v", err) + os.Exit(1) + } + err = server.Start() + if err != nil { + logger.Error("failed to start server: %v", err) + os.Exit(1) + } + logger.Info("🏁 server stopped") +} diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go new file mode 100644 index 000000000..69c612d21 --- /dev/null +++ b/transports/bifrost-http/server/server.go @@ -0,0 +1,934 @@ +// Package server provides the HTTP server for Bifrost. +package server + +import ( + "context" + "embed" + "fmt" + "net" + "os" + "os/signal" + "path/filepath" + "runtime" + "sync" + "syscall" + "time" + + "github.com/bytedance/sonic" + "github.com/fasthttp/router" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/framework/configstore/tables" + dynamicPlugins "github.com/maximhq/bifrost/framework/plugins" + "github.com/maximhq/bifrost/plugins/governance" + "github.com/maximhq/bifrost/plugins/logging" + "github.com/maximhq/bifrost/plugins/maxim" + "github.com/maximhq/bifrost/plugins/otel" + "github.com/maximhq/bifrost/plugins/semanticcache" + "github.com/maximhq/bifrost/plugins/telemetry" + "github.com/maximhq/bifrost/transports/bifrost-http/handlers" + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttpadaptor" +) + +// Constants +const ( + DefaultHost = "localhost" + DefaultPort = "8080" + DefaultAppDir = "" // Empty string means use OS-specific config directory + DefaultLogLevel = string(schemas.LogLevelInfo) + DefaultLogOutputStyle = string(schemas.LoggerOutputTypeJSON) +) + +// BifrostHTTPServer represents a HTTP server instance. +type BifrostHTTPServer struct { + ctx context.Context + cancel context.CancelFunc + + Version string + UIContent embed.FS + + Port string + Host string + AppDir string + + LogLevel string + LogOutputStyle string + + Plugins []schemas.Plugin + pluginStatusMutex sync.RWMutex + pluginStatus []schemas.PluginStatus + + Client *bifrost.Bifrost + Config *lib.Config + + Server *fasthttp.Server + Router *router.Router + WebSocketHandler *handlers.WebSocketHandler +} + +var logger schemas.Logger + +// SetLogger sets the logger for the server. +func SetLogger(l schemas.Logger) { + logger = l +} + +// NewBifrostHTTPServer creates a new instance of BifrostHTTPServer. +func NewBifrostHTTPServer(version string, uiContent embed.FS) *BifrostHTTPServer { + return &BifrostHTTPServer{ + Version: version, + UIContent: uiContent, + Port: DefaultPort, + Host: DefaultHost, + AppDir: DefaultAppDir, + LogLevel: DefaultLogLevel, + LogOutputStyle: DefaultLogOutputStyle, + } +} + +// GetDefaultConfigDir returns the OS-specific default configuration directory for Bifrost. +// This follows standard conventions: +// - Linux/macOS: ~/.config/bifrost +// - Windows: %APPDATA%\bifrost +// - If appDir is provided (non-empty), it returns that instead +func GetDefaultConfigDir(appDir string) string { + // If appDir is provided, use it directly + if appDir != "" { + return appDir + } + + // Get OS-specific config directory + var configDir string + switch runtime.GOOS { + case "windows": + // Windows: %APPDATA%\bifrost + if appData := os.Getenv("APPDATA"); appData != "" { + configDir = filepath.Join(appData, "bifrost") + } else { + // Fallback to user home directory + if homeDir, err := os.UserHomeDir(); err == nil { + configDir = filepath.Join(homeDir, "AppData", "Roaming", "bifrost") + } + } + default: + // Linux, macOS and other Unix-like systems: ~/.config/bifrost + if homeDir, err := os.UserHomeDir(); err == nil { + configDir = filepath.Join(homeDir, ".config", "bifrost") + } + } + + // If we couldn't determine the config directory, fall back to current directory + if configDir == "" { + configDir = "./bifrost-data" + } + + return configDir +} + +// MarshalPluginConfig marshals the plugin configuration +func MarshalPluginConfig[T any](source any) (*T, error) { + // If its a *T, then we will confirm + if config, ok := source.(*T); ok { + return config, nil + } + // Initialize a new instance for unmarshaling + config := new(T) + // If its a map[string]any, then we will JSON parse and confirm + if configMap, ok := source.(map[string]any); ok { + configString, err := sonic.Marshal(configMap) + if err != nil { + return nil, err + } + if err := sonic.Unmarshal([]byte(configString), config); err != nil { + return nil, err + } + return config, nil + } + // If its a string, then we will JSON parse and confirm + if configStr, ok := source.(string); ok { + if err := sonic.Unmarshal([]byte(configStr), config); err != nil { + return nil, err + } + return config, nil + } + return nil, fmt.Errorf("invalid config type") +} + +type GovernanceInMemoryStore struct { + config *lib.Config +} + +func (s *GovernanceInMemoryStore) GetConfiguredProviders() map[schemas.ModelProvider]configstore.ProviderConfig { + // Use read lock for thread-safe access - no need to copy on hot path + s.config.Mu.RLock() + defer s.config.Mu.RUnlock() + return s.config.Providers +} + +// LoadPlugin loads a plugin by name and returns it as type T. +func LoadPlugin[T schemas.Plugin](ctx context.Context, name string, path *string, pluginConfig any, bifrostConfig *lib.Config) (T, error) { + var zero T + if path != nil { + logger.Info("loading dynamic plugin %s from path %s", name, *path) + // Load dynamic plugin + plugins, err := dynamicPlugins.LoadPlugins(&dynamicPlugins.Config{ + Plugins: []dynamicPlugins.DynamicPluginConfig{ + { + Path: *path, + Name: name, + Enabled: true, + Config: pluginConfig, + }, + }, + }) + if err != nil { + return zero, fmt.Errorf("failed to load dynamic plugin %s: %v", name, err) + } + if len(plugins) == 0 { + return zero, fmt.Errorf("dynamic plugin %s returned no instances", name) + } + if p, ok := any(plugins[0]).(T); ok { + return p, nil + } + return zero, fmt.Errorf("dynamic plugin type mismatch") + } + switch name { + case telemetry.PluginName: + plugin, err := telemetry.Init(&telemetry.Config{ + CustomLabels: bifrostConfig.ClientConfig.PrometheusLabels, + }, bifrostConfig.PricingManager, logger) + if err != nil { + return zero, err + } + if p, ok := any(plugin).(T); ok { + return p, nil + } + return zero, fmt.Errorf("telemetry plugin type mismatch") + case logging.PluginName: + loggingConfig, err := MarshalPluginConfig[logging.Config](pluginConfig) + if err != nil { + return zero, fmt.Errorf("failed to marshal logging plugin config: %v", err) + } + plugin, err := logging.Init(ctx, loggingConfig, logger, bifrostConfig.LogsStore, bifrostConfig.PricingManager) + if err != nil { + return zero, err + } + if p, ok := any(plugin).(T); ok { + return p, nil + } + return zero, fmt.Errorf("logging plugin type mismatch") + case governance.PluginName: + governanceConfig, err := MarshalPluginConfig[governance.Config](pluginConfig) + if err != nil { + return zero, fmt.Errorf("failed to marshal governance plugin config: %v", err) + } + inMemoryStore := &GovernanceInMemoryStore{ + config: bifrostConfig, + } + plugin, err := governance.Init(ctx, governanceConfig, logger, bifrostConfig.ConfigStore, bifrostConfig.GovernanceConfig, bifrostConfig.PricingManager, inMemoryStore) + if err != nil { + return zero, err + } + if p, ok := any(plugin).(T); ok { + return p, nil + } + return zero, fmt.Errorf("governance plugin type mismatch") + case maxim.PluginName: + // And keep backward compatibility for ENV variables + maximConfig, err := MarshalPluginConfig[maxim.Config](pluginConfig) + if err != nil { + return zero, fmt.Errorf("failed to marshal maxim plugin config: %v", err) + } + plugin, err := maxim.Init(maximConfig, logger) + if err != nil { + return zero, err + } + if p, ok := any(plugin).(T); ok { + return p, nil + } + return zero, fmt.Errorf("maxim plugin type mismatch") + case semanticcache.PluginName: + semanticcacheConfig, err := MarshalPluginConfig[semanticcache.Config](pluginConfig) + if err != nil { + return zero, fmt.Errorf("failed to marshal semantic cache plugin config: %v", err) + } + plugin, err := semanticcache.Init(ctx, semanticcacheConfig, logger, bifrostConfig.VectorStore) + if err != nil { + return zero, err + } + if p, ok := any(plugin).(T); ok { + return p, nil + } + return zero, fmt.Errorf("semantic cache plugin type mismatch") + case otel.PluginName: + otelConfig, err := MarshalPluginConfig[otel.Config](pluginConfig) + if err != nil { + return zero, fmt.Errorf("failed to marshal otel plugin config: %v", err) + } + plugin, err := otel.Init(ctx, otelConfig, logger, bifrostConfig.PricingManager) + if err != nil { + return zero, err + } + if p, ok := any(plugin).(T); ok { + return p, nil + } + return zero, fmt.Errorf("otel plugin type mismatch") + } + return zero, fmt.Errorf("plugin %s not found", name) +} + +// LoadPlugins loads the plugins for the server. +func LoadPlugins(ctx context.Context, config *lib.Config) ([]schemas.Plugin, []schemas.PluginStatus, error) { + var err error + pluginStatus := []schemas.PluginStatus{} + plugins := []schemas.Plugin{} + // Initialize telemetry plugin + promPlugin, err := LoadPlugin[*telemetry.PrometheusPlugin](ctx, telemetry.PluginName, nil, nil, config) + if err != nil { + logger.Error("failed to initialize telemetry plugin: %v", err) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: telemetry.PluginName, + Status: schemas.PluginStatusError, + Logs: []string{fmt.Sprintf("error initializing telemetry plugin %v", err)}, + }) + } else { + plugins = append(plugins, promPlugin) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: telemetry.PluginName, + Status: schemas.PluginStatusActive, + Logs: []string{"telemetry plugin initialized successfully"}, + }) + } + // Initializing logger plugin + var loggingPlugin *logging.LoggerPlugin + if config.ClientConfig.EnableLogging && config.LogsStore != nil { + // Use dedicated logs database with high-scale optimizations + loggingPlugin, err = LoadPlugin[*logging.LoggerPlugin](ctx, logging.PluginName, nil, &logging.Config{ + DisableContentLogging: &config.ClientConfig.DisableContentLogging, + }, config) + if err != nil { + logger.Error("failed to initialize logging plugin: %v", err) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: logging.PluginName, + Status: schemas.PluginStatusError, + Logs: []string{fmt.Sprintf("error initializing logging plugin %v", err)}, + }) + } else { + plugins = append(plugins, loggingPlugin) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: logging.PluginName, + Status: schemas.PluginStatusActive, + Logs: []string{"logging plugin initialized successfully"}, + }) + } + } else { + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: logging.PluginName, + Status: schemas.PluginStatusDisabled, + Logs: []string{"logging plugin disabled"}, + }) + } + // Initializing governance plugin + var governancePlugin *governance.GovernancePlugin + if config.ClientConfig.EnableGovernance { + // Initialize governance plugin + governancePlugin, err = LoadPlugin[*governance.GovernancePlugin](ctx, governance.PluginName, nil, &governance.Config{ + IsVkMandatory: &config.ClientConfig.EnforceGovernanceHeader, + }, config) + if err != nil { + logger.Error("failed to initialize governance plugin: %s", err.Error()) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: governance.PluginName, + Status: schemas.PluginStatusError, + Logs: []string{fmt.Sprintf("error initializing governance plugin %v", err)}, + }) + } else { + plugins = append(plugins, governancePlugin) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: governance.PluginName, + Status: schemas.PluginStatusActive, + Logs: []string{"governance plugin initialized successfully"}, + }) + } + } else { + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: governance.PluginName, + Status: schemas.PluginStatusDisabled, + Logs: []string{"governance plugin disabled"}, + }) + } + for _, plugin := range config.PluginConfigs { + if !plugin.Enabled { + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: plugin.Name, + Status: schemas.PluginStatusDisabled, + Logs: []string{fmt.Sprintf("plugin %s disabled", plugin.Name)}, + }) + continue + } + pluginInstance, err := LoadPlugin[schemas.Plugin](ctx, plugin.Name, plugin.Path, plugin.Config, config) + if err != nil { + logger.Error("failed to load plugin %s: %v", plugin.Name, err) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: plugin.Name, + Status: schemas.PluginStatusError, + Logs: []string{fmt.Sprintf("error loading plugin %s: %v", plugin.Name, err)}, + }) + } else { + plugins = append(plugins, pluginInstance) + pluginStatus = append(pluginStatus, schemas.PluginStatus{ + Name: plugin.Name, + Status: schemas.PluginStatusActive, + Logs: []string{fmt.Sprintf("plugin %s initialized successfully", plugin.Name)}, + }) + } + } + + // Atomically publish the plugin state + config.Plugins.Store(&plugins) + + return plugins, pluginStatus, nil +} + +// FindPluginByName retrieves a plugin by name and returns it as type T. +// T must satisfy schemas.Plugin. +func FindPluginByName[T schemas.Plugin](plugins []schemas.Plugin, name string) (T, error) { + for _, plugin := range plugins { + if plugin.GetName() == name { + if p, ok := plugin.(T); ok { + return p, nil + } + var zero T + return zero, fmt.Errorf("plugin %q found but type mismatch", name) + } + } + var zero T + return zero, fmt.Errorf("plugin %q not found", name) +} + +// ReloadClientConfigFromConfigStore reloads the client config from config store +func (s *BifrostHTTPServer) ReloadClientConfigFromConfigStore() error { + if s.Config == nil || s.Config.ConfigStore == nil { + return fmt.Errorf("config store not found") + } + config, err := s.Config.ConfigStore.GetClientConfig(context.Background()) + if err != nil { + return fmt.Errorf("failed to get client config: %v", err) + } + s.Config.ClientConfig = *config + // Reloading config in bifrost client + if s.Client != nil { + account := lib.NewBaseAccount(s.Config) + s.Client.ReloadConfig(schemas.BifrostConfig{ + Account: account, + InitialPoolSize: s.Config.ClientConfig.InitialPoolSize, + DropExcessRequests: s.Config.ClientConfig.DropExcessRequests, + Plugins: s.Config.GetLoadedPlugins(), + MCPConfig: s.Config.MCPConfig, + Logger: logger, + }) + } + return nil +} + +// UpdateAuthConfig updates auth config +func (s *BifrostHTTPServer) UpdateAuthConfig(ctx context.Context, authConfig *configstore.AuthConfig) error { + if authConfig == nil { + return fmt.Errorf("config store not found") + } + if s.Config == nil || s.Config.ConfigStore == nil { + return fmt.Errorf("config store not found") + } + if authConfig.AdminUserName == "" || authConfig.AdminPassword == "" { + return fmt.Errorf("username and password are required") + } + return s.Config.ConfigStore.UpdateAuthConfig(ctx, authConfig) +} + +// UpdateDropExcessRequests updates excess requests config +func (s *BifrostHTTPServer) UpdateDropExcessRequests(value bool) { + if s.Config == nil { + return + } + s.Client.UpdateDropExcessRequests(value) +} + +// UpdatePluginStatus updates the status of a plugin +func (s *BifrostHTTPServer) UpdatePluginStatus(name string, status string, logs []string) error { + s.pluginStatusMutex.Lock() + defer s.pluginStatusMutex.Unlock() + // Remove plugin status if already exists + for i, pluginStatus := range s.pluginStatus { + if pluginStatus.Name == name { + s.pluginStatus = append(s.pluginStatus[:i], s.pluginStatus[i+1:]...) + break + } + } + logsCopy := make([]string, len(logs)) + copy(logsCopy, logs) + // Add new plugin status + s.pluginStatus = append(s.pluginStatus, schemas.PluginStatus{ + Name: name, + Status: status, + Logs: logsCopy, + }) + return nil +} + +// GetPluginStatus returns the status of all plugins +func (s *BifrostHTTPServer) GetPluginStatus() []schemas.PluginStatus { + s.pluginStatusMutex.RLock() + defer s.pluginStatusMutex.RUnlock() + result := make([]schemas.PluginStatus, len(s.pluginStatus)) + copy(result, s.pluginStatus) + return result +} + +// ReloadPlugin reloads a plugin with new instance and updates Bifrost core. +// Uses atomic CompareAndSwap with retry loop to handle concurrent updates safely. +func (s *BifrostHTTPServer) ReloadPlugin(ctx context.Context, name string, path *string, pluginConfig any) error { + logger.Debug("reloading plugin %s", name) + newPlugin, err := LoadPlugin[schemas.Plugin](ctx, name, path, pluginConfig, s.Config) + if err != nil { + s.UpdatePluginStatus(name, schemas.PluginStatusError, []string{fmt.Sprintf("error loading plugin %s: %v", name, err)}) + return err + } + if err := s.Client.ReloadPlugin(newPlugin); err != nil { + s.UpdatePluginStatus(name, schemas.PluginStatusError, []string{fmt.Sprintf("error reloading plugin %s: %v", name, err)}) + return err + } + // CAS retry loop (matching bifrost.go pattern) + for { + oldPlugins := s.Config.Plugins.Load() + oldPluginsSlice := []schemas.Plugin{} + if oldPlugins != nil { + oldPluginsSlice = *oldPlugins + } + + // Create new slice with replaced/appended plugin + newPlugins := make([]schemas.Plugin, len(oldPluginsSlice)) + copy(newPlugins, oldPluginsSlice) + + found := false + for i, existing := range newPlugins { + if existing.GetName() == name { + newPlugins[i] = newPlugin + found = true + break + } + } + if !found { + newPlugins = append(newPlugins, newPlugin) + } + + // Atomic compare-and-swap + if s.Config.Plugins.CompareAndSwap(oldPlugins, &newPlugins) { + s.Plugins = newPlugins // Keep BifrostHTTPServer.Plugins in sync + return nil + } + // Retry on contention (extremely rare for plugin updates) + } +} + +// ReloadPricingManager reloads the pricing manager +func (s *BifrostHTTPServer) ReloadPricingManager() error { + if s.Config == nil || s.Config.PricingManager == nil { + return fmt.Errorf("pricing manager not found") + } + if s.Config.FrameworkConfig == nil || s.Config.FrameworkConfig.Pricing == nil { + return fmt.Errorf("framework config not found") + } + return s.Config.PricingManager.ReloadPricing(context.Background(), s.Config.FrameworkConfig.Pricing) +} + +// RefetchModelsForProvider deletes existing models for a provider and refetches them from the provider +func (s *BifrostHTTPServer) RefetchModelsForProvider(ctx context.Context, provider schemas.ModelProvider) error { + if s.Config == nil || s.Config.PricingManager == nil { + return fmt.Errorf("pricing manager not found") + } + if s.Client == nil { + return fmt.Errorf("bifrost client not found") + } + + allModels, err := s.Client.ListModelsRequest(ctx, &schemas.BifrostListModelsRequest{ + Provider: provider, + }) + if err != nil { + return fmt.Errorf("failed to update provider model catalog: failed to list all models: %s", bifrost.GetErrorMessage(err)) + } + + s.Config.PricingManager.DeleteModelDataForProvider(provider) + + s.Config.PricingManager.AddModelDataToPool(allModels) + + return nil +} + +// DeleteModelsForProvider deletes all models for a specific provider from the model catalog +func (s *BifrostHTTPServer) DeleteModelsForProvider(provider schemas.ModelProvider) error { + if s.Config == nil || s.Config.PricingManager == nil { + return fmt.Errorf("pricing manager not found") + } + + s.Config.PricingManager.DeleteModelDataForProvider(provider) + + return nil +} + +// RemovePlugin removes a plugin from the server. +// Uses atomic CompareAndSwap with retry loop to handle concurrent updates safely. +func (s *BifrostHTTPServer) RemovePlugin(ctx context.Context, name string) error { + if err := s.Client.RemovePlugin(name); err != nil { + return err + } + isDisabled := ctx.Value("isDisabled") + if isDisabled != nil && isDisabled.(bool) { + s.UpdatePluginStatus(name, schemas.PluginStatusDisabled, []string{fmt.Sprintf("plugin %s is disabled", name)}) + } else { + // Removing plugin from plugin status + s.UpdatePluginStatus(name, schemas.PluginStatusDisabled, []string{fmt.Sprintf("plugin %s is removed", name)}) + } + // CAS retry loop (matching bifrost.go pattern) + for { + oldPlugins := s.Config.Plugins.Load() + oldPluginsSlice := []schemas.Plugin{} + if oldPlugins != nil { + oldPluginsSlice = *oldPlugins + } + + // Create new slice without the removed plugin + newPlugins := make([]schemas.Plugin, 0, len(oldPluginsSlice)) + for _, existing := range oldPluginsSlice { + if existing.GetName() != name { + newPlugins = append(newPlugins, existing) + } + } + + // Atomic compare-and-swap + if s.Config.Plugins.CompareAndSwap(oldPlugins, &newPlugins) { + s.Plugins = newPlugins // Keep BifrostHTTPServer.Plugins in sync + return nil + } + // Retry on contention (extremely rare for plugin updates) + } +} + +// RegisterInferenceRoutes initializes the routes for the inference handler +func (s *BifrostHTTPServer) RegisterInferenceRoutes(ctx context.Context, middlewares ...lib.BifrostHTTPMiddleware) error { + inferenceHandler := handlers.NewInferenceHandler(s.Client, s.Config) + integrationHandler := handlers.NewIntegrationHandler(s.Client, s.Config) + integrationHandler.RegisterRoutes(s.Router, middlewares...) + inferenceHandler.RegisterRoutes(s.Router, middlewares...) + return nil +} + +// RegisterAPIRoutes initializes the routes for the Bifrost HTTP server. +func (s *BifrostHTTPServer) RegisterAPIRoutes(ctx context.Context, middlewares ...lib.BifrostHTTPMiddleware) error { + var err error + // Initializing plugin specific handlers + var loggingHandler *handlers.LoggingHandler + loggerPlugin, _ := FindPluginByName[*logging.LoggerPlugin](s.Plugins, logging.PluginName) + if loggerPlugin != nil { + loggingHandler = handlers.NewLoggingHandler(loggerPlugin.GetPluginLogManager(), s) + } + var governanceHandler *handlers.GovernanceHandler + governancePlugin, _ := FindPluginByName[*governance.GovernancePlugin](s.Plugins, governance.PluginName) + if governancePlugin != nil { + governanceHandler, err = handlers.NewGovernanceHandler(governancePlugin, s.Config.ConfigStore) + if err != nil { + return fmt.Errorf("failed to initialize governance handler: %v", err) + } + } + var cacheHandler *handlers.CacheHandler + semanticCachePlugin, _ := FindPluginByName[*semanticcache.Plugin](s.Plugins, semanticcache.PluginName) + if semanticCachePlugin != nil { + cacheHandler = handlers.NewCacheHandler(semanticCachePlugin) + } + // Websocket handler needs to go below UI handler + logger.Debug("initializing websocket server") + if loggerPlugin != nil { + s.WebSocketHandler = handlers.NewWebSocketHandler(ctx, loggerPlugin.GetPluginLogManager(), s.Config.ClientConfig.AllowedOrigins) + loggerPlugin.SetLogCallback(s.WebSocketHandler.BroadcastLogUpdate) + } else { + s.WebSocketHandler = handlers.NewWebSocketHandler(ctx, nil, s.Config.ClientConfig.AllowedOrigins) + } + // Start WebSocket heartbeat + s.WebSocketHandler.StartHeartbeat() + // Adding telemetry middleware + // Chaining all middlewares + // lib.ChainMiddlewares chains multiple middlewares together + // Initialize + healthHandler := handlers.NewHealthHandler(s.Config) + providerHandler := handlers.NewProviderHandler(s, s.Config, s.Client) + mcpHandler := handlers.NewMCPHandler(s.Client, s.Config) + configHandler := handlers.NewConfigHandler(s, s.Config) + pluginsHandler := handlers.NewPluginsHandler(s, s.Config.ConfigStore) + sessionHandler := handlers.NewSessionHandler(s.Config.ConfigStore) + // Going ahead with API handlers + healthHandler.RegisterRoutes(s.Router, middlewares...) + providerHandler.RegisterRoutes(s.Router, middlewares...) + mcpHandler.RegisterRoutes(s.Router, middlewares...) + configHandler.RegisterRoutes(s.Router, middlewares...) + if pluginsHandler != nil { + pluginsHandler.RegisterRoutes(s.Router, middlewares...) + } + if sessionHandler != nil { + sessionHandler.RegisterRoutes(s.Router, middlewares...) + } + if cacheHandler != nil { + cacheHandler.RegisterRoutes(s.Router, middlewares...) + } + if governanceHandler != nil { + governanceHandler.RegisterRoutes(s.Router, middlewares...) + } + if loggingHandler != nil { + loggingHandler.RegisterRoutes(s.Router, middlewares...) + } + if s.WebSocketHandler != nil { + s.WebSocketHandler.RegisterRoutes(s.Router, middlewares...) + } + // Add Prometheus /metrics endpoint + prometheusPlugin, err := FindPluginByName[*telemetry.PrometheusPlugin](s.Plugins, telemetry.PluginName) + if err == nil && prometheusPlugin.GetRegistry() != nil { + // Use the plugin's dedicated registry if available + metricsHandler := fasthttpadaptor.NewFastHTTPHandler(promhttp.HandlerFor(prometheusPlugin.GetRegistry(), promhttp.HandlerOpts{})) + s.Router.GET("/metrics", metricsHandler) + } else { + logger.Warn("prometheus plugin not found or registry is nil, skipping metrics endpoint") + } + // 404 handler + s.Router.NotFound = func(ctx *fasthttp.RequestCtx) { + handlers.SendError(ctx, fasthttp.StatusNotFound, "Route not found: "+string(ctx.Path())) + } + return nil +} + +// RegisterUIRoutes registers the UI handler with the specified router +func (s *BifrostHTTPServer) RegisterUIRoutes(middlewares ...lib.BifrostHTTPMiddleware) { + // Register UI handlers + // Registering UI handlers + // WARNING: This UI handler needs to be registered after all the other handlers + handlers.NewUIHandler(s.UIContent).RegisterRoutes(s.Router, middlewares...) +} + +// GetAllRedactedKeys gets all redacted keys from the config store +func (s *BifrostHTTPServer) GetAllRedactedKeys(ctx context.Context, ids []string) []schemas.Key { + if s.Config == nil || s.Config.ConfigStore == nil { + return nil + } + redactedKeys, err := s.Config.ConfigStore.GetAllRedactedKeys(ctx, ids) + if err != nil { + logger.Error("failed to get all redacted keys: %v", err) + return nil + } + return redactedKeys +} + +// GetAllRedactedVirtualKeys gets all redacted virtual keys from the config store +func (s *BifrostHTTPServer) GetAllRedactedVirtualKeys(ctx context.Context, ids []string) []tables.TableVirtualKey { + if s.Config == nil || s.Config.ConfigStore == nil { + return nil + } + virtualKeys, err := s.Config.ConfigStore.GetRedactedVirtualKeys(ctx, ids) + if err != nil { + logger.Error("failed to get all redacted virtual keys: %v", err) + return nil + } + return virtualKeys +} + +// PrepareCommonMiddlewares gets the common middlewares for the Bifrost HTTP server +func (s *BifrostHTTPServer) PrepareCommonMiddlewares() []lib.BifrostHTTPMiddleware { + commonMiddlewares := []lib.BifrostHTTPMiddleware{} + // Preparing middlewares + // Initializing prometheus plugin + prometheusPlugin, err := FindPluginByName[*telemetry.PrometheusPlugin](s.Plugins, telemetry.PluginName) + if err == nil { + commonMiddlewares = append(commonMiddlewares, prometheusPlugin.HTTPMiddleware) + } else { + logger.Warn("prometheus plugin not found, skipping telemetry middleware") + } + return commonMiddlewares +} + +// Bootstrap initializes the Bifrost HTTP server with all necessary components. +// It: +// 1. Initializes Prometheus collectors for monitoring +// 2. Reads and parses configuration from the specified config file +// 3. Initializes the Bifrost client with the configuration +// 4. Sets up HTTP routes for text and chat completions +// +// The server exposes the following endpoints: +// - POST /v1/text/completions: For text completion requests +// - POST /v1/chat/completions: For chat completion requests +// - GET /metrics: For Prometheus metrics +func (s *BifrostHTTPServer) Bootstrap(ctx context.Context) error { + var err error + s.ctx, s.cancel = context.WithCancel(ctx) + handlers.SetVersion(s.Version) + configDir := GetDefaultConfigDir(s.AppDir) + s.pluginStatusMutex = sync.RWMutex{} + // Ensure app directory exists + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create app directory %s: %v", configDir, err) + } + // Initialize high-performance configuration store with dedicated database + s.Config, err = lib.LoadConfig(ctx, configDir) + if err != nil { + return fmt.Errorf("failed to load config %v", err) + } + // Load plugins + s.pluginStatusMutex.Lock() + defer s.pluginStatusMutex.Unlock() + s.Plugins, s.pluginStatus, err = LoadPlugins(ctx, s.Config) + if err != nil { + return fmt.Errorf("failed to load plugins %v", err) + } + // Initialize bifrost client + // Create account backed by the high-performance store (all processing is done in LoadFromDatabase) + // The account interface now benefits from ultra-fast config access times via in-memory storage + account := lib.NewBaseAccount(s.Config) + s.Client, err = bifrost.Init(ctx, schemas.BifrostConfig{ + Account: account, + InitialPoolSize: s.Config.ClientConfig.InitialPoolSize, + DropExcessRequests: s.Config.ClientConfig.DropExcessRequests, + Plugins: s.Plugins, + MCPConfig: s.Config.MCPConfig, + Logger: logger, + }) + if err != nil { + return fmt.Errorf("failed to initialize bifrost: %v", err) + } + logger.Info("bifrost client initialized") + // List all models and add to model catalog + logger.Info("listing all models and adding to model catalog") + modelData, listModelsErr := s.Client.ListAllModels(ctx, nil) + if listModelsErr != nil { + if listModelsErr.Error != nil { + logger.Error("failed to list all models: %s", listModelsErr.Error.Message) + } else { + logger.Error("failed to list all models: %v", listModelsErr) + } + } else if s.Config.PricingManager != nil { + s.Config.PricingManager.AddModelDataToPool(modelData) + } + // Add pricing data to the client + logger.Info("models added to catalog") + s.Config.SetBifrostClient(s.Client) + // Initialize routes + s.Router = router.New() + commonMiddlewares := s.PrepareCommonMiddlewares() + apiMiddlewares := commonMiddlewares + inferenceMiddlewares := commonMiddlewares + var authConfig *configstore.AuthConfig + if s.Config.ConfigStore != nil { + authConfig, err = s.Config.ConfigStore.GetAuthConfig(ctx) + if err != nil { + logger.Error("failed to get auth config: %v", err) + return fmt.Errorf("failed to get auth config: %v", err) + } + } else if s.Config.GovernanceConfig != nil && s.Config.GovernanceConfig.AuthConfig != nil { + authConfig = s.Config.GovernanceConfig.AuthConfig + } + if ctx.Value("isEnterprise") == nil && authConfig != nil && authConfig.IsEnabled { + apiMiddlewares = append(apiMiddlewares, handlers.AuthMiddleware(s.Config.ConfigStore)) + } + // Register routes + err = s.RegisterAPIRoutes(s.ctx, apiMiddlewares...) + if err != nil { + return fmt.Errorf("failed to initialize routes: %v", err) + } + // Registering inference routes + if ctx.Value("isEnterprise") == nil && authConfig != nil && authConfig.IsEnabled && !authConfig.DisableAuthOnInference { + inferenceMiddlewares = append(inferenceMiddlewares, handlers.AuthMiddleware(s.Config.ConfigStore)) + } + // Registering inference middlewares + err = s.RegisterInferenceRoutes(s.ctx, inferenceMiddlewares...) + if err != nil { + return fmt.Errorf("failed to initialize inference routes: %v", err) + } + // Register UI handler + s.RegisterUIRoutes() + if err != nil { + return fmt.Errorf("failed to initialize routes: %v", err) + } + // Create fasthttp server instance + s.Server = &fasthttp.Server{ + Handler: handlers.CorsMiddleware(s.Config)(handlers.TransportInterceptorMiddleware(s.Config)(s.Router.Handler)), + MaxRequestBodySize: s.Config.ClientConfig.MaxRequestBodySizeMB * 1024 * 1024, + } + return nil +} + +// Start starts the HTTP server at the specified host and port +// Also watches signals and errors +func (s *BifrostHTTPServer) Start() error { + // Create channels for signal and error handling + sigChan := make(chan os.Signal, 1) + errChan := make(chan error, 1) + // Watching for signals + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + // Start server in a goroutine + serverAddr := net.JoinHostPort(s.Host, s.Port) + go func() { + logger.Info("successfully started bifrost, serving UI on http://%s:%s", s.Host, s.Port) + if err := s.Server.ListenAndServe(serverAddr); err != nil { + errChan <- err + } + }() + // Wait for either termination signal or server error + select { + case sig := <-sigChan: + logger.Info("received signal %v, initiating graceful shutdown...", sig) + // Create shutdown context with timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Perform graceful shutdown + if err := s.Server.Shutdown(); err != nil { + logger.Error("error during graceful shutdown: %v", err) + } else { + logger.Info("server gracefully shutdown") + } + // Cancelling main context + if s.cancel != nil { + s.cancel() + } + // Wait for shutdown to complete or timeout + done := make(chan struct{}) + go func() { + defer close(done) + logger.Info("shutting down bifrost client...") + s.Client.Shutdown() + logger.Info("bifrost client shutdown completed") + logger.Info("cleaning up storage engines...") + // Cleaning up storage engines + if s.Config != nil && s.Config.PricingManager != nil { + s.Config.PricingManager.Cleanup() + } + if s.Config != nil && s.Config.ConfigStore != nil { + s.Config.ConfigStore.Close(shutdownCtx) + } + if s.Config != nil && s.Config.LogsStore != nil { + s.Config.LogsStore.Close(shutdownCtx) + } + if s.Config != nil && s.Config.VectorStore != nil { + s.Config.VectorStore.Close(shutdownCtx, "") + } + logger.Info("storage engines cleanup completed") + }() + select { + case <-done: + logger.Info("cleanup completed") + case <-shutdownCtx.Done(): + logger.Warn("cleanup timed out after 30 seconds") + } + + case err := <-errChan: + return err + } + return nil +} diff --git a/transports/bifrost-http/server/server_test.go b/transports/bifrost-http/server/server_test.go new file mode 100644 index 000000000..184550f27 --- /dev/null +++ b/transports/bifrost-http/server/server_test.go @@ -0,0 +1,280 @@ +package server + +import ( + "testing" +) + +// TestConfig is a sample config struct for testing +type TestConfig struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Count int `json:"count"` +} + +func TestMarshalPluginConfig_WithPointerType(t *testing.T) { + // Test case 1: source is already *T + expected := &TestConfig{ + Name: "test-plugin", + Enabled: true, + Count: 42, + } + + result, err := MarshalPluginConfig[TestConfig](expected) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result != expected { + t.Errorf("Expected same pointer, got different pointer") + } + + if result.Name != expected.Name { + t.Errorf("Expected Name=%s, got %s", expected.Name, result.Name) + } + if result.Enabled != expected.Enabled { + t.Errorf("Expected Enabled=%v, got %v", expected.Enabled, result.Enabled) + } + if result.Count != expected.Count { + t.Errorf("Expected Count=%d, got %d", expected.Count, result.Count) + } +} + +func TestMarshalPluginConfig_WithMap(t *testing.T) { + // Test case 2: source is map[string]any + configMap := map[string]any{ + "name": "test-plugin", + "enabled": true, + "count": 42, + } + + result, err := MarshalPluginConfig[TestConfig](configMap) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + if result.Name != "test-plugin" { + t.Errorf("Expected Name=test-plugin, got %s", result.Name) + } + if result.Enabled != true { + t.Errorf("Expected Enabled=true, got %v", result.Enabled) + } + if result.Count != 42 { + t.Errorf("Expected Count=42, got %d", result.Count) + } +} + +func TestMarshalPluginConfig_WithString(t *testing.T) { + // Test case 3: source is string (JSON) + configStr := `{"name":"test-plugin","enabled":true,"count":42}` + + result, err := MarshalPluginConfig[TestConfig](configStr) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + if result.Name != "test-plugin" { + t.Errorf("Expected Name=test-plugin, got %s", result.Name) + } + if result.Enabled != true { + t.Errorf("Expected Enabled=true, got %v", result.Enabled) + } + if result.Count != 42 { + t.Errorf("Expected Count=42, got %d", result.Count) + } +} + +func TestMarshalPluginConfig_WithInvalidType(t *testing.T) { + // Test case 4: source is invalid type (should return error) + invalidSource := 12345 + + result, err := MarshalPluginConfig[TestConfig](invalidSource) + if err == nil { + t.Fatal("Expected error for invalid type, got nil") + } + + if result != nil { + t.Errorf("Expected nil result for invalid type, got %v", result) + } + + expectedError := "invalid config type" + if err.Error() != expectedError { + t.Errorf("Expected error message '%s', got '%s'", expectedError, err.Error()) + } +} + +func TestMarshalPluginConfig_WithInvalidJSONString(t *testing.T) { + // Test case 5: source is string but invalid JSON + invalidJSON := `{"name":"test-plugin","enabled":true,count:42}` // missing quotes around count + + result, err := MarshalPluginConfig[TestConfig](invalidJSON) + if err == nil { + t.Fatal("Expected error for invalid JSON, got nil") + } + + if result != nil { + t.Errorf("Expected nil result for invalid JSON, got %v", result) + } +} + +func TestMarshalPluginConfig_WithInvalidMapData(t *testing.T) { + // Test case 6: source is map but contains invalid data types + configMap := map[string]any{ + "name": "test-plugin", + "enabled": "not-a-boolean", // wrong type + "count": 42, + } + + result, err := MarshalPluginConfig[TestConfig](configMap) + if err == nil { + t.Fatal("Expected error for invalid map data, got nil") + } + + if result != nil { + t.Errorf("Expected nil result for invalid map data, got %v", result) + } +} + +func TestMarshalPluginConfig_WithEmptyMap(t *testing.T) { + // Test case 7: source is empty map (should work, return zero values) + configMap := map[string]any{} + + result, err := MarshalPluginConfig[TestConfig](configMap) + if err != nil { + t.Fatalf("Expected no error for empty map, got: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + // All fields should have zero values + if result.Name != "" { + t.Errorf("Expected empty Name, got %s", result.Name) + } + if result.Enabled != false { + t.Errorf("Expected Enabled=false, got %v", result.Enabled) + } + if result.Count != 0 { + t.Errorf("Expected Count=0, got %d", result.Count) + } +} + +func TestMarshalPluginConfig_WithEmptyString(t *testing.T) { + // Test case 8: source is empty string (should fail as invalid JSON) + configStr := "" + + result, err := MarshalPluginConfig[TestConfig](configStr) + if err == nil { + t.Fatal("Expected error for empty string, got nil") + } + + if result != nil { + t.Errorf("Expected nil result for empty string, got %v", result) + } +} + +func TestMarshalPluginConfig_WithNil(t *testing.T) { + // Test case 9: source is nil (should return error as invalid type) + result, err := MarshalPluginConfig[TestConfig](nil) + if err == nil { + t.Fatal("Expected error for nil source, got nil") + } + + if result != nil { + t.Errorf("Expected nil result for nil source, got %v", result) + } +} + +// Benchmark tests +func BenchmarkMarshalPluginConfig_WithPointerType(b *testing.B) { + config := &TestConfig{ + Name: "test-plugin", + Enabled: true, + Count: 42, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = MarshalPluginConfig[TestConfig](config) + } +} + +func BenchmarkMarshalPluginConfig_WithMap(b *testing.B) { + configMap := map[string]any{ + "name": "test-plugin", + "enabled": true, + "count": 42, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = MarshalPluginConfig[TestConfig](configMap) + } +} + +func BenchmarkMarshalPluginConfig_WithString(b *testing.B) { + configStr := `{"name":"test-plugin","enabled":true,"count":42}` + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = MarshalPluginConfig[TestConfig](configStr) + } +} + +// Complex config for additional testing +type ComplexConfig struct { + Settings map[string]string `json:"settings"` + Tags []string `json:"tags"` + Metadata map[string]any `json:"metadata"` + Nested *TestConfig `json:"nested"` +} + +func TestMarshalPluginConfig_WithComplexType(t *testing.T) { + // Test with a more complex nested structure + configMap := map[string]any{ + "settings": map[string]any{ + "key1": "value1", + "key2": "value2", + }, + "tags": []any{"tag1", "tag2", "tag3"}, + "metadata": map[string]any{ + "version": "1.0.0", + "author": "test", + }, + "nested": map[string]any{ + "name": "nested-config", + "enabled": true, + "count": 10, + }, + } + + result, err := MarshalPluginConfig[ComplexConfig](configMap) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + if len(result.Settings) != 2 { + t.Errorf("Expected 2 settings, got %d", len(result.Settings)) + } + if len(result.Tags) != 3 { + t.Errorf("Expected 3 tags, got %d", len(result.Tags)) + } + if result.Nested == nil { + t.Fatal("Expected non-nil nested config") + } + if result.Nested.Name != "nested-config" { + t.Errorf("Expected nested name=nested-config, got %s", result.Nested.Name) + } +} diff --git a/transports/changelog.md b/transports/changelog.md new file mode 100644 index 000000000..13d717f75 --- /dev/null +++ b/transports/changelog.md @@ -0,0 +1,5 @@ +- chore: update core version to 1.2.22 and framework version to 1.1.27 +- feat: added unified streaming lifecycle events across all providers to fully align with OpenAI’s streaming response types. +- chore: shift from `alpha/responses` to `v1/responses` in openrouter provider for responses API +- feat: send back pricing data for models in list models response +- fix: custom keyless providers initial list models request fixes \ No newline at end of file diff --git a/transports/config.example.json b/transports/config.example.json deleted file mode 100644 index 159aecac6..000000000 --- a/transports/config.example.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "OpenAI": { - "keys": [ - { - "value": "env.OPENAI_API_KEY", - "models": ["gpt-4o-mini", "gpt-4-turbo"], - "weight": 1.0 - } - ], - "network_config": { - "default_request_timeout_in_seconds": 30, - "max_retries": 1, - "retry_backoff_initial_ms": 100, - "retry_backoff_max_ms": 2000 - }, - "concurrency_and_buffer_size": { - "concurrency": 3, - "buffer_size": 10 - } - }, - "Anthropic": { - "keys": [ - { - "value": "env.ANTHROPIC_API_KEY", - "models": [ - "claude-3-7-sonnet-20250219", - "claude-3-5-sonnet-20240620", - "claude-2.1" - ], - "weight": 1.0 - } - ], - "network_config": { - "default_request_timeout_in_seconds": 30, - "max_retries": 1, - "retry_backoff_initial_ms": 100, - "retry_backoff_max_ms": 2000 - }, - "concurrency_and_buffer_size": { - "concurrency": 3, - "buffer_size": 10 - } - }, - "Bedrock": { - "keys": [ - { - "value": "env.BEDROCK_API_KEY", - "models": [ - "anthropic.claude-v2:1", - "mistral.mixtral-8x7b-instruct-v0:1", - "mistral.mistral-large-2402-v1:0", - "anthropic.claude-3-sonnet-20240229-v1:0" - ], - "weight": 1.0 - } - ], - "network_config": { - "default_request_timeout_in_seconds": 30, - "max_retries": 1, - "retry_backoff_initial_ms": 100, - "retry_backoff_max_ms": 2000 - }, - "meta_config": { - "secret_access_key": "env.BEDROCK_ACCESS_KEY", - "region": "us-east-1" - }, - "concurrency_and_buffer_size": { - "concurrency": 3, - "buffer_size": 10 - } - }, - "Cohere": { - "keys": [ - { - "value": "env.COHERE_API_KEY", - "models": ["command-a-03-2025"], - "weight": 1.0 - } - ], - "network_config": { - "default_request_timeout_in_seconds": 30, - "max_retries": 1, - "retry_backoff_initial_ms": 100, - "retry_backoff_max_ms": 2000 - }, - "concurrency_and_buffer_size": { - "concurrency": 3, - "buffer_size": 10 - } - }, - "Azure": { - "keys": [ - { - "value": "env.AZURE_API_KEY", - "models": ["gpt-4o"], - "weight": 1.0 - } - ], - "network_config": { - "default_request_timeout_in_seconds": 30, - "max_retries": 1, - "retry_backoff_initial_ms": 100, - "retry_backoff_max_ms": 2000 - }, - "meta_config": { - "endpoint": "env.AZURE_ENDPOINT", - "deployments": { - "gpt-4o": "gpt-4o-aug" - }, - "api_version": "2024-08-01-preview" - }, - "concurrency_and_buffer_size": { - "concurrency": 3, - "buffer_size": 10 - } - } -} diff --git a/transports/config.schema.json b/transports/config.schema.json new file mode 100644 index 000000000..6c7ffc243 --- /dev/null +++ b/transports/config.schema.json @@ -0,0 +1,1882 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://www.getbifrost.ai/schema", + "title": "Bifrost Configuration Schema", + "description": "Schema for Bifrost HTTP transport configuration", + "type": "object", + "properties": { + "encryption_key": { + "type": "string", + "description": "You can set the value as env. to use an environment variable. We also read encryption key from BIFROST_ENCRYPTION_KEY environment variable. Note: once set, the encryption key cannot be changed unless you clean up the database. Accepts any string; a secure 32-byte AES-256 key will be derived using Argon2id KDF. If not provided, data will be saved in plain text. Recommended: use a passphrase of at least 16 bytes for better security" + }, + "client": { + "type": "object", + "description": "Client configuration settings", + "properties": { + "drop_excess_requests": { + "type": "boolean", + "description": "Whether to drop excess requests when pool is full" + }, + "initial_pool_size": { + "type": "integer", + "minimum": 1, + "description": "Initial size of the connection pool", + "default": 300 + }, + "prometheus_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels to use for Prometheus metrics" + }, + "allowed_origins": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "const": "*" + }, + { + "type": "string", + "format": "uri" + } + ] + }, + "description": "CORS allowed origins (supports \"*\" or URI strings)" + }, + "enable_logging": { + "type": "boolean", + "description": "Enable request/response logging" + }, + "enable_governance": { + "type": "boolean", + "description": "Enable governance features" + }, + "enforce_governance_header": { + "type": "boolean", + "description": "Enforce governance header. This will require every incoming request to include x-bf-vk header." + }, + "allow_direct_keys": { + "type": "boolean", + "description": "Allow provider keys" + }, + "max_request_body_size_mb": { + "type": "integer", + "minimum": 1, + "description": "Maximum request body size in MB" + }, + "enable_litellm_fallbacks": { + "type": "boolean", + "description": "Enable litellm-specific fallbacks for text completion for Groq" + } + }, + "additionalProperties": false + }, + "framework": { + "type": "object", + "properties": { + "pricing": { + "$ref": "#/$defs/pricing_config" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "description": "AI provider configurations", + "properties": { + "openai": { + "$ref": "#/$defs/provider" + }, + "anthropic": { + "$ref": "#/$defs/provider" + }, + "bedrock": { + "$ref": "#/$defs/provider_with_bedrock_config" + }, + "cohere": { + "$ref": "#/$defs/provider" + }, + "azure": { + "$ref": "#/$defs/provider_with_azure_config" + }, + "vertex": { + "$ref": "#/$defs/provider_with_vertex_config" + }, + "mistral": { + "$ref": "#/$defs/provider" + }, + "ollama": { + "$ref": "#/$defs/provider" + }, + "groq": { + "$ref": "#/$defs/provider" + }, + "gemini": { + "$ref": "#/$defs/provider" + }, + "openrouter": { + "$ref": "#/$defs/provider" + }, + "sgl": { + "$ref": "#/$defs/provider" + }, + "parasail": { + "$ref": "#/$defs/provider" + }, + "perplexity": { + "$ref": "#/$defs/provider" + }, + "cerebras": { + "$ref": "#/$defs/provider" + } + }, + "additionalProperties": true + }, + "governance": { + "type": "object", + "description": "Governance configuration for budgets, rate limits, customers, teams, and virtual keys", + "properties": { + "budgets": { + "type": "array", + "description": "Budget configurations", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Budget ID" + }, + "name": { + "type": "string", + "description": "Budget name" + }, + "limit": { + "type": "number", + "description": "Budget limit" + }, + "duration": { + "type": "string", + "description": "Budget duration (e.g., '1d', '1w', '1m')" + } + }, + "required": [ + "id", + "name", + "limit", + "duration" + ], + "additionalProperties": false + } + }, + "rate_limits": { + "type": "array", + "description": "Rate limit configurations", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Rate limit ID" + }, + "name": { + "type": "string", + "description": "Rate limit name" + }, + "limit": { + "type": "integer", + "description": "Request limit" + }, + "duration": { + "type": "string", + "description": "Rate limit duration (e.g., '1m', '1h')" + } + }, + "required": [ + "id", + "name", + "limit", + "duration" + ], + "additionalProperties": false + } + }, + "customers": { + "type": "array", + "description": "Customer configurations", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Customer ID" + }, + "name": { + "type": "string", + "description": "Customer name" + }, + "budget_id": { + "type": "string", + "description": "Associated budget ID" + }, + "rate_limit_id": { + "type": "string", + "description": "Associated rate limit ID" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "teams": { + "type": "array", + "description": "Team configurations", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Team ID" + }, + "name": { + "type": "string", + "description": "Team name" + }, + "customer_id": { + "type": "string", + "description": "Associated customer ID" + }, + "budget_id": { + "type": "string", + "description": "Associated budget ID" + }, + "rate_limit_id": { + "type": "string", + "description": "Associated rate limit ID" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "virtual_keys": { + "type": "array", + "description": "Virtual key configurations", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Virtual key ID" + }, + "name": { + "type": "string", + "description": "Virtual key name" + }, + "key": { + "type": "string", + "description": "Virtual key value" + }, + "team_id": { + "type": "string", + "description": "Associated team ID" + }, + "customer_id": { + "type": "string", + "description": "Associated customer ID" + }, + "budget_id": { + "type": "string", + "description": "Associated budget ID" + }, + "rate_limit_id": { + "type": "string", + "description": "Associated rate limit ID" + }, + "keys": { + "type": "array", + "description": "Provider keys associated with this virtual key", + "items": { + "type": "object", + "properties": { + "key_id": { + "type": "string", + "description": "Provider key ID" + } + }, + "additionalProperties": false + } + } + }, + "required": [ + "id", + "name", + "key" + ], + "additionalProperties": false + } + }, + "auth_config": { + "$ref": "#/$defs/auth_config" + } + }, + "additionalProperties": false + }, + "mcp": { + "type": "object", + "description": "Model Context Protocol configuration", + "properties": { + "client_configs": { + "type": "array", + "items": { + "$ref": "#/$defs/mcp_client_config" + }, + "description": "MCP client configurations" + } + }, + "additionalProperties": false + }, + "vector_store": { + "type": "object", + "description": "Vector store configuration for caching", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable vector store" + }, + "type": { + "type": "string", + "enum": [ + "weaviate" + ], + "description": "Vector store type" + }, + "config": { + "anyOf": [ + { + "if": { + "properties": { + "type": { + "const": "weaviate" + } + } + }, + "then": { + "$ref": "#/$defs/weaviate_config" + } + } + ] + } + }, + "additionalProperties": false + }, + "config_store": { + "type": "object", + "description": "Configuration store settings", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable configuration store" + }, + "type": { + "type": "string", + "enum": [ + "sqlite", + "postgres" + ], + "description": "Configuration store type" + }, + "config": { + "anyOf": [ + { + "if": { + "properties": { + "type": { + "const": "sqlite" + } + } + }, + "then": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Database file path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "if": { + "properties": { + "type": { + "const": "postgres" + } + } + }, + "then": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Database host" + }, + "port": { + "type": "string", + "description": "Database port" + }, + "user": { + "type": "string", + "description": "Database user" + }, + "password": { + "type": "string", + "description": "Database password. Leave empty if you want to use IAM role authentication." + }, + "db_name": { + "type": "string", + "description": "Database name" + }, + "ssl_mode": { + "type": "string", + "description": "Database SSL mode" + } + }, + "required": [ + "host", + "port", + "user", + "password", + "db_name", + "ssl_mode" + ], + "additionalProperties": false + } + } + ] + } + }, + "additionalProperties": false + }, + "logs_store": { + "type": "object", + "description": "Logs store settings", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable logs store" + }, + "type": { + "type": "string", + "enum": [ + "sqlite", + "postgres" + ], + "description": "Logs store type" + }, + "config": { + "type": "object", + "oneOf": [ + { + "if": { + "properties": { + "../type": { + "const": "sqlite" + } + } + }, + "then": { + "properties": { + "path": { + "type": "string", + "description": "Database file path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "if": { + "properties": { + "../type": { + "const": "postgres" + } + } + }, + "then": { + "properties": { + "host": { + "type": "string", + "description": "Database host" + }, + "port": { + "type": "integer", + "description": "Database port" + }, + "user": { + "type": "string", + "description": "Database user" + }, + "password": { + "type": "string", + "description": "Database password. Leave empty if you want to use IAM role authentication." + }, + "db_name": { + "type": "string", + "description": "Database name" + }, + "ssl_mode": { + "type": "string", + "description": "Database SSL mode" + } + }, + "required": [ + "host", + "port", + "user", + "password", + "db_name", + "ssl_mode" + ], + "additionalProperties": false + } + } + ] + } + }, + "additionalProperties": false + }, + "cluster_config": { + "$ref": "#/$defs/cluster_config" + }, + "saml_config": { + "$ref": "#/$defs/saml_config" + }, + "load_balancer_config": { + "$ref": "#/$defs/load_balancer_config" + }, + "guardrails_config": { + "$ref": "#/$defs/guardrails_config" + }, + "plugins": { + "type": "array", + "description": "Plugins configuration", + "items": { + "type": "object", + "required": [ + "enabled", + "name" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable plugins" + }, + "name": { + "type": "string", + "description": "Name of the plugin (built-in: telemetry, logging, governance, maxim, semanticcache, otel, or custom plugin name)" + }, + "config": { + "type": "object", + "description": "Configuration for the plugin" + }, + "path": { + "type": "string", + "description": "Path to the plugin (optional, required for dynamic plugins)", + "optional": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "name": { + "const": "telemetry" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "Configuration for the telemetry plugin (Prometheus metrics)", + "properties": {}, + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "name": { + "const": "logging" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "Configuration for the logging plugin", + "properties": {}, + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "name": { + "const": "governance" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "Configuration for the governance plugin", + "properties": { + "is_vk_mandatory": { + "type": "boolean", + "description": "Whether virtual key (x-bf-vk header) is mandatory for all requests" + } + }, + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "name": { + "const": "maxim" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "Configuration for the Maxim SDK integration plugin", + "properties": { + "api_key": { + "type": "string", + "description": "API key for Maxim SDK authentication" + }, + "log_repo_id": { + "type": "string", + "description": "Optional default ID for the Maxim logger instance" + } + }, + "required": [ + "api_key" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "name": { + "const": "semanticcache" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "Configuration for the semantic cache plugin", + "properties": { + "provider": { + "type": "string", + "description": "Provider to use for generating embeddings", + "enum": [ + "openai", + "anthropic", + "gemini", + "bedrock", + "azure", + "cohere", + "mistral", + "groq", + "ollama", + "openrouter", + "vertex", + "cerebras", + "parasail", + "perplexity", + "sgl" + ] + }, + "keys": { + "type": "array", + "description": "API keys for the embedding provider", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "embedding_model": { + "type": "string", + "description": "Model to use for generating embeddings (optional)" + }, + "cleanup_on_shutdown": { + "type": "boolean", + "description": "Clean up cache on shutdown (default: false)" + }, + "ttl": { + "description": "Time-to-live for cached responses (supports duration strings like '5m', '1h' or seconds as number, default: 5min)", + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9]+(ns|us|Β΅s|ms|s|m|h)$" + }, + { + "type": "integer", + "minimum": 0 + } + ] + }, + "threshold": { + "type": "number", + "description": "Cosine similarity threshold for semantic matching (default: 0.8)", + "minimum": 0, + "maximum": 1 + }, + "vector_store_namespace": { + "type": "string", + "description": "Namespace for vector store (optional)" + }, + "dimension": { + "type": "integer", + "description": "Dimension for vector store embeddings", + "minimum": 1 + }, + "conversation_history_threshold": { + "type": "integer", + "description": "Skip caching for requests with more than this number of messages in conversation history (default: 3)", + "minimum": 0 + }, + "cache_by_model": { + "type": "boolean", + "description": "Include model in cache key (default: true)" + }, + "cache_by_provider": { + "type": "boolean", + "description": "Include provider in cache key (default: true)" + }, + "exclude_system_prompt": { + "type": "boolean", + "description": "Exclude system prompt in cache key (default: false)" + } + }, + "required": [ + "provider", + "keys", + "dimension" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "name": { + "const": "otel" + } + } + }, + "then": { + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "Configuration for the OpenTelemetry plugin", + "properties": { + "collector_url": { + "type": "string", + "description": "URL of the OpenTelemetry collector", + "oneOf": [ + { + "format": "uri" + }, + { + "pattern": "^[^:\\s]+:\\d+$" + } + ] + }, + "trace_type": { + "type": "string", + "description": "Type of trace to use for the OTEL collector", + "enum": [ + "otel" + ] + }, + "protocol": { + "type": "string", + "description": "Protocol to use for the OTEL collector", + "enum": [ + "http", + "grpc" + ] + } + }, + "required": [ + "collector_url", + "trace_type", + "protocol" + ], + "additionalProperties": false + } + } + } + } + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$defs": { + "auth_config": { + "type": "object", + "properties": { + "admin_username": { + "type": "string", + "description": "Admin username" + }, + "admin_password": { + "type": "string", + "description": "Admin password" + }, + "is_enabled": { + "type": "boolean", + "description": "Whether authentication is enabled" + }, + "disable_auth_on_inference": { + "type": "boolean", + "description": "Whether authentication is disabled on inference" + } + }, + "additionalProperties": false + }, + "pricing_config": { + "type": "object", + "properties": { + "pricing_url": { + "type": "string", + "description": "Pricing URL", + "optional": true, + "format": "uri" + }, + "pricing_sync_interval": { + "type": "integer", + "description": "Pricing sync interval in seconds. Default is 24 hours. Minimum is 3600 seconds (1 hour).", + "default": 86400, + "optional": true, + "minimum": 3600 + } + }, + "additionalProperties": false + }, + "network_config": { + "type": "object", + "properties": { + "base_url": { + "type": "string", + "format": "uri", + "description": "Base URL for the provider (optional, required for Ollama)" + }, + "extra_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Additional headers to send with requests" + }, + "default_request_timeout_in_seconds": { + "type": "integer", + "minimum": 1, + "description": "Default request timeout in seconds" + }, + "max_retries": { + "type": "integer", + "minimum": 0, + "description": "Maximum number of retries" + }, + "retry_backoff_initial_ms": { + "type": "integer", + "minimum": 0, + "description": "Initial retry backoff in milliseconds" + }, + "retry_backoff_max_ms": { + "type": "integer", + "minimum": 0, + "description": "Maximum retry backoff in milliseconds" + } + }, + "additionalProperties": false + }, + "concurrency_config": { + "type": "object", + "properties": { + "concurrency": { + "type": "integer", + "minimum": 1, + "description": "Number of concurrent requests" + }, + "buffer_size": { + "type": "integer", + "minimum": 1, + "description": "Buffer size for requests" + } + }, + "required": [ + "concurrency", + "buffer_size" + ], + "additionalProperties": false + }, + "base_key": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "API key value (can use env. prefix)" + }, + "models": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Supported models for this key" + }, + "weight": { + "type": "number", + "minimum": 0, + "description": "Weight for load balancing" + } + }, + "required": [ + "weight" + ], + "additionalProperties": false + }, + "bedrock_key": { + "allOf": [ + { + "$ref": "#/$defs/base_key" + }, + { + "type": "object", + "properties": { + "bedrock_key_config": { + "type": "object", + "properties": { + "access_key": { + "type": "string", + "description": "AWS access key (can use env. prefix)" + }, + "secret_key": { + "type": "string", + "description": "AWS secret key (can use env. prefix)" + }, + "session_token": { + "type": "string", + "description": "AWS session token (can use env. prefix)" + }, + "deployments": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Model to deployment mappings" + }, + "arn": { + "type": "string", + "description": "AWS ARN" + }, + "region": { + "type": "string", + "description": "AWS region" + } + }, + "required": [ + "region" + ], + "additionalProperties": false + } + }, + "required": [ + "bedrock_key_config" + ] + } + ] + }, + "azure_key": { + "allOf": [ + { + "$ref": "#/$defs/base_key" + }, + { + "type": "object", + "properties": { + "azure_key_config": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "description": "Azure endpoint (can use env. prefix)" + }, + "deployments": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Model to deployment mappings" + }, + "api_version": { + "type": "string", + "description": "Azure API version" + } + }, + "required": [ + "endpoint", + "api_version" + ], + "additionalProperties": false + } + }, + "required": [ + "azure_key_config" + ] + } + ] + }, + "vertex_key": { + "allOf": [ + { + "$ref": "#/$defs/base_key" + }, + { + "type": "object", + "properties": { + "vertex_key_config": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Google Cloud project ID (can use env. prefix)" + }, + "region": { + "type": "string", + "description": "Google Cloud region" + }, + "auth_credentials": { + "type": "string", + "description": "Authentication credentials (can use env. prefix)" + } + }, + "required": [ + "project_id", + "region" + ], + "additionalProperties": false + } + }, + "required": [ + "vertex_key_config" + ] + } + ] + }, + "provider": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/base_key" + }, + "minItems": 1, + "description": "API keys for this provider" + }, + "network_config": { + "$ref": "#/$defs/network_config" + }, + "concurrency_and_buffer_size": { + "$ref": "#/$defs/concurrency_config" + }, + "proxy_config": { + "$ref": "#/$defs/proxy_config" + }, + "send_back_raw_response": { + "type": "boolean", + "description": "Include raw response in BifrostResponse (default: false)" + } + }, + "required": [ + "keys" + ], + "additionalProperties": false + }, + "provider_with_bedrock_config": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/bedrock_key" + }, + "minItems": 1, + "description": "API keys for this provider" + }, + "network_config": { + "$ref": "#/$defs/network_config" + }, + "concurrency_and_buffer_size": { + "$ref": "#/$defs/concurrency_config" + }, + "proxy_config": { + "$ref": "#/$defs/proxy_config" + }, + "send_back_raw_response": { + "type": "boolean", + "description": "Include raw response in BifrostResponse (default: false)" + } + }, + "required": [ + "keys" + ], + "additionalProperties": false + }, + "provider_with_azure_config": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/azure_key" + }, + "minItems": 1, + "description": "API keys for this provider" + }, + "network_config": { + "$ref": "#/$defs/network_config" + }, + "concurrency_and_buffer_size": { + "$ref": "#/$defs/concurrency_config" + }, + "proxy_config": { + "$ref": "#/$defs/proxy_config" + }, + "send_back_raw_response": { + "type": "boolean", + "description": "Include raw response in BifrostResponse (default: false)" + } + }, + "required": [ + "keys" + ], + "additionalProperties": false + }, + "provider_with_vertex_config": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/vertex_key" + }, + "minItems": 1, + "description": "API keys for this provider" + }, + "network_config": { + "$ref": "#/$defs/network_config" + }, + "concurrency_and_buffer_size": { + "$ref": "#/$defs/concurrency_config" + }, + "proxy_config": { + "$ref": "#/$defs/proxy_config" + }, + "send_back_raw_response": { + "type": "boolean", + "description": "Include raw response in BifrostResponse (default: false)" + } + }, + "required": [ + "keys" + ], + "additionalProperties": false + }, + "mcp_client_config": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the MCP client" + }, + "connection_type": { + "type": "string", + "enum": [ + "stdio", + "websocket", + "http" + ], + "description": "Connection type for MCP client" + }, + "stdio_config": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to execute" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command arguments" + }, + "envs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Environment variables" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + "websocket_config": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "WebSocket URL" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "http_config": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "HTTP URL" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "connection_type" + ], + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "connection_type": { + "const": "stdio" + } + }, + "required": [ + "stdio_config" + ] + }, + { + "properties": { + "connection_type": { + "const": "websocket" + } + }, + "required": [ + "websocket_config" + ] + }, + { + "properties": { + "connection_type": { + "const": "http" + } + }, + "required": [ + "http_config" + ] + } + ] + }, + "weaviate_config": { + "type": "object", + "description": "Weaviate configuration for vector store", + "properties": { + "scheme": { + "type": "string", + "description": "Weaviate server scheme (http or https) - REQUIRED" + }, + "host": { + "type": "string", + "description": "Weaviate server host (host:port) - REQUIRED" + }, + "api_key": { + "type": "string", + "description": "API key for Weaviate authentication (optional)" + }, + "grpc_config": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Weaviate server host (host:port). If host is without a port number then the 80 port for insecured and 443 port for secured connections will be used." + }, + "secured": { + "type": "boolean", + "description": "Secured set it to true if it's a secured connection" + } + } + }, + "headers": { + "type": "object", + "description": "Additional headers to send with requests" + }, + "timeout": { + "type": "string", + "pattern": "^[0-9]+(ns|us|Β΅s|ms|s|m|h)$", + "description": "Timeout for Weaviate operations (e.g., '5s')" + }, + "class_name": { + "type": "string", + "description": "Class name for Weaviate vector store" + }, + "properties": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Properties for Weaviate vector store" + } + }, + "required": [ + "scheme", + "host" + ], + "additionalProperties": false + }, + "proxy_config": { + "type": "object", + "description": "Proxy configuration for provider connections", + "properties": { + "type": { + "type": "string", + "enum": [ + "none", + "http", + "socks5", + "environment" + ], + "description": "Type of proxy to use" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the proxy server" + }, + "username": { + "type": "string", + "description": "Username for proxy authentication" + }, + "password": { + "type": "string", + "description": "Password for proxy authentication" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "cluster_config": { + "type": "object", + "description": "Cluster mode configuration", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether cluster mode is enabled" + }, + "peers": { + "type": "array", + "description": "List of peer addresses", + "items": { + "type": "string", + "description": "Peer address in host:port format" + } + }, + "gossip": { + "type": "object", + "description": "Gossip protocol configuration", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port for gossip communication" + }, + "config": { + "type": "object", + "description": "Gossip protocol settings", + "properties": { + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "description": "Timeout for operations in seconds" + }, + "success_threshold": { + "type": "integer", + "minimum": 1, + "description": "Number of successful probes required" + }, + "failure_threshold": { + "type": "integer", + "minimum": 1, + "description": "Number of failed probes before marking as failed" + } + }, + "required": [ + "timeout_seconds", + "success_threshold", + "failure_threshold" + ], + "additionalProperties": false + } + }, + "required": [ + "port", + "config" + ], + "additionalProperties": false + }, + "discovery": { + "type": "object", + "description": "Auto-discovery configuration for cluster nodes", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether auto-discovery is enabled" + }, + "type": { + "type": "string", + "enum": [ + "kubernetes", + "dns", + "udp", + "consul", + "etcd", + "mdns" + ], + "description": "Discovery mechanism type" + }, + "allowed_address_space": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CIDR notation for allowed address spaces (e.g., ['10.0.0.0/8', '192.168.0.0/16'])" + }, + "k8s_namespace": { + "type": "string", + "description": "Kubernetes namespace for service discovery" + }, + "k8s_label_selector": { + "type": "string", + "description": "Kubernetes label selector for filtering pods" + }, + "dns_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "DNS names to resolve for node discovery" + }, + "udp_broadcast_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port for UDP broadcast discovery" + }, + "consul_address": { + "type": "string", + "description": "Consul server address for service discovery" + }, + "etcd_endpoints": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Etcd endpoints for service discovery" + }, + "mdns_service": { + "type": "string", + "description": "mDNS service name for local network discovery" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "saml_config": { + "type": "object", + "description": "SAML/SCIM (System for Cross-domain Identity Management) configuration", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether SAML/SCIM authentication is enabled" + }, + "provider": { + "type": "string", + "enum": [ + "okta", + "entra" + ], + "description": "SCIM provider type" + }, + "config": { + "type": "object", + "description": "Provider-specific configuration" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "provider": { + "const": "okta" + } + } + }, + "then": { + "properties": { + "config": { + "$ref": "#/$defs/okta_config" + } + } + } + }, + { + "if": { + "properties": { + "provider": { + "const": "entra" + } + } + }, + "then": { + "properties": { + "config": { + "$ref": "#/$defs/entra_config" + } + } + } + } + ] + }, + "okta_config": { + "type": "object", + "description": "Okta JWT authentication configuration", + "properties": { + "issuerUrl": { + "type": "string", + "format": "uri", + "description": "Okta issuer URL (e.g., https://your-domain.okta.com/oauth2/default)" + }, + "clientId": { + "type": "string", + "description": "Okta application client ID" + }, + "clientSecret": { + "type": "string", + "description": "Okta client secret (optional, required for token revocation)" + }, + "audience": { + "type": "string", + "description": "JWT audience for validation (optional)" + }, + "userIdField": { + "type": "string", + "description": "JWT claim field for user ID (default: 'sub')", + "default": "sub" + }, + "teamIdsField": { + "type": "string", + "description": "JWT claim field for team IDs (default: 'groups')", + "default": "groups" + }, + "rolesField": { + "type": "string", + "description": "JWT claim field for roles (default: 'roles')", + "default": "roles" + } + }, + "required": [ + "issuerUrl", + "clientId" + ], + "additionalProperties": false + }, + "entra_config": { + "type": "object", + "description": "Microsoft Entra ID (formerly Azure AD) JWT authentication configuration", + "properties": { + "tenantId": { + "type": "string", + "description": "Azure tenant ID or 'common' for multi-tenant applications" + }, + "clientId": { + "type": "string", + "description": "Application (client) ID from Azure portal" + }, + "clientSecret": { + "type": "string", + "description": "Client secret (optional, required for token revocation)" + }, + "audience": { + "type": "string", + "description": "JWT audience for validation (default: clientId)" + }, + "appIdUri": { + "type": "string", + "format": "uri", + "description": "App ID URI for v1.0 tokens (e.g., api://{clientId})" + }, + "userIdField": { + "type": "string", + "description": "JWT claim field for user ID (default: 'oid')", + "default": "oid" + }, + "teamIdsField": { + "type": "string", + "description": "JWT claim field for team IDs (default: 'groups')", + "default": "groups" + }, + "rolesField": { + "type": "string", + "description": "JWT claim field for roles (default: 'roles')", + "default": "roles" + } + }, + "required": [ + "tenantId", + "clientId" + ], + "additionalProperties": false + }, + "load_balancer_config": { + "type": "object", + "description": "Load balancer configuration for intelligent request routing", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether load balancing is enabled" + }, + "tracker_config": { + "type": "object", + "description": "Configuration for tracking route metrics and performance" + }, + "bootstrap": { + "type": "object", + "description": "Bootstrap data for initializing load balancer with historical metrics", + "properties": { + "route_metrics": { + "type": "object", + "description": "Historical metrics per route" + }, + "direction_metrics": { + "type": "object", + "description": "Historical metrics per direction" + }, + "routes": { + "type": "object", + "description": "Known routes" + } + } + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "guardrails_config": { + "type": "object", + "description": "Guardrails configuration for content moderation and policy enforcement", + "properties": { + "guardrail_rules": { + "type": "array", + "description": "List of guardrail rules", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the rule" + }, + "name": { + "type": "string", + "description": "Name of the guardrail rule" + }, + "description": { + "type": "string", + "description": "Description of what the rule does" + }, + "enabled": { + "type": "boolean", + "description": "Whether this rule is enabled" + }, + "cel_expression": { + "type": "string", + "description": "CEL (Common Expression Language) expression for rule evaluation" + }, + "apply_to": { + "type": "string", + "enum": [ + "input", + "output", + "both" + ], + "description": "When to apply the guardrail (input, output, or both)" + }, + "sampling_rate": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Percentage of requests to apply this rule to (0-100)" + }, + "timeout": { + "type": "integer", + "minimum": 0, + "description": "Timeout in milliseconds for rule execution" + }, + "provider_config_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "IDs of provider configurations to use with this rule" + } + }, + "required": [ + "id", + "name", + "enabled", + "cel_expression", + "apply_to" + ], + "additionalProperties": false + } + }, + "guardrail_providers": { + "type": "array", + "description": "List of guardrail provider configurations", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the provider config" + }, + "provider_name": { + "type": "string", + "description": "Name of the guardrail provider (e.g., 'bedrock', 'azure')" + }, + "policy_name": { + "type": "string", + "description": "Name of the specific policy to use" + }, + "enabled": { + "type": "boolean", + "description": "Whether this provider config is enabled" + }, + "config": { + "type": "object", + "description": "Provider-specific configuration" + } + }, + "required": [ + "id", + "provider_name", + "policy_name", + "enabled" + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/transports/docker-entrypoint.sh b/transports/docker-entrypoint.sh new file mode 100644 index 000000000..9563048af --- /dev/null +++ b/transports/docker-entrypoint.sh @@ -0,0 +1,76 @@ +#!/bin/sh +set -e + +# Function to fix permissions on mounted volumes +fix_permissions() { + # Check if /app/data exists and fix ownership if needed + if [ -d "/app/data" ]; then + # Get current user info + CURRENT_UID=$(id -u) + CURRENT_GID=$(id -g) + + # Get directory ownership + DATA_UID=$(stat -c '%u' /app/data 2>/dev/null || echo "0") + DATA_GID=$(stat -c '%g' /app/data 2>/dev/null || echo "0") + + # If ownership doesn't match current user, try to fix it + if [ "$DATA_UID" != "$CURRENT_UID" ] || [ "$DATA_GID" != "$CURRENT_GID" ]; then + echo "Fixing permissions on /app/data (was $DATA_UID:$DATA_GID, setting to $CURRENT_UID:$CURRENT_GID)" + + # Try to change ownership (will work if running as root or if user has permission) + if chown -R "$CURRENT_UID:$CURRENT_GID" /app/data 2>/dev/null; then + echo "Successfully updated permissions on /app/data" + else + echo "Warning: Could not change ownership of /app/data. You may need to run:" + echo " docker run --user \$(id -u):\$(id -g) ..." + echo " or ensure the host directory is owned by UID:GID $CURRENT_UID:$CURRENT_GID" + fi + fi + + # Ensure logs subdirectory exists with correct permissions + mkdir -p /app/data/logs + chmod 755 /app/data/logs 2>/dev/null || true + fi +} + +# Fix permissions before starting the application +fix_permissions + +# Parse command line arguments and set environment variables +parse_args() { + while [ $# -gt 0 ]; do + case $1 in + --port|-port) + if [ -n "$2" ]; then + export APP_PORT="$2" + shift 2 + else + echo "Error: --port requires a value" + exit 1 + fi + ;; + --host|-host) + if [ -n "$2" ]; then + export APP_HOST="$2" + shift 2 + else + echo "Error: --host requires a value" + exit 1 + fi + ;; + *) + # Keep other arguments for the main application + set -- "$@" "$1" + shift + ;; + esac + done +} + +# Parse arguments if any are provided +if [ $# -gt 1 ]; then + parse_args "$@" +fi + +# Build the command with environment variables and standard arguments +exec /app/main -app-dir "$APP_DIR" -port "$APP_PORT" -host "$APP_HOST" -log-level "$LOG_LEVEL" -log-style "$LOG_STYLE" \ No newline at end of file diff --git a/transports/go.mod b/transports/go.mod index c92d309e3..838668454 100644 --- a/transports/go.mod +++ b/transports/go.mod @@ -2,32 +2,129 @@ module github.com/maximhq/bifrost/transports go 1.24.1 +toolchain go1.24.3 + require ( + github.com/bytedance/sonic v1.14.1 github.com/fasthttp/router v1.5.4 - github.com/joho/godotenv v1.5.1 - github.com/maximhq/bifrost/core v1.0.2 - github.com/valyala/fasthttp v1.60.0 + github.com/fasthttp/websocket v1.5.12 + github.com/google/uuid v1.6.0 + github.com/maximhq/bifrost/core v1.2.22 + github.com/maximhq/bifrost/framework v1.1.27 + github.com/maximhq/bifrost/plugins/governance v1.3.28 + github.com/maximhq/bifrost/plugins/logging v1.3.28 + github.com/maximhq/bifrost/plugins/maxim v1.4.27 + github.com/maximhq/bifrost/plugins/otel v1.0.27 + github.com/maximhq/bifrost/plugins/semanticcache v1.3.27 + github.com/maximhq/bifrost/plugins/telemetry v1.3.27 + github.com/prometheus/client_golang v1.23.0 + github.com/valyala/fasthttp v1.67.0 + gorm.io/gorm v1.31.1 ) require ( - github.com/andybalholm/brotli v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect - github.com/aws/smithy-go v1.22.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect + github.com/aws/smithy-go v1.23.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.0 // indirect + github.com/go-openapi/errors v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/loads v0.23.1 // indirect + github.com/go-openapi/runtime v0.29.0 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/strfmt v0.24.0 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/validate v0.25.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jaswdr/faker/v2 v2.8.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/maximhq/bifrost/plugins/mocker v1.3.27 // indirect + github.com/maximhq/maxim-go v0.1.14 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/redis/go-redis/v9 v9.14.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/text v0.24.0 // indirect + github.com/weaviate/weaviate v1.33.1 // indirect + github.com/weaviate/weaviate-go-client/v5 v5.5.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.8.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/transports/go.sum b/transports/go.sum index bab9764a1..eeeff2f0f 100644 --- a/transports/go.sum +++ b/transports/go.sum @@ -1,52 +1,301 @@ -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= -github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= +github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko= +github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k= +github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs= +github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fasthttp/router v1.5.4 h1:oxdThbBwQgsDIYZ3wR1IavsNl6ZS9WdjKukeMikOnC8= github.com/fasthttp/router v1.5.4/go.mod h1:3/hysWq6cky7dTfzaaEPZGdptwjwx0qzTgFCKEWRjgc= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= +github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= +github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= +github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= +github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0= +github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44= +github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0= +github.com/go-openapi/runtime v0.29.0/go.mod h1:52HOkEmLL/fE4Pg3Kf9nxc9fYQn0UsIWyGjGIJE9dkg= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc= +github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU= +github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jaswdr/faker/v2 v2.8.0 h1:3AxdXW9U7dJmWckh/P0YgRbNlCcVsTyrUNUnLVP9b3Q= +github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/maximhq/bifrost/core v1.0.1 h1:B0u6o13faUexA+V0EUU0bsLW2dHg9+R2TZKQzPzCxlY= -github.com/maximhq/bifrost/core v1.0.1/go.mod h1:4+Ept2EnX1EEjH/mBuSwK7eE56znI/BCoCbIrx25/x8= -github.com/maximhq/bifrost/core v1.0.2 h1:GG1CGrvbz5lbdDudlJodKHx9pHr0VAoUd5lhgxUWc00= -github.com/maximhq/bifrost/core v1.0.2/go.mod h1:ZF8LVnUwVzHZ3SkCQPvXXmu0w3b4sjRLS6ij9aPYcjg= -github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= -github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maximhq/bifrost/core v1.2.22 h1:bwY7gYPlWTH06Esd7Qn6flarbTloI802vomP+KTKTjw= +github.com/maximhq/bifrost/core v1.2.22/go.mod h1:tCsM7mGAUgs+jY9yfotSsE0HFr7J7SjzEItKhVDvLPo= +github.com/maximhq/bifrost/framework v1.1.27 h1:jqG+uJENycCtbzinBTMKFQzj6L+Lj3BPZz63Azw7qPA= +github.com/maximhq/bifrost/framework v1.1.27/go.mod h1:oKDoY3V4MlVrQ9JaHSN5bPLyuGHgtT73oj1S8uoa/Eg= +github.com/maximhq/bifrost/plugins/governance v1.3.28 h1:5w5bxjEpzhU1pjgAUr9oEcnuFuO+57nGIhvCl/PXUUs= +github.com/maximhq/bifrost/plugins/governance v1.3.28/go.mod h1:gsz90eqhMrfLDtkQ384K9TbcrlUSaqzwscOayOWBjxA= +github.com/maximhq/bifrost/plugins/logging v1.3.28 h1:NqG+AoZUde3m6V0Po9yjlnK4CiQ1QB/GDrmf0zFvHxU= +github.com/maximhq/bifrost/plugins/logging v1.3.28/go.mod h1:nsCovsWTwG8Q4pCSBtm+pAIZTui6DG+MZiDTAH52FNQ= +github.com/maximhq/bifrost/plugins/maxim v1.4.27 h1:DSmzOkjx4RlTdwqjH9lm6ZRvHL8Gj/arO2Ln6JMYLEA= +github.com/maximhq/bifrost/plugins/maxim v1.4.27/go.mod h1:l0TurRtkgI29WmKSrLY7IjqDwsALbqj14k1ReKbSg7I= +github.com/maximhq/bifrost/plugins/mocker v1.3.27 h1:7sGOaZHylSllfS9GrCr8Rp+WhcOcXco6EKeMag0l604= +github.com/maximhq/bifrost/plugins/mocker v1.3.27/go.mod h1:p7RU3W8MVFBwKbkOgvB9VJABmZt8rDG6dIDWyAxrWVc= +github.com/maximhq/bifrost/plugins/otel v1.0.27 h1:udrLwfQxzq0joFsNWerj6AtFEQv25myzpNDF1Ggwt3o= +github.com/maximhq/bifrost/plugins/otel v1.0.27/go.mod h1:Kqcy9Zl79nafGvNI4tEPerdkmm7SSEpPfmc2oDO4ElI= +github.com/maximhq/bifrost/plugins/semanticcache v1.3.27 h1:1aYQM+BULgIaa4/gmly/5Dv1k5TIXIPQf9py8Ro0cGM= +github.com/maximhq/bifrost/plugins/semanticcache v1.3.27/go.mod h1:oT1tLMUkcw8H2AdfYFG850qEqFLFj1KnCCm9yJGn9dk= +github.com/maximhq/bifrost/plugins/telemetry v1.3.27 h1:CD62+l8ieNF6osw3zugv6kwt448bQtQcfhFgdafDQ1E= +github.com/maximhq/bifrost/plugins/telemetry v1.3.27/go.mod h1:aON72XBfvL0dQVunOmErp8yqZQnW0Pkfjb6p8RSB6+c= +github.com/maximhq/maxim-go v0.1.14 h1:NQgpf3aRoD2Kq1GAqeSrLn3rQresn1H6mPP3JJ85qhA= +github.com/maximhq/maxim-go v0.1.14/go.mod h1:0+UTWM7UZwNNE5VnljLtr/vpRGtYP8r/2q9WDwlLWFw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4= +github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw= -github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc= +github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac= +github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM= +github.com/weaviate/weaviate v1.33.1 h1:fV69ffJSH0aO3LvLiKYlVZ8wFa94oQ1g3uMyZGTb838= +github.com/weaviate/weaviate v1.33.1/go.mod h1:SnxXSIoiusZttZ/gI9knXhFAu0UYqn9N/ekgsNnXbNw= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= +go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/transports/http/main.go b/transports/http/main.go deleted file mode 100644 index 8af6fb317..000000000 --- a/transports/http/main.go +++ /dev/null @@ -1,443 +0,0 @@ -// Package http provides an HTTP service using FastHTTP that exposes endpoints -// for text and chat completions using various AI model providers (OpenAI, Anthropic, Bedrock, etc.). - -// The HTTP service provides two main endpoints: -// - /v1/text/completions: For text completion requests -// - /v1/chat/completions: For chat completion requests - -// Configuration is handled through a JSON config file and environment variables: -// - Use -config flag to specify the config file location -// - Use -env flag to specify the .env file location -// - Use -port flag to specify the server port (default: 8080) -// - Use -pool-size flag to specify the initial connection pool size (default: 300) - -// try running the server with: -// go run http.go -config config.example.json -env .env -port 8080 -pool-size 300 -// after setting the environment variables present in config.example.json in your .env file. - -package main - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "log" - "os" - "reflect" - "strings" - "sync" - - "github.com/fasthttp/router" - "github.com/joho/godotenv" - bifrost "github.com/maximhq/bifrost/core" - schemas "github.com/maximhq/bifrost/core/schemas" - "github.com/maximhq/bifrost/core/schemas/meta" - "github.com/valyala/fasthttp" -) - -// Command line flags -var ( - initialPoolSize int // Initial size of the connection pool - dropExcessRequests bool // Drop excess requests - port string // Port to run the server on - configPath string // Path to the config file - envPath string // Path to the .env file -) - -// init initializes command line flags with default values. -// It also checks for environment variables that might override the defaults. -func init() { - flag.IntVar(&initialPoolSize, "pool-size", 300, "Initial pool size for Bifrost") - flag.StringVar(&port, "port", "8080", "Port to run the server on") - flag.StringVar(&configPath, "config", "", "Path to the config file") - flag.StringVar(&envPath, "env", "", "Path to the .env file") - flag.BoolVar(&dropExcessRequests, "drop-excess-requests", false, "Drop excess requests") - flag.Parse() - - if configPath == "" { - log.Fatalf("config path is required") - } - - if envPath == "" { - log.Fatalf("env path is required") - } -} - -// ProviderConfig represents the configuration for a specific AI model provider. -// It includes API keys, network settings, provider-specific metadata, and concurrency settings. -type ProviderConfig struct { - Keys []schemas.Key `json:"keys"` // API keys for the provider - NetworkConfig *schemas.NetworkConfig `json:"network_config,omitempty"` // Network-related settings - MetaConfig *schemas.MetaConfig `json:"-"` // Provider-specific metadata - ConcurrencyAndBufferSize *schemas.ConcurrencyAndBufferSize `json:"concurrency_and_buffer_size,omitempty"` // Concurrency settings -} - -// ConfigMap maps provider names to their configurations. -type ConfigMap map[schemas.ModelProvider]ProviderConfig - -// readConfig reads and parses the configuration file. -// It handles case conversion for provider names and sets up provider-specific metadata. -// Returns a ConfigMap containing all provider configurations. -// Panics if the config file cannot be read or parsed. -// -// In the config file, use placeholder keys (e.g., env.OPENAI_API_KEY) instead of hardcoding actual values. -// These placeholders will be replaced with the corresponding values from the .env file. -// Location of the .env file is specified by the -env flag. It -// Example: -// -// "keys":[{ -// "value": "env.OPENAI_API_KEY" -// "models": ["gpt-4o-mini", "gpt-4-turbo"], -// "weight": 1.0 -// }] -// -// In this example, OPENAI_API_KEY refers to a key in the .env file. At runtime, its value will be used to replace the placeholder. -// Same setup applies to keys in meta configs of all the providers. -// Example: -// -// "meta_config": { -// "secret_access_key": "env.BEDROCK_ACCESS_KEY" -// "region": "env.BEDROCK_REGION" -// } -// -// In this example, BEDROCK_ACCESS_KEY and BEDROCK_REGION refer to keys in the .env file. -func readConfig(configLocation string) ConfigMap { - data, err := os.ReadFile(configLocation) - if err != nil { - log.Fatalf("failed to read config JSON file: %v", err) - } - - // First unmarshal into a map with string keys to handle case conversion - var rawConfig map[string]ProviderConfig - if err := json.Unmarshal(data, &rawConfig); err != nil { - log.Fatalf("failed to unmarshal JSON: %v", err) - } - - if rawConfig == nil { - log.Fatalf("provided config is nil") - } - - // Create a new config map with lowercase provider names - config := make(ConfigMap) - for rawProvider, cfg := range rawConfig { - provider := schemas.ModelProvider(strings.ToLower(rawProvider)) - - switch provider { - case schemas.Azure: - var azureMetaConfig meta.AzureMetaConfig - if err := json.Unmarshal(data, &struct { - Azure struct { - MetaConfig *meta.AzureMetaConfig `json:"meta_config"` - } `json:"Azure"` - }{Azure: struct { - MetaConfig *meta.AzureMetaConfig `json:"meta_config"` - }{&azureMetaConfig}}); err != nil { - log.Printf("warning: failed to unmarshal Azure meta config: %v", err) - } - var metaConfig schemas.MetaConfig = &azureMetaConfig - cfg.MetaConfig = &metaConfig - case schemas.Bedrock: - var bedrockMetaConfig meta.BedrockMetaConfig - if err := json.Unmarshal(data, &struct { - Bedrock struct { - MetaConfig *meta.BedrockMetaConfig `json:"meta_config"` - } `json:"Bedrock"` - }{Bedrock: struct { - MetaConfig *meta.BedrockMetaConfig `json:"meta_config"` - }{&bedrockMetaConfig}}); err != nil { - log.Printf("warning: failed to unmarshal Bedrock meta config: %v", err) - } - var metaConfig schemas.MetaConfig = &bedrockMetaConfig - cfg.MetaConfig = &metaConfig - } - - config[provider] = cfg - } - - return config -} - -// BaseAccount implements the Account interface for Bifrost. -// It manages provider configurations and API keys. -type BaseAccount struct { - Config ConfigMap // Map of provider configurations - mu sync.Mutex // Mutex to protect Config access -} - -// GetConfiguredProviders returns a list of all configured providers. -// Implements the Account interface. -func (baseAccount *BaseAccount) GetConfiguredProviders() ([]schemas.ModelProvider, error) { - baseAccount.mu.Lock() - defer baseAccount.mu.Unlock() - - providers := make([]schemas.ModelProvider, 0, len(baseAccount.Config)) - for provider := range baseAccount.Config { - providers = append(providers, provider) - } - return providers, nil -} - -// GetKeysForProvider returns the API keys configured for a specific provider. -// Implements the Account interface. -func (baseAccount *BaseAccount) GetKeysForProvider(providerKey schemas.ModelProvider) ([]schemas.Key, error) { - baseAccount.mu.Lock() - defer baseAccount.mu.Unlock() - - return baseAccount.Config[providerKey].Keys, nil -} - -// GetConfigForProvider returns the complete configuration for a specific provider. -// Implements the Account interface. -func (baseAccount *BaseAccount) GetConfigForProvider(providerKey schemas.ModelProvider) (*schemas.ProviderConfig, error) { - baseAccount.mu.Lock() - defer baseAccount.mu.Unlock() - - config, exists := baseAccount.Config[providerKey] - if !exists { - return nil, errors.New("config for provider not found") - } - - providerConfig := &schemas.ProviderConfig{} - - if config.NetworkConfig != nil { - providerConfig.NetworkConfig = *config.NetworkConfig - } - - if config.MetaConfig != nil { - providerConfig.MetaConfig = *config.MetaConfig - } - - if config.ConcurrencyAndBufferSize != nil { - providerConfig.ConcurrencyAndBufferSize = *config.ConcurrencyAndBufferSize - } - - return providerConfig, nil -} - -// readKeys reads environment variables from a .env file and updates the provider configurations. -// It replaces values starting with "env." in the config with actual values from the environment. -// Returns an error if any required environment variable is missing. -func (baseAccount *BaseAccount) readKeys(envLocation string) error { - envVars, err := godotenv.Read(envLocation) - if err != nil { - return fmt.Errorf("failed to read .env file: %w", err) - } - - // Helper function to check and replace env values - replaceEnvValue := func(value string) (string, error) { - if strings.HasPrefix(value, "env.") { - envKey := strings.TrimPrefix(value, "env.") - if envValue, exists := envVars[envKey]; exists { - return envValue, nil - } - return "", fmt.Errorf("environment variable %s not found in .env file", envKey) - } - return value, nil - } - - // Helper function to recursively check and replace env values in a struct - var processStruct func(interface{}) error - processStruct = func(v interface{}) error { - val := reflect.ValueOf(v) - - // Dereference pointer if present - if val.Kind() == reflect.Ptr { - val = val.Elem() - } - - // Handle interface types - if val.Kind() == reflect.Interface { - val = val.Elem() - // If the interface value is a pointer, dereference it - if val.Kind() == reflect.Ptr { - val = val.Elem() - } - } - - if val.Kind() != reflect.Struct { - return nil - } - - typ := val.Type() - for i := 0; i < val.NumField(); i++ { - field := val.Field(i) - fieldType := typ.Field(i) - - // Skip unexported fields - if !field.CanSet() { - continue - } - - switch field.Kind() { - case reflect.String: - if field.CanSet() { - value := field.String() - if strings.HasPrefix(value, "env.") { - newValue, err := replaceEnvValue(value) - if err != nil { - return fmt.Errorf("field %s: %w", fieldType.Name, err) - } - field.SetString(newValue) - } - } - case reflect.Interface: - if !field.IsNil() { - if err := processStruct(field.Interface()); err != nil { - return err - } - } - } - } - return nil - } - - // Lock the config map for the entire update operation - baseAccount.mu.Lock() - defer baseAccount.mu.Unlock() - - // Check and replace values in provider configs - for provider, config := range baseAccount.Config { - // Check keys - for i, key := range config.Keys { - newValue, err := replaceEnvValue(key.Value) - if err != nil { - return fmt.Errorf("provider %s: %w", provider, err) - } - config.Keys[i].Value = newValue - } - - // Check meta config if it exists - if config.MetaConfig != nil { - if err := processStruct(config.MetaConfig); err != nil { - return fmt.Errorf("provider %s: %w", provider, err) - } - } - - baseAccount.Config[provider] = config - } - - return nil -} - -// CompletionRequest represents a request for either text or chat completion. -// It includes all necessary fields for both types of completions. -type CompletionRequest struct { - Provider schemas.ModelProvider `json:"provider"` // The AI model provider to use - Messages []schemas.Message `json:"messages"` // Chat messages (for chat completion) - Text string `json:"text"` // Text input (for text completion) - Model string `json:"model"` // Model to use - Params *schemas.ModelParameters `json:"params"` // Additional model parameters - Fallbacks []schemas.Fallback `json:"fallbacks"` // Fallback providers and models -} - -// handleCompletion processes both text and chat completion requests. -// It handles request parsing, validation, and response formatting. -func handleCompletion(ctx *fasthttp.RequestCtx, client *bifrost.Bifrost, isChat bool) { - var req CompletionRequest - if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { - ctx.SetStatusCode(fasthttp.StatusBadRequest) - ctx.SetBodyString(fmt.Sprintf("invalid request format: %v", err)) - return - } - - if req.Provider == "" { - ctx.SetStatusCode(fasthttp.StatusBadRequest) - ctx.SetBodyString("Provider is required") - return - } - - bifrostReq := &schemas.BifrostRequest{ - Model: req.Model, - Params: req.Params, - Fallbacks: req.Fallbacks, - } - - if isChat { - if len(req.Messages) == 0 { - ctx.SetStatusCode(fasthttp.StatusBadRequest) - ctx.SetBodyString("Messages array is required") - return - } - bifrostReq.Input = schemas.RequestInput{ - ChatCompletionInput: &req.Messages, - } - } else { - if req.Text == "" { - ctx.SetStatusCode(fasthttp.StatusBadRequest) - ctx.SetBodyString("Text is required") - return - } - bifrostReq.Input = schemas.RequestInput{ - TextCompletionInput: &req.Text, - } - } - - var resp *schemas.BifrostResponse - var err *schemas.BifrostError - if isChat { - resp, err = client.ChatCompletionRequest(req.Provider, bifrostReq, ctx) - } else { - resp, err = client.TextCompletionRequest(req.Provider, bifrostReq, ctx) - } - - if err != nil { - if err.IsBifrostError { - ctx.SetStatusCode(fasthttp.StatusInternalServerError) - } else { - ctx.SetStatusCode(fasthttp.StatusBadRequest) - } - ctx.SetContentType("application/json") - json.NewEncoder(ctx).Encode(err) - return - } - - ctx.SetStatusCode(fasthttp.StatusOK) - ctx.SetContentType("application/json") - json.NewEncoder(ctx).Encode(resp) -} - -// main is the entry point of the application. -// It: -// 1. Reads and parses configuration -// 2. Initializes the Bifrost client -// 3. Sets up HTTP routes -// 4. Starts the HTTP server -func main() { - config := readConfig(configPath) - account := &BaseAccount{Config: config} - - if err := account.readKeys(envPath); err != nil { - log.Printf("warning: failed to read environment variables: %v", err) - } - - client, err := bifrost.Init(schemas.BifrostConfig{ - Account: account, - InitialPoolSize: initialPoolSize, - DropExcessRequests: dropExcessRequests, - }) - if err != nil { - log.Fatalf("failed to initialize bifrost: %v", err) - } - - r := router.New() - - r.POST("/v1/text/completions", func(ctx *fasthttp.RequestCtx) { - handleCompletion(ctx, client, false) - }) - - r.POST("/v1/chat/completions", func(ctx *fasthttp.RequestCtx) { - handleCompletion(ctx, client, true) - }) - - server := &fasthttp.Server{ - Handler: r.Handler, - } - - fmt.Printf("Starting HTTP server on port %s\n", port) - if err := server.ListenAndServe(fmt.Sprintf(":%s", port)); err != nil { - log.Fatalf("failed to start server: %v", err) - } - - client.Shutdown() -} diff --git a/transports/version b/transports/version new file mode 100644 index 000000000..98390b6f2 --- /dev/null +++ b/transports/version @@ -0,0 +1 @@ +1.3.24 diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/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 diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 000000000..f73138e64 --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,21 @@ +{ + "printWidth": 140, + "singleQuote": false, + "bracketSpacing": true, + "semi": true, + "bracketSameLine": false, + "useTabs": true, + "tabWidth": 2, + "trailingComma": "all", + "plugins": [ + "prettier-plugin-tailwindcss" + ], + "tailwindAttributes": [ + "buttonClassname" + ], + "tailwindFunctions": [ + "cn", + "classNames" + ], + "endOfLine": "lf" +} \ No newline at end of file diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..02ff65c46 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,242 @@ +# Bifrost UI + +A modern, production-ready dashboard for the [Bifrost AI Gateway](https://github.com/maximhq/bifrost) - providing real-time monitoring, configuration management, and comprehensive observability for your AI infrastructure. + +## 🌟 Overview + +Bifrost UI is a Next.js-powered web dashboard that serves as the control center for your Bifrost AI Gateway. It provides an intuitive interface to monitor AI requests, configure providers, manage MCP clients, and extend functionality through plugins. + +### Key Features + +- **πŸ”΄ Real-time Log Monitoring** - Live streaming dashboard with WebSocket integration +- **βš™οΈ Provider Management** - Configure 8+ AI providers (OpenAI, Azure, Anthropic, Bedrock, etc.) +- **πŸ”Œ MCP Integration** - Manage Model Context Protocol clients for advanced AI capabilities +- **🧩 Plugin System** - Extend functionality with observability, testing, and custom plugins +- **πŸ“Š Analytics Dashboard** - Request metrics, success rates, latency tracking, and token usage +- **🎨 Modern UI** - Dark/light mode, responsive design, and accessible components +- **πŸ“š Documentation Hub** - Built-in documentation browser and quick-start guides + +## πŸš€ Quick Start + +### Development + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +The development server runs on `http://localhost:3000` and connects to your Bifrost HTTP transport backend (default: `http://localhost:8080`). + +### Environment Variables + +```bash +# Development only - customize Bifrost backend port +NEXT_PUBLIC_BIFROST_PORT=8080 +``` + +## πŸ—οΈ Architecture + +### Technology Stack + +- **Framework**: Next.js 15 with App Router +- **Language**: TypeScript +- **Styling**: Tailwind CSS + Radix UI components +- **State Management**: React hooks and context +- **Real-time**: WebSocket integration +- **HTTP Client**: Axios with typed service layer +- **Theme**: Dark/light mode support + +### Integration Model + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” HTTP/WebSocket β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Bifrost UI β”‚ ◄─────────────────► β”‚ Bifrost HTTP β”‚ +β”‚ (Next.js) β”‚ β”‚ Transport (Go) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ Build artifacts β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Development**: UI runs on port 3000, connects to Go backend on port 8080 +- **Production**: UI built as static assets served directly by Go HTTP transport +- **Communication**: REST API + WebSocket for real-time features + +## πŸ“± Features Deep Dive + +### Real-time Log Monitoring + +The main dashboard provides comprehensive request monitoring: + +- **Live Updates**: WebSocket connection for real-time log streaming +- **Advanced Filtering**: Filter by providers, models, status, content, and time ranges +- **Request Analytics**: Success rates, average latency, total tokens usage +- **Detailed Views**: Full request/response inspection with syntax highlighting +- **Search**: Full-text search across request content and metadata + +### Provider Configuration + +Manage all your AI providers from a unified interface: + +- **Supported Providers**: OpenAI, Azure OpenAI, Anthropic, AWS Bedrock, Cohere, Google Vertex AI, Mistral, Ollama, Parasail, SGLang, Cerebras, Groq, Gemini, OpenRouter +- **Key Management**: Multiple API keys with weights and model assignments +- **Network Configuration**: Custom base URLs, timeouts, retry policies, proxy settings +- **Provider-specific Settings**: Azure deployments, Bedrock regions, Vertex projects +- **Concurrency Control**: Per-provider concurrency limits and buffer sizes + +### MCP Client Management + +Model Context Protocol integration for advanced AI capabilities: + +- **Client Configuration**: Add, update, and delete MCP clients +- **Connection Monitoring**: Real-time status and health checks +- **Reconnection**: Manual and automatic reconnection capabilities +- **Tool Integration**: Seamless integration with MCP tools and resources + +### Plugin Ecosystem + +Extend Bifrost with powerful plugins: + +- **Maxim Logger**: Advanced LLM observability and analytics +- **Response Mocker**: Mock responses for testing and development +- **Circuit Breaker**: Resilience patterns and failure handling +- **Custom Plugins**: Build your own with the plugin development guide + +## πŸ› οΈ Development + +### Project Structure + +``` +ui/ +β”œβ”€β”€ app/ # Next.js App Router pages +β”‚ β”œβ”€β”€ page.tsx # Main logs dashboard +β”‚ β”œβ”€β”€ config/ # Provider & MCP configuration +β”‚ β”œβ”€β”€ docs/ # Documentation browser +β”‚ └── plugins/ # Plugin management +β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ logs/ # Log monitoring components +β”‚ β”œβ”€β”€ config/ # Configuration forms +β”‚ └── ui/ # Base UI components (Radix) +β”œβ”€β”€ hooks/ # Custom React hooks +β”œβ”€β”€ lib/ # Utilities and services +β”‚ β”œβ”€β”€ api.ts # Backend API service +β”‚ β”œβ”€β”€ types/ # TypeScript definitions +β”‚ └── utils/ # Helper functions +└── scripts/ # Build and deployment scripts +``` + +### API Integration + +The UI uses Redux Toolkit + RTK Query for state management and API communication with the Bifrost HTTP transport backend: + +```typescript +// Example API usage with RTK Query +import { useGetLogsQuery, useCreateProviderMutation, getErrorMessage } from "@/lib/store"; + +// Get real-time logs with automatic caching +const { data: logs, error, isLoading } = useGetLogsQuery({ filters, pagination }); + +// Configure provider with optimistic updates +const [createProvider] = useCreateProviderMutation(); + +const handleCreate = async () => { + try { + await createProvider({ + provider: "openai", + keys: [{ value: "sk-...", models: ["gpt-4"], weight: 1 }], + // ... other config + }).unwrap(); + // Success handling + } catch (error) { + console.error(getErrorMessage(error)); + } +}; +``` + +### Component Guidelines + +- **Composition**: Use Radix UI primitives for accessibility +- **Styling**: Tailwind CSS with CSS variables for theming +- **Types**: Full TypeScript coverage matching Go backend schemas +- **Error Handling**: Consistent error states and user feedback + +### Adding New Features + +1. **Backend Integration**: Add API endpoints to `lib/api.ts` +2. **Type Definitions**: Update types in `lib/types/` +3. **UI Components**: Build with Radix UI and Tailwind +4. **State Management**: Use React hooks or context as needed +5. **Real-time Updates**: Integrate WebSocket events when applicable + +## πŸ”§ Configuration + +### Provider Setup + +The UI supports comprehensive provider configuration: + +```typescript +interface ProviderConfig { + keys: Key[]; // API keys with model assignments + network_config: NetworkConfig; // URLs, timeouts, retries + meta_config?: MetaConfig; // Provider-specific settings + concurrency_and_buffer_size: { + // Performance tuning + concurrency: number; + buffer_size: number; + }; + proxy_config?: ProxyConfig; // Proxy settings +} +``` + +### Real-time Features + +WebSocket connection provides: + +- Live log streaming +- Connection status monitoring +- Automatic reconnection +- Filtered real-time updates + +## πŸ“Š Monitoring & Analytics + +The dashboard provides comprehensive observability: + +- **Request Metrics**: Total requests, success rate, average latency +- **Token Usage**: Input/output tokens, total consumption tracking +- **Provider Performance**: Per-provider success rates and latencies +- **Error Analysis**: Detailed error categorization and troubleshooting +- **Historical Data**: Time-based filtering and trend analysis + +## 🀝 Contributing + +We welcome contributions! See our [Contributing Guide](https://github.com/maximhq/bifrost/tree/main/docs/contributing) for: + +- Code conventions and style guide +- Development setup and workflow +- Adding new providers or features +- Plugin development guidelines + +## πŸ“š Documentation + +- **Quick Start**: [Get started in 30 seconds](https://github.com/maximhq/bifrost/tree/main/docs/quickstart) +- **Configuration**: [Complete setup guide](https://github.com/maximhq/bifrost/tree/main/docs/usage/http-transport/configuration) +- **API Reference**: [HTTP transport endpoints](https://github.com/maximhq/bifrost/tree/main/docs/usage/http-transport) +- **Architecture**: [Design and performance](https://github.com/maximhq/bifrost/tree/main/docs/architecture) + +## πŸ”— Links + +- **Main Repository**: [github.com/maximhq/bifrost](https://github.com/maximhq/bifrost) +- **HTTP Transport**: [../transports/bifrost-http](../transports/bifrost-http) +- **Documentation**: [docs/](../docs/) +- **Website**: [getmaxim.ai](https://getmaxim.ai) + +## πŸ“„ License + +Licensed under the same terms as the main Bifrost project. See [LICENSE](../LICENSE) for details. + +--- + +_Built with β™₯️ by [Maxim AI](https://getmaxim.ai)_ diff --git a/ui/app/_fallbacks/enterprise/components/adaptive-routing/adaptiveRoutingView.tsx b/ui/app/_fallbacks/enterprise/components/adaptive-routing/adaptiveRoutingView.tsx new file mode 100644 index 000000000..783aae692 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/adaptive-routing/adaptiveRoutingView.tsx @@ -0,0 +1,16 @@ +import { Shuffle } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function AdaptiveRoutingView() { + return ( +
+ } + title="Unlock adaptive routing for better performance" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/intelligent-load-balancing" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/alert-channels/alertChannelsView.tsx b/ui/app/_fallbacks/enterprise/components/alert-channels/alertChannelsView.tsx new file mode 100644 index 000000000..bc8a5511c --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/alert-channels/alertChannelsView.tsx @@ -0,0 +1,16 @@ +import { Siren } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function AlertChannelsView() { + return ( +
+ } + title="Unlock alert channels for better observability" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/alert-channels" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/api-keys/APIKeysView.tsx b/ui/app/_fallbacks/enterprise/components/api-keys/APIKeysView.tsx new file mode 100644 index 000000000..0bc202c2b --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/api-keys/APIKeysView.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { useGetCoreConfigQuery } from "@/lib/store"; +import { Copy, InfoIcon, KeyRound } from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import ContactUsView from "../views/contactUsView"; + +export default function APIKeysView() { + const { data: bifrostConfig, isLoading } = useGetCoreConfigQuery({ fromDB: true }); + const [isTokenVisible, setIsTokenVisible] = useState(false); + const isAuthConfigure = useMemo(() => { + return bifrostConfig?.auth_config?.is_enabled; + }, [bifrostConfig]); + + const curlExample = `# Base64 encode your username:password +# Example: echo -n "username:password" | base64 +curl --location 'http://localhost:8080/v1/chat/completions' +--header 'Content-Type: application/json' +--header 'Accept: application/json' +--header 'Authorization: Basic ' +--data '{ + "model": "openai/gpt-4", + "messages": [ + { + "role": "user", + "content": "explain big bang?" + } + ] +}'`; + + const maskToken = (token: string, revealed: boolean) => { + if (revealed) return token; + return token.substring(0, 8) + "β€’".repeat(Math.max(0, token.length - 8)); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + }; + + if (isLoading) { + return
Loading...
; + } + if (!isAuthConfigure) { + return ( + + + +

+ To generate API keys, you need to set up admin username and password first.{" "} + + Configure Security Settings + + .
+
+ Once generated you will need to use this API key for all API calls to the Bifrost admin APIs and UI. +

+
+
+ ); + } + + const isInferenceAuthDisabled = bifrostConfig?.auth_config?.disable_auth_on_inference ?? false; + + return ( +
+ + + +

+ {isInferenceAuthDisabled ? ( + <> + Authentication is currently disabled for inference API calls. You can make inference requests without authentication. Dashboard and admin API calls still require Basic auth with your admin credentials encoded in the standard{" "} + username:password format with base64 encoding. + + ) : ( + <> + Use Basic auth with your admin credentials when making API calls to Bifrost. Encode your credentials in the standard{" "} + username:password format with base64 encoding. + + )} +

+ {!isInferenceAuthDisabled && ( + <> +
+

+ Example: +

+ +
+ +
+									{curlExample}
+								
+
+ + )} +
+
+ + } + title="Scope Based API Keys" + description="Need granular access control with scope-based API keys? Enterprise customers can create multiple API keys with specific permissions for different services, teams, or environments." + readmeLink="https://docs.getbifrost.io/enterprise/api-keys" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/audit-logs/auditLogsView.tsx b/ui/app/_fallbacks/enterprise/components/audit-logs/auditLogsView.tsx new file mode 100644 index 000000000..a2aeeb353 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/audit-logs/auditLogsView.tsx @@ -0,0 +1,16 @@ +import { ScrollText } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function AuditLogsView() { + return ( +
+ } + title="Unlock audit logs for better compliance" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/audit-logs" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/cluster/clusterView.tsx b/ui/app/_fallbacks/enterprise/components/cluster/clusterView.tsx new file mode 100644 index 000000000..66d7d797e --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/cluster/clusterView.tsx @@ -0,0 +1,16 @@ +import { Layers } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function ClusterPage() { + return ( +
+ } + title="Unlock cluster mode to scale reliably" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/clustering" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/guardrails/guardrailsConfigurationView.tsx b/ui/app/_fallbacks/enterprise/components/guardrails/guardrailsConfigurationView.tsx new file mode 100644 index 000000000..5db4d0cc3 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/guardrails/guardrailsConfigurationView.tsx @@ -0,0 +1,17 @@ +import { Construction } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + + +export default function GuardrailsConfigurationView() { + return ( +
+ } + title="Unlock guardrails for better security" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/guardrails" + /> +
+ ) +} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/components/guardrails/guardrailsProviderView.tsx b/ui/app/_fallbacks/enterprise/components/guardrails/guardrailsProviderView.tsx new file mode 100644 index 000000000..b151ca467 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/guardrails/guardrailsProviderView.tsx @@ -0,0 +1,17 @@ +import { Construction } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + + +export default function guardrailsProviderView() { + return ( +
+ } + title="Unlock guardrails for better security" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/guardrails" + /> +
+ ) +} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/components/login/loginView.tsx b/ui/app/_fallbacks/enterprise/components/login/loginView.tsx new file mode 100644 index 000000000..90e583d33 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/login/loginView.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getErrorMessage, setAuthToken, useIsAuthEnabledQuery, useLoginMutation } from "@/lib/store/apis"; +import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react"; +import { useTheme } from "next-themes"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +const externalLinks = [ + { + title: "Discord Server", + url: "https://getmax.im/bifrost-discord", + icon: DiscordLogoIcon, + }, + { + title: "GitHub Repository", + url: "https://github.com/maximhq/bifrost", + icon: GithubLogoIcon, + }, + { + title: "Full Documentation", + url: "https://docs.getbifrost.ai", + icon: BooksIcon, + strokeWidth: 1, + }, +]; + +export default function LoginView() { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isCheckingAuth, setIsCheckingAuth] = useState(true); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const { data: isAuthEnabledData, isLoading: isLoadingIsAuthEnabled, error: isAuthEnabledError } = useIsAuthEnabledQuery(); + const isAuthEnabled = isAuthEnabledData?.is_auth_enabled || false; + const hasValidToken = isAuthEnabledData?.has_valid_token || false; + const [login, { isLoading: isLoggingIn }] = useLoginMutation(); + + useEffect(() => { + setMounted(true); + }, []); + + // Check auth status on component mount + useEffect(() => { + if (isLoadingIsAuthEnabled) { + return; + } + if (isAuthEnabledError) { + setErrorMessage("Unable to verify authentication status. Please retry."); + return; + } + if (!isAuthEnabled || hasValidToken) { + router.push("/workspace"); + return; + } + // Auth is enabled but user is not logged in, show login form + setIsCheckingAuth(false); + }, [hasValidToken, isAuthEnabled, isAuthEnabledError, isLoadingIsAuthEnabled, router]); + + const handleSubmit = async (e: React.FormEvent) => { + setIsLoading(true); + e.preventDefault(); + setErrorMessage(""); + try { + const result = await login({ username, password }).unwrap(); + // Store token immediately before navigation + if (result.token) { + setAuthToken(result.token); + // Small delay to ensure token is persisted + await new Promise((resolve) => setTimeout(resolve, 100)); + // Redirect to workspace on successful login + router.push("/workspace"); + } else { + setErrorMessage("Login successful but no token received"); + } + } catch (error) { + const message = getErrorMessage(error); + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + // Use light logo for SSR to avoid hydration mismatch + const logoSrc = mounted && resolvedTheme === "dark" ? "/bifrost-logo-dark.png" : "/bifrost-logo.png"; + + // Show loading state while checking auth + if (isCheckingAuth || isLoadingIsAuthEnabled) { + return ( +
+
+
+
+ Bifrost +
+
+
Checking authentication...
+
+
+
+
+ ); + } + + return ( +
+
+
+ {/* Logo */} +
+ Bifrost +
+ +
+

Welcome back

+

Sign in to your account to continue

+
+ +
+ {errorMessage &&
{errorMessage}
} + +
+ + setUsername(e.target.value)} + required + className="text-sm" + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="text-sm" + autoComplete="current-password" + /> +
+ + +
+ + {/* Social Links */} +
+ {externalLinks.map((item, index) => ( + + + + ))} +
+
+
+
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/rbac/rbacView.tsx b/ui/app/_fallbacks/enterprise/components/rbac/rbacView.tsx new file mode 100644 index 000000000..5b7f7d36d --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/rbac/rbacView.tsx @@ -0,0 +1,17 @@ +import { UserRoundCheck } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + + +export default function RBACView() { + return ( +
+ } + title="Unlock roles and permissions for better security" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance" + /> +
+ ) +} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/components/scim/scimView.tsx b/ui/app/_fallbacks/enterprise/components/scim/scimView.tsx new file mode 100644 index 000000000..5be3dfd2c --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/scim/scimView.tsx @@ -0,0 +1,16 @@ +import { BookUser } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function SCIMView() { + return ( +
+ } + title="Unlock SCIM based access management for user provisioning" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/governance" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/user-groups/usersView.tsx b/ui/app/_fallbacks/enterprise/components/user-groups/usersView.tsx new file mode 100644 index 000000000..678e96437 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/user-groups/usersView.tsx @@ -0,0 +1,17 @@ +import { Users } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + + +export default function UsersView() { + return ( +
+ } + title="Unlock users & user management" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/users" + /> +
+ ) +} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/components/views/contactUsView.tsx b/ui/app/_fallbacks/enterprise/components/views/contactUsView.tsx new file mode 100644 index 000000000..587ccf763 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/views/contactUsView.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowUpRight } from "lucide-react"; + +interface Props { + className?: string; + icon: React.ReactNode; + title: string; + description: string; + readmeLink: string; +} + +export default function ContactUsView({ icon, title, description, className, readmeLink }: Props) { + return ( +
+
{icon}
+
+

{title}

+
{description}
+
+ + +
+
+
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx new file mode 100644 index 000000000..5a33f3d60 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { createContext, useContext } from "react"; + +// RBAC Resource Names (must match backend definitions) +export enum RbacResource { + GuardrailsConfig = "GuardrailsConfig", + UserProvisioning = "UserProvisioning", + Cluster = "Cluster", + Settings = "Settings", + Users = "Users", + Logs = "Logs", + Observability = "Observability", + VirtualKeys = "VirtualKeys", + ModelProvider = "ModelProvider", + Plugins = "Plugins", + MCPGateway = "MCPGateway", + AdaptiveRouter = "AdaptiveRouter", + AuditLogs = "AuditLogs", +} + +// RBAC Operation Names (must match backend definitions) +export enum RbacOperation { + Read = "Read", + View = "View", + Create = "Create", + Update = "Update", + Delete = "Delete", +} + +interface RbacContextType { + isAllowed: (resource: RbacResource, operation: RbacOperation) => boolean; + permissions: Record>; + isLoading: boolean; + refetch: () => void; +} + +const RbacContext = createContext(null); + +// Dummy provider that allows all permissions +export function RbacProvider({ children }: { children: React.ReactNode }) { + return ( + true, // Always allow in OSS + permissions: {}, + isLoading: false, + refetch: () => {}, + }} + > + {children} + + ); +} + +// Hook that always returns true (no restrictions in OSS) +export function useRbac(resource: RbacResource, operation: RbacOperation): boolean { + return true; +} + +// Hook to access full RBAC context +export function useRbacContext() { + const context = useContext(RbacContext); + if (!context) { + // Return dummy values if used outside provider + return { + isAllowed: () => true, + permissions: {}, + isLoading: false, + refetch: () => {}, + }; + } + return context; +} diff --git a/ui/app/_fallbacks/enterprise/lib/index.ts b/ui/app/_fallbacks/enterprise/lib/index.ts new file mode 100644 index 000000000..8c183c78d --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/index.ts @@ -0,0 +1,24 @@ +// Fallback exports for non-enterprise builds +export * from "./store"; + +// Re-export OAuth token management utilities for convenience (fallback no-ops) +export { + REFRESH_TOKEN_ENDPOINT, clearOAuthStorage, + clearUserInfo, + getAccessToken, + getRefreshState, + getRefreshToken, + getTokenExpiry, + getUserInfo, + isTokenExpired, setOAuthTokens, + setRefreshState, + setUserInfo, + type UserInfo +} from "./store/utils/tokenManager"; + +// Re-export base query (fallback passthrough) +export { createBaseQueryWithRefresh } from "./store/utils/baseQueryWithRefresh"; + +// Re-export RBAC context (dummy implementation for OSS) +export * from "./contexts/rbacContext"; + diff --git a/ui/app/_fallbacks/enterprise/lib/store/apis/index.ts b/ui/app/_fallbacks/enterprise/lib/store/apis/index.ts new file mode 100644 index 000000000..8062d0505 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/apis/index.ts @@ -0,0 +1,11 @@ +// Placeholder for enterprise APIs +// Export empty objects when enterprise features are not available + +export const scimApi = null +export const guardrailsApi = null +export const clusterApi = null +export const rbacApi = null + +// Empty apis array when enterprise features are not available +export const apis = [] + diff --git a/ui/app/_fallbacks/enterprise/lib/store/index.ts b/ui/app/_fallbacks/enterprise/lib/store/index.ts new file mode 100644 index 000000000..d99394d1a --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/index.ts @@ -0,0 +1,21 @@ +// Fallback exports for non-enterprise builds +export * from "./apis"; +export * from "./slices"; + +// Export OAuth token management utilities (fallback no-ops) +export { + REFRESH_TOKEN_ENDPOINT, clearOAuthStorage, + clearUserInfo, + getAccessToken, + getRefreshState, + getRefreshToken, + getTokenExpiry, + getUserInfo, + isTokenExpired, setOAuthTokens, + setRefreshState, + setUserInfo, + type UserInfo +} from "./utils/tokenManager"; + +// Export base query (fallback passthrough) +export { createBaseQueryWithRefresh } from "./utils/baseQueryWithRefresh"; diff --git a/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts b/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts new file mode 100644 index 000000000..1796d8fee --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts @@ -0,0 +1,12 @@ +// Placeholder for enterprise reducers +// Export noop reducers when enterprise features are not available + +export const scimReducer = (state = {}) => state +export const userReducer = (state = {}) => state +export const guardrailReducer = (state = {}) => state + +// Empty reducers map when enterprise features are not available +export const reducers = {} + +// Empty enterprise state type when enterprise features are not available +export type EnterpriseState = {} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/lib/store/utils/baseQueryWithRefresh.ts b/ui/app/_fallbacks/enterprise/lib/store/utils/baseQueryWithRefresh.ts new file mode 100644 index 000000000..db0d1f60e --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/utils/baseQueryWithRefresh.ts @@ -0,0 +1,16 @@ +// Fallback base query for non-enterprise builds +// Simply passes through the base query without any refresh logic + +import type { BaseQueryFn } from '@reduxjs/toolkit/query/react' + +/** + * Fallback base query wrapper that does nothing + * Used when enterprise features are not available + */ +export function createBaseQueryWithRefresh ( + baseQuery: BaseQueryFn +): BaseQueryFn { + // Simply return the base query as-is (no refresh logic) + return baseQuery +} + diff --git a/ui/app/_fallbacks/enterprise/lib/store/utils/tokenManager.ts b/ui/app/_fallbacks/enterprise/lib/store/utils/tokenManager.ts new file mode 100644 index 000000000..32b5086eb --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/utils/tokenManager.ts @@ -0,0 +1,78 @@ +// Fallback OAuth Token Manager for non-enterprise builds +// These functions return null/no-op when enterprise features are not available + +export const getAccessToken = async (): Promise => Promise.resolve(null) + +export const getRefreshToken = async (): Promise => Promise.resolve(null) + +export const getTokenExpiry = (): number | null => null + +export const isTokenExpired = (): boolean => false + +export const setOAuthTokens = async (accessToken: string, expiresIn?: number | null) => { + // No-op in non-enterprise builds +} + +export const clearOAuthStorage = () => { + // No-op in non-enterprise builds +} + +export const getRefreshState = () => ({ + isRefreshing: false, + refreshPromise: null +}) + +export const setRefreshState = (refreshing: boolean, promise: Promise | null = null) => { + // No-op in non-enterprise builds +} + +export const REFRESH_TOKEN_ENDPOINT = '' + +// User info type definition (matching enterprise version) +export interface UserInfo { + name?: string + email?: string + picture?: string + preferred_username?: string + given_name?: string + family_name?: string +} + +// Fallback getUserInfo that returns null for non-enterprise builds +export const getUserInfo = (): UserInfo | null => null + +// Fallback setUserInfo - no-op +export const setUserInfo = (userInfo: UserInfo) => { + // No-op in non-enterprise builds +} + +// Fallback clearUserInfo - no-op +export const clearUserInfo = () => { + // No-op in non-enterprise builds +} + +// Fallback secure storage functions - no-op +export const setSecureItem = async (key: string, value: string): Promise => { + // No-op in non-enterprise builds +} + +export const getSecureItem = async (key: string): Promise => Promise.resolve(null) + +export const removeSecureItem = (key: string): void => { + // No-op in non-enterprise builds +} + +export const setSecureLocalItem = async (key: string, value: string): Promise => { + // No-op in non-enterprise builds +} + +export const getSecureLocalItem = async (key: string): Promise => Promise.resolve(null) + +export const removeSecureLocalItem = (key: string): void => { + // No-op in non-enterprise builds +} + +export const clearEncryptionKey = (): void => { + // No-op in non-enterprise builds +} + diff --git a/ui/app/clientLayout.tsx b/ui/app/clientLayout.tsx new file mode 100644 index 000000000..078dc48c4 --- /dev/null +++ b/ui/app/clientLayout.tsx @@ -0,0 +1,54 @@ +"use client"; + +import FullPageLoader from "@/components/fullPageLoader"; +import NotAvailableBanner from "@/components/notAvailableBanner"; +import ProgressProvider from "@/components/progressBar"; +import Sidebar from "@/components/sidebar"; +import { ThemeProvider } from "@/components/themeProvider"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { WebSocketProvider } from "@/hooks/useWebSocket"; +import { getErrorMessage, ReduxProvider, useGetCoreConfigQuery } from "@/lib/store"; +import { RbacProvider } from "@enterprise/lib/contexts/rbacContext"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { useEffect } from "react"; +import { toast, Toaster } from "sonner"; + +function AppContent({ children }: { children: React.ReactNode }) { + const { data: bifrostConfig, error } = useGetCoreConfigQuery({}); + + useEffect(() => { + if (error) { + toast.error(getErrorMessage(error)); + } + }, [error]); + + return ( + + + +
+
+ {bifrostConfig?.is_db_connected ? children : bifrostConfig ? : } +
+
+
+
+ ); +} + +export function ClientLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + + + ); +} diff --git a/ui/app/favicon.ico b/ui/app/favicon.ico new file mode 100644 index 000000000..856be557a Binary files /dev/null and b/ui/app/favicon.ico differ diff --git a/ui/app/globals.css b/ui/app/globals.css new file mode 100644 index 000000000..c95929e69 --- /dev/null +++ b/ui/app/globals.css @@ -0,0 +1,215 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --height-base: calc(100vh - 130px); + + /* Font size overrides - format: [size, { line-height: value }] */ + --text-xs: 0.75rem; + --text-xs--line-height: 1rem; + --text-sm: 0.825rem; + --text-sm--line-height: 1.25rem; + --text-base: 0.95rem; + --text-base--line-height: 1.5rem; + --text-lg: 1.125rem; + --text-lg--line-height: 1.75rem; + --text-xl: 1.25rem; + --text-xl--line-height: 1.75rem; + --text-2xl: 1.5rem; + --text-2xl--line-height: 2rem; + --text-3xl: 1.875rem; + --text-3xl--line-height: 2.25rem; + --text-4xl: 2.25rem; + --text-4xl--line-height: 2.5rem; + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --text-7xl: 4.5rem; + --text-7xl--line-height: 1; + --text-8xl: 6rem; + --text-8xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; +} + +:root { + --radius: 0.5rem; + --color-cream-100: oklch(0.98 0 0); + --background: #f4f4f5; + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.5081 0.1049 165.61); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: color-mix(in oklch, var(--color-cream-100) 20%, transparent); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --color-ink-900: oklch(0.141 0.005 285.823); + --background: color-mix(in oklch, var(--color-ink-900) 20%, transparent); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: color-mix(in oklch, var(--color-ink-900) 20%, transparent); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-none; + } + body { + @apply bg-background text-foreground; + } +} + +@utility custom-scrollbar { + overflow: auto !important; + scrollbar-width: thin; /* Firefox */ + scrollbar-color: rgba(228, 228, 231, 1) transparent; /* Firefox */ + + &::-webkit-scrollbar { + --custom-scrollbar-width: 8px; + --custom-scrollbar-height: 8px; + width: var(--custom-scrollbar-width, 8px); + height: var(--custom-scrollbar-height, 8px); + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + --tw-bg-opacity: 1 !important; + background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important; + border-radius: 8px; + opacity: 0; + visibility: hidden; + } + + &:hover::-webkit-scrollbar-thumb { + opacity: 1; + visibility: visible; + } + + &::-webkit-scrollbar-thumb:hover { + --tw-bg-opacity: 1 !important; + background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important; + } + + /* For older WebKit browsers */ + &::-webkit-scrollbar-thumb:horizontal { + background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important; + } + + &::-webkit-scrollbar-thumb:vertical { + background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important; + } + + &:hover::-webkit-scrollbar-thumb:horizontal { + background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important; + } + + &:hover::-webkit-scrollbar-thumb:vertical { + background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important; + } +} + +body { + overscroll-behavior: none; +} + +.query-builder-wrapper { + padding: 1rem; + padding-inline: 0.5rem; + +} diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx new file mode 100644 index 000000000..5c0b9718e --- /dev/null +++ b/ui/app/layout.tsx @@ -0,0 +1,30 @@ +import { Geist, Geist_Mono } from "next/font/google" +import "./globals.css" + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], + display: "swap", +}) + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], + display: "swap", +}) + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + ) +} diff --git a/ui/app/login/layout.tsx b/ui/app/login/layout.tsx new file mode 100644 index 000000000..001dd7546 --- /dev/null +++ b/ui/app/login/layout.tsx @@ -0,0 +1,15 @@ +import { ThemeProvider } from "@/components/themeProvider"; +import { ReduxProvider } from "@/lib/store/provider"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; + +export default function LoginLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
{children}
+
+
+
+ ); +} diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx new file mode 100644 index 000000000..a04f46434 --- /dev/null +++ b/ui/app/login/page.tsx @@ -0,0 +1,9 @@ +import LoginView from "@enterprise/components/login/loginView"; + +export default function LoginPage() { + return ( +
+ +
+ ); +} diff --git a/ui/app/not-found.tsx b/ui/app/not-found.tsx new file mode 100644 index 000000000..f2493c2d5 --- /dev/null +++ b/ui/app/not-found.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+
+

404

+

Page not found

+

The page you are looking for doesn’t exist or has been moved

+
+ + Go home + +
+
+
+ ); +} diff --git a/ui/app/page.tsx b/ui/app/page.tsx new file mode 100644 index 000000000..275a4da47 --- /dev/null +++ b/ui/app/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { redirect } from "next/navigation"; + +export default function Index() { + redirect("login"); +} diff --git a/ui/app/workspace/adaptive-routing/page.tsx b/ui/app/workspace/adaptive-routing/page.tsx new file mode 100644 index 000000000..6a6204b8f --- /dev/null +++ b/ui/app/workspace/adaptive-routing/page.tsx @@ -0,0 +1,9 @@ +import AdaptiveRoutingView from "@enterprise/components/adaptive-routing/adaptiveRoutingView"; + +export default function AdaptiveRoutingPage() { + return ( +
+ +
+ ); +} diff --git a/ui/app/workspace/alert-channels/page.tsx b/ui/app/workspace/alert-channels/page.tsx new file mode 100644 index 000000000..b7700ec98 --- /dev/null +++ b/ui/app/workspace/alert-channels/page.tsx @@ -0,0 +1,9 @@ +import AlertChannelsView from "@enterprise/components/alert-channels/alertChannelsView"; + +export default function AlertChannelsPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/ui/app/workspace/audit-logs/page.tsx b/ui/app/workspace/audit-logs/page.tsx new file mode 100644 index 000000000..72a83490b --- /dev/null +++ b/ui/app/workspace/audit-logs/page.tsx @@ -0,0 +1,7 @@ +import AuditLogsView from "@enterprise/components/audit-logs/auditLogsView"; + +export default function AuditLogsPage() { + return
+ +
; +} \ No newline at end of file diff --git a/ui/app/workspace/cluster/page.tsx b/ui/app/workspace/cluster/page.tsx new file mode 100644 index 000000000..3ef8fcd93 --- /dev/null +++ b/ui/app/workspace/cluster/page.tsx @@ -0,0 +1,9 @@ +import ClusterView from "@enterprise/components/cluster/clusterView"; + +export default async function ClusterPage() { + return ( +
+ +
+ ); +} diff --git a/ui/app/workspace/config/layout.tsx b/ui/app/workspace/config/layout.tsx new file mode 100644 index 000000000..12025cd28 --- /dev/null +++ b/ui/app/workspace/config/layout.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; + +export default function ConfigLayout({ children }: { children: React.ReactNode }) { + const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View); + if (!hasConfigAccess) { + return
You don't have permission to view config
; + } + return
{children}
; +} diff --git a/ui/app/workspace/config/page.tsx b/ui/app/workspace/config/page.tsx new file mode 100644 index 000000000..dba902e0e --- /dev/null +++ b/ui/app/workspace/config/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import FullPageLoader from "@/components/fullPageLoader"; +import { useGetCoreConfigQuery } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import APIKeysView from "@enterprise/components/api-keys/APIKeysView"; +import { Gauge, KeyRound, Landmark, Settings, Shield, Sliders, Zap } from "lucide-react"; +import { useQueryState } from "nuqs"; +import { useEffect } from "react"; +import ClientSettingsView from "./views/clientSettingsView"; +import FeatureTogglesView from "./views/featureTogglesView"; +import ObservabilityView from "./views/observabilityView"; +import PerformanceTuningView from "./views/performanceTuningView"; +import PricingConfigView from "./views/pricingConfigView"; +import SecurityView from "./views/securityView"; + +const tabs = [ + { + id: "client-settings", + label: "Client Settings", + icon: , + }, + { + id: "pricing-config", + label: "Pricing Config", + icon: , + }, + { + id: "feature-toggles", + label: "Feature Toggles", + icon: , + }, + { + id: "observability", + label: "Observability", + icon: , + }, + { + id: "security", + label: "Security", + icon: , + }, + { + id: "api-keys", + label: "API Keys", + icon: , + }, + { + id: "performance-tuning", + label: "Performance Tuning", + icon: , + }, +]; + +export default function ConfigPage() { + const [activeTab, setActiveTab] = useQueryState("tab"); + const { isLoading } = useGetCoreConfigQuery({ fromDB: true }); + + useEffect(() => { + if (!activeTab) { + setActiveTab(tabs[0].id); + } + }, [activeTab, setActiveTab]); + + if (isLoading) { + return ; + } + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ {activeTab === "client-settings" && } + {activeTab === "pricing-config" && } + {activeTab === "feature-toggles" && } + {activeTab === "observability" && } + {activeTab === "security" && } + {activeTab === "api-keys" && } + {activeTab === "performance-tuning" && } +
+
+ ); +} diff --git a/ui/app/workspace/config/views/clientSettingsView.tsx b/ui/app/workspace/config/views/clientSettingsView.tsx new file mode 100644 index 000000000..7d372ea1f --- /dev/null +++ b/ui/app/workspace/config/views/clientSettingsView.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { getErrorMessage, useGetCoreConfigQuery, useGetDroppedRequestsQuery, useUpdateCoreConfigMutation } from "@/lib/store"; +import { CoreConfig } from "@/lib/types/config"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +const defaultConfig: CoreConfig = { + drop_excess_requests: false, + initial_pool_size: 1000, + prometheus_labels: [], + enable_logging: true, + disable_content_logging: false, + enable_governance: true, + enforce_governance_header: false, + allow_direct_keys: false, + allowed_origins: [], + max_request_body_size_mb: 100, + enable_litellm_fallbacks: false, +}; + +export default function ClientSettingsView() { + const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update); + const [droppedRequests, setDroppedRequests] = useState(0); + const { data: droppedRequestsData } = useGetDroppedRequestsQuery(); + const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); + const config = bifrostConfig?.client_config; + const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation(); + const [localConfig, setLocalConfig] = useState(defaultConfig); + + useEffect(() => { + if (droppedRequestsData) { + setDroppedRequests(droppedRequestsData.dropped_requests); + } + }, [droppedRequestsData]); + + useEffect(() => { + if (config) { + setLocalConfig(config); + } + }, [config]); + + const hasChanges = useMemo(() => { + if (!config) return false; + return ( + localConfig.drop_excess_requests !== config.drop_excess_requests || + localConfig.enforce_governance_header !== config.enforce_governance_header || + localConfig.allow_direct_keys !== config.allow_direct_keys || + localConfig.enable_litellm_fallbacks !== config.enable_litellm_fallbacks + ); + }, [config, localConfig]); + + const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[]) => { + setLocalConfig((prev) => ({ ...prev, [field]: value })); + }, []); + + const handleSave = useCallback(async () => { + try { + await updateCoreConfig({ ...bifrostConfig!, client_config: localConfig }).unwrap(); + toast.success("Client settings updated successfully."); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }, [bifrostConfig, localConfig, updateCoreConfig]); + + return ( +
+
+
+

Client Settings

+

Configure client behavior and request handling.

+
+ +
+ +
+ {/* Drop Excess Requests */} +
+
+ +

+ If enabled, Bifrost will drop requests that exceed pool capacity.{" "} + {localConfig.drop_excess_requests && droppedRequests > 0 ? ( + + Have dropped {droppedRequests} requests since last restart. + + ) : ( + <> + )} +

+
+ handleConfigChange("drop_excess_requests", checked)} + /> +
+ + {/* Enforce Virtual Keys */} + {localConfig.enable_governance && ( +
+
+ +

+ Enforce the use of a virtual key for all requests. If enabled, requests without the x-bf-vk header will be rejected. +

+
+ handleConfigChange("enforce_governance_header", checked)} + /> +
+ )} + + {/* Allow Direct API Keys */} +
+
+ +

+ Allow API keys to be passed directly in request headers (Authorization or x-api-key). Bifrost will directly use + the key. +

+
+ handleConfigChange("allow_direct_keys", checked)} + /> +
+ + {/* Enable LiteLLM Fallbacks */} +
+
+ +

Enable litellm-specific fallbacks for text completion for Groq.

+
+ handleConfigChange("enable_litellm_fallbacks", checked)} + /> +
+
+
+ ); +} diff --git a/ui/app/workspace/config/views/featureTogglesView.tsx b/ui/app/workspace/config/views/featureTogglesView.tsx new file mode 100644 index 000000000..5ddad21a0 --- /dev/null +++ b/ui/app/workspace/config/views/featureTogglesView.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store"; +import { CoreConfig } from "@/lib/types/config"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import PluginsForm from "./pluginsForm"; + +const defaultConfig: CoreConfig = { + drop_excess_requests: false, + initial_pool_size: 1000, + prometheus_labels: [], + enable_logging: true, + disable_content_logging: false, + enable_governance: true, + enforce_governance_header: false, + allow_direct_keys: false, + allowed_origins: [], + max_request_body_size_mb: 100, + enable_litellm_fallbacks: false, +}; + +export default function FeatureTogglesView() { + const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update); + const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); + const config = bifrostConfig?.client_config; + const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation(); + const [localConfig, setLocalConfig] = useState(defaultConfig); + const [needsRestart, setNeedsRestart] = useState(false); + + useEffect(() => { + if (config) { + setLocalConfig(config); + } + }, [config]); + + const hasChanges = useMemo(() => { + if (!config) return false; + return ( + localConfig.enable_logging !== config.enable_logging || + localConfig.disable_content_logging !== config.disable_content_logging || + localConfig.enable_governance !== config.enable_governance + ); + }, [config, localConfig]); + + const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[]) => { + setLocalConfig((prev) => ({ ...prev, [field]: value })); + setNeedsRestart(true); + }, []); + + const handleSave = useCallback(async () => { + if (!bifrostConfig) { + toast.error("Configuration not loaded"); + return; + } + try { + await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap(); + toast.success("Feature toggles updated successfully."); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }, [bifrostConfig, localConfig, updateCoreConfig]); + + return ( +
+
+
+

Feature Toggles

+

Enable or disable major features.

+
+ +
+ +
+ {/* Enable Logs */} +
+
+
+ +

+ Enable logging of requests and responses to a SQL database. This can add 40-60mb of overhead to the system memory. + {!bifrostConfig?.is_logs_connected && ( + Requires logs store to be configured and enabled in config.json. + )} +

+
+ { + if (bifrostConfig?.is_logs_connected) { + handleConfigChange("enable_logging", checked); + } + }} + /> +
+ {needsRestart && } +
+ + {/* Disable Content Logging - Only show when logging is enabled */} + {localConfig.enable_logging && bifrostConfig?.is_logs_connected && ( +
+
+
+ +

+ When enabled, only usage metadata (latency, cost, token count, etc.) will be logged. Request/response content will not be stored. +

+
+ handleConfigChange("disable_content_logging", checked)} + /> +
+ {needsRestart && } +
+ )} + + {/* Enable Governance */} +
+
+
+ +

+ Enable governance on requests. You can configure budgets and rate limits in the Governance tab. +

+
+ handleConfigChange("enable_governance", checked)} + /> +
+ {needsRestart && } +
+ + {/* Plugins Form */} + +
+
+ ); +} + +const RestartWarning = () => { + return
Need to restart Bifrost to apply changes.
; +}; diff --git a/ui/app/workspace/config/views/observabilityView.tsx b/ui/app/workspace/config/views/observabilityView.tsx new file mode 100644 index 000000000..30251b359 --- /dev/null +++ b/ui/app/workspace/config/views/observabilityView.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store"; +import { CoreConfig } from "@/lib/types/config"; +import { parseArrayFromText } from "@/lib/utils/array"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { AlertTriangle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +const defaultConfig: CoreConfig = { + drop_excess_requests: false, + initial_pool_size: 1000, + prometheus_labels: [], + enable_logging: true, + enable_governance: true, + enforce_governance_header: false, + allow_direct_keys: false, + allowed_origins: [], + max_request_body_size_mb: 100, + enable_litellm_fallbacks: false, + disable_content_logging: false, +}; + +export default function ObservabilityView() { + const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update); + const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); + const config = bifrostConfig?.client_config; + const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation(); + const [localConfig, setLocalConfig] = useState(defaultConfig); + const [needsRestart, setNeedsRestart] = useState(false); + + const [localValues, setLocalValues] = useState<{ + prometheus_labels: string; + }>({ + prometheus_labels: "", + }); + + useEffect(() => { + if (bifrostConfig && config) { + setLocalConfig(config); + setLocalValues({ + prometheus_labels: config?.prometheus_labels?.join(", ") || "", + }); + } + }, [config, bifrostConfig]); + + const hasChanges = useMemo(() => { + if (!config) return false; + const localLabels = localConfig.prometheus_labels.slice().sort().join(","); + const serverLabels = config.prometheus_labels.slice().sort().join(","); + return localLabels !== serverLabels; + }, [config, localConfig]); + + const handlePrometheusLabelsChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, prometheus_labels: value })); + setLocalConfig((prev) => ({ ...prev, prometheus_labels: parseArrayFromText(value) })); + setNeedsRestart(true); + }, []); + + const handleSave = useCallback(async () => { + if (!bifrostConfig) { + toast.error("Could not save settings: configuration not loaded."); + return; + } + try { + await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap(); + toast.success("Observability settings updated successfully."); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }, [bifrostConfig, localConfig, updateCoreConfig]); + + return ( +
+
+
+

Observability Settings

+

Configure monitoring and observability features.

+
+ +
+ + + + + These settings require a Bifrost service restart to take effect. Current connections will continue with existing settings until + restart. + + + +
+ {/* Prometheus Labels */} +
+
+
+ +

Comma-separated list of custom labels to add to the Prometheus metrics.

+
+