feat: initial open-source release
Beeline - Open-source LLM observability and control platform Features: - Real-time agent monitoring dashboard - LLM metrics and analytics (TimescaleDB) - Cost tracking and budget controls - WebSocket event streaming - MCP (Model Context Protocol) server Apache 2.0 License
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Dependencies (rebuilt in container)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env*
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
.github/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# EditorConfig helps maintain consistent coding styles
|
||||||
|
# https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Default owners for everything in the repo
|
||||||
|
* @adenhq/maintainers
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
/honeycomb/ @adenhq/maintainers
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
/hive/ @adenhq/maintainers
|
||||||
|
|
||||||
|
# Infrastructure
|
||||||
|
/docker-compose*.yml @adenhq/maintainers
|
||||||
|
/.github/ @adenhq/maintainers
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
/docs/ @adenhq/maintainers
|
||||||
|
*.md @adenhq/maintainers
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report a bug to help us improve
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Describe the Bug
|
||||||
|
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
## To Reproduce
|
||||||
|
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS: [e.g., Ubuntu 22.04, macOS 14]
|
||||||
|
- Docker version: [e.g., 24.0.0]
|
||||||
|
- Node version: [e.g., 20.10.0]
|
||||||
|
- Browser (if applicable): [e.g., Chrome 120]
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Relevant parts of your `config.yaml` (remove any sensitive data):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# paste here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Relevant log output:
|
||||||
|
|
||||||
|
```
|
||||||
|
paste logs here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Add any other context about the problem here.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature or enhancement
|
||||||
|
title: '[Feature]: '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
A clear and concise description of what problem this feature would solve.
|
||||||
|
|
||||||
|
Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
A description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Add any other context, mockups, or screenshots about the feature request here.
|
||||||
|
|
||||||
|
## Implementation Ideas
|
||||||
|
|
||||||
|
If you have ideas about how this could be implemented, share them here.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## Description
|
||||||
|
|
||||||
|
Brief description of the changes in this PR.
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change that adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] Documentation update
|
||||||
|
- [ ] Refactoring (no functional changes)
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
Fixes #(issue number)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
- Change 1
|
||||||
|
- Change 2
|
||||||
|
- Change 3
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Describe the tests you ran to verify your changes:
|
||||||
|
|
||||||
|
- [ ] Unit tests pass (`npm run test`)
|
||||||
|
- [ ] Lint passes (`npm run lint`)
|
||||||
|
- [ ] Manual testing performed
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the project's style guidelines
|
||||||
|
- [ ] I have performed a self-review of my code
|
||||||
|
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||||
|
- [ ] I have made corresponding changes to the documentation
|
||||||
|
- [ ] My changes generate no new warnings
|
||||||
|
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||||
|
- [ ] New and existing unit tests pass locally with my changes
|
||||||
|
|
||||||
|
## Screenshots (if applicable)
|
||||||
|
|
||||||
|
Add screenshots to demonstrate UI changes.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build frontend image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./honeycomb
|
||||||
|
push: false
|
||||||
|
tags: honeycomb-frontend:test
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build backend image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./hive
|
||||||
|
push: false
|
||||||
|
tags: honeycomb-backend:test
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
# Extract version from tag
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ contains(github.ref, '-') }}
|
||||||
|
|
||||||
|
docker-publish:
|
||||||
|
name: Publish Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}/frontend
|
||||||
|
ghcr.io/${{ github.repository }}/backend
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Build and push frontend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./honeycomb
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ github.repository }}/frontend:${{ github.ref_name }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./hive
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ github.repository }}/backend:${{ github.ref_name }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment files (generated from config.yaml)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
honeycomb/.env
|
||||||
|
hive/.env
|
||||||
|
|
||||||
|
# User configuration (copied from .example)
|
||||||
|
config.yaml
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json.example
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.local
|
||||||
|
.cache/
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial project structure
|
||||||
|
- React frontend (honeycomb) with Vite and TypeScript
|
||||||
|
- Node.js backend (hive) with Express and TypeScript
|
||||||
|
- Docker Compose configuration for local development
|
||||||
|
- Configuration system via `config.yaml`
|
||||||
|
- GitHub Actions CI/CD workflows
|
||||||
|
- Comprehensive documentation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-01-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/adenhq/beeline/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/adenhq/beeline/releases/tag/v0.1.0
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
- Using welcoming and inclusive language
|
||||||
|
- Being respectful of differing viewpoints and experiences
|
||||||
|
- Gracefully accepting constructive criticism
|
||||||
|
- Focusing on what is best for the community
|
||||||
|
- Showing empathy towards other community members
|
||||||
|
|
||||||
|
Examples of unacceptable behavior:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery and unwelcome sexual attention
|
||||||
|
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information without explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at contact@adenhq.com.
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0.
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
# Contributing to Beeline
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Beeline! This document provides guidelines and information for contributors.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/beeline.git`
|
||||||
|
3. Create a feature branch: `git checkout -b feature/your-feature-name`
|
||||||
|
4. Make your changes
|
||||||
|
5. Run tests: `npm run test`
|
||||||
|
6. Commit your changes following our commit conventions
|
||||||
|
7. Push to your fork and submit a Pull Request
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Copy configuration
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
|
||||||
|
# Generate environment files
|
||||||
|
npm run setup
|
||||||
|
|
||||||
|
# Start development environment
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Convention
|
||||||
|
|
||||||
|
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
type(scope): description
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `docs`: Documentation changes
|
||||||
|
- `style`: Code style changes (formatting, etc.)
|
||||||
|
- `refactor`: Code refactoring
|
||||||
|
- `test`: Adding or updating tests
|
||||||
|
- `chore`: Maintenance tasks
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
feat(auth): add OAuth2 login support
|
||||||
|
fix(api): handle null response from external service
|
||||||
|
docs(readme): update installation instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. Update documentation if needed
|
||||||
|
2. Add tests for new functionality
|
||||||
|
3. Ensure all tests pass
|
||||||
|
4. Update the CHANGELOG.md if applicable
|
||||||
|
5. Request review from maintainers
|
||||||
|
|
||||||
|
### PR Title Format
|
||||||
|
|
||||||
|
Follow the same convention as commits:
|
||||||
|
```
|
||||||
|
feat(component): add new feature description
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `honeycomb/` - React frontend application
|
||||||
|
- `hive/` - Node.js backend API
|
||||||
|
- `docs/` - Documentation
|
||||||
|
- `scripts/` - Build and utility scripts
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Use TypeScript for all new code
|
||||||
|
- Follow existing code patterns
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
- Add comments for complex logic
|
||||||
|
- Keep functions focused and small
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Run tests for a specific package
|
||||||
|
npm run test --workspace=honeycomb
|
||||||
|
npm run test --workspace=hive
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Feel free to open an issue for questions or join our [Discord community](https://discord.com/invite/MXE49hrKDk).
|
||||||
|
|
||||||
|
Thank you for contributing!
|
||||||
+1198
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
Copyright 2024 Aden
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Beeline
|
||||||
|
|
||||||
|
Beeline Instrumentation for your AI agents
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img width="100%" alt="Beeline Banner" src="https://storage.googleapis.com/aden-prod-assets/website/title-card.png" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://github.com/adenhq/beeline/blob/main/LICENSE)
|
||||||
|
[](https://www.ycombinator.com/companies/aden)
|
||||||
|
[](https://hub.docker.com/u/adenhq)
|
||||||
|
[](https://discord.com/invite/MXE49hrKDk)
|
||||||
|
[](https://x.com/aden_hq)
|
||||||
|
[](https://www.linkedin.com/company/teamaden/)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Beeline provides advanced runtime control for your AI agents, enabling you to observe, intervene, and dynamically adjust agent behavior as it executes. By giving you real-time visibility and control, Beeline helps you build more reliable AI systems—catching and correcting issues during execution rather than reacting after failures occur.
|
||||||
|
|
||||||
|
Visit [adenhq.com](https://adenhq.com) for complete documentation, examples, and guides.
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- **[Documentation](https://docs.adenhq.com/)** - Complete guides and API reference
|
||||||
|
- **[Self-Hosting Guide](https://docs.adenhq.com/getting-started/quickstart)** - Deploy Beeline on your infrastructure
|
||||||
|
- **[Changelog](https://github.com/adenhq/beeline/releases)** - Latest updates and releases
|
||||||
|
<!-- - **[Roadmap](https://adenhq.com/roadmap)** - Upcoming features and plans -->
|
||||||
|
- **[Report Issues](https://github.com/adenhq/beeline/issues)** - Bug reports and feature requests
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) (v20.10+)
|
||||||
|
- [Docker Compose](https://docs.docker.com/compose/install/) (v2.0+)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/adenhq/beeline.git
|
||||||
|
cd beeline
|
||||||
|
|
||||||
|
# Copy and configure
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
|
||||||
|
# Run setup and start services
|
||||||
|
npm run setup
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access the application:**
|
||||||
|
|
||||||
|
- Dashboard: http://localhost:3000
|
||||||
|
- API: http://localhost:4000
|
||||||
|
- Health: http://localhost:4000/health
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Observe** - Real-time visibility into agent execution, decisions, and performance
|
||||||
|
- **Metrics & Analytics** - Track costs, latency, and token usage with TimescaleDB
|
||||||
|
- **Cost Control** - Set budgets and policies to manage LLM spending
|
||||||
|
- **Real-time Events** - WebSocket streaming for live agent monitoring
|
||||||
|
- **Self-Hostable** - Deploy on your own infrastructure with full control
|
||||||
|
- **Production-Ready** - Built for scale and reliability
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
beeline/
|
||||||
|
├── honeycomb/ # Frontend (React + TypeScript + Vite)
|
||||||
|
├── hive/ # Backend (Node.js + TypeScript + Express)
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── scripts/ # Build and utility scripts
|
||||||
|
├── config.yaml.example # Configuration template
|
||||||
|
└── docker-compose.yml # Container orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development with Hot Reload
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy development overrides
|
||||||
|
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||||
|
|
||||||
|
# Start with hot reload enabled
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Without Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generate environment files
|
||||||
|
npm run generate:env
|
||||||
|
|
||||||
|
# Start frontend (in honeycomb/)
|
||||||
|
cd honeycomb && npm run dev
|
||||||
|
|
||||||
|
# Start backend (in hive/)
|
||||||
|
cd hive && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[Developer Guide](DEVELOPER.md)** - Comprehensive guide for developers
|
||||||
|
- [Getting Started](docs/getting-started.md) - Quick setup instructions
|
||||||
|
- [Configuration Guide](docs/configuration.md) - All configuration options
|
||||||
|
- [Architecture Overview](docs/architecture.md) - System design and structure
|
||||||
|
|
||||||
|
## Community & Support
|
||||||
|
|
||||||
|
We use [Discord](https://discord.com/invite/MXE49hrKDk) for support, feature requests, and community discussions.
|
||||||
|
|
||||||
|
- Discord - [Join our community](https://discord.com/invite/MXE49hrKDk)
|
||||||
|
- Twitter/X - [@adenhq](https://x.com/aden_hq)
|
||||||
|
- LinkedIn - [Company Page](https://www.linkedin.com/company/teamaden/)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## Join Our Team
|
||||||
|
|
||||||
|
**We're hiring!** Join us in engineering, research, and go-to-market roles.
|
||||||
|
|
||||||
|
[View Open Positions](https://jobs.adenhq.com/a8cec478-cdbc-473c-bbd4-f4b7027ec193/applicant)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
For security concerns, please see [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Made with care by the <a href="https://adenhq.com">Aden</a> team
|
||||||
|
</p>
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 0.x.x | :white_check_mark: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
|
||||||
|
|
||||||
|
### How to Report
|
||||||
|
|
||||||
|
**Please do NOT report security vulnerabilities through public GitHub issues.**
|
||||||
|
|
||||||
|
Instead, please send an email to contact@adenhq.com with:
|
||||||
|
|
||||||
|
1. A description of the vulnerability
|
||||||
|
2. Steps to reproduce the issue
|
||||||
|
3. Potential impact of the vulnerability
|
||||||
|
4. Any possible mitigations you've identified
|
||||||
|
|
||||||
|
### What to Expect
|
||||||
|
|
||||||
|
- **Acknowledgment**: We will acknowledge receipt of your report within 48 hours
|
||||||
|
- **Communication**: We will keep you informed of our progress
|
||||||
|
- **Resolution**: We aim to resolve critical vulnerabilities within 7 days
|
||||||
|
- **Credit**: We will credit you in our security advisories (unless you prefer to remain anonymous)
|
||||||
|
|
||||||
|
### Safe Harbor
|
||||||
|
|
||||||
|
We consider security research conducted in accordance with this policy to be:
|
||||||
|
|
||||||
|
- Authorized concerning any applicable anti-hacking laws
|
||||||
|
- Authorized concerning any relevant anti-circumvention laws
|
||||||
|
- Exempt from restrictions in our Terms of Service that would interfere with conducting security research
|
||||||
|
|
||||||
|
## Security Best Practices for Users
|
||||||
|
|
||||||
|
1. **Keep Updated**: Always run the latest version
|
||||||
|
2. **Secure Configuration**: Review `config.yaml` settings, especially in production
|
||||||
|
3. **Environment Variables**: Never commit `.env` files or `config.yaml` with secrets
|
||||||
|
4. **Network Security**: Use HTTPS in production, configure firewalls appropriately
|
||||||
|
5. **Database Security**: Use strong passwords, limit network access
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- Environment-based configuration (no hardcoded secrets)
|
||||||
|
- Input validation on API endpoints
|
||||||
|
- Secure session handling
|
||||||
|
- CORS configuration
|
||||||
|
- Rate limiting (configurable)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Beeline Configuration
|
||||||
|
# ======================
|
||||||
|
# Copy this file to config.yaml and customize for your environment.
|
||||||
|
# Run `npm run setup` to generate .env files from this configuration.
|
||||||
|
#
|
||||||
|
# For detailed documentation, see: docs/configuration.md
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Application Settings
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
app:
|
||||||
|
# Application name (displayed in UI and logs)
|
||||||
|
name: Beeline
|
||||||
|
|
||||||
|
# Environment: development, production, or test
|
||||||
|
environment: development
|
||||||
|
|
||||||
|
# Log level: debug, info, warn, error
|
||||||
|
log_level: info
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
server:
|
||||||
|
# Frontend settings
|
||||||
|
frontend:
|
||||||
|
# Port for the frontend application
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
# Backend (Hive) settings
|
||||||
|
backend:
|
||||||
|
# Port for the backend API
|
||||||
|
port: 4000
|
||||||
|
|
||||||
|
# Host to bind to (0.0.0.0 for all interfaces)
|
||||||
|
host: 0.0.0.0
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# TimescaleDB Configuration (Time-series metrics storage)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
timescaledb:
|
||||||
|
# Connection URL for TimescaleDB
|
||||||
|
# Format: postgresql://user:password@host:port/database
|
||||||
|
url: postgresql://postgres:postgres@localhost:5432/aden_tsdb
|
||||||
|
|
||||||
|
# External port mapping (for docker-compose)
|
||||||
|
port: 5432
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# MongoDB Configuration (Policies, pricing, control config)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
mongodb:
|
||||||
|
# Connection URL for MongoDB
|
||||||
|
url: mongodb://localhost:27017
|
||||||
|
|
||||||
|
# Database name for main data
|
||||||
|
database: aden
|
||||||
|
|
||||||
|
# Database name for ERP data
|
||||||
|
erp_database: erp
|
||||||
|
|
||||||
|
# External port mapping (for docker-compose)
|
||||||
|
port: 27017
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Redis Configuration (Caching and Socket.IO)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
redis:
|
||||||
|
# Connection URL for Redis
|
||||||
|
url: redis://localhost:6379
|
||||||
|
|
||||||
|
# External port mapping (for docker-compose)
|
||||||
|
port: 6379
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Authentication & Security
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
auth:
|
||||||
|
# JWT secret key - CHANGE THIS IN PRODUCTION!
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
jwt_secret: change-this-to-a-secure-random-string-min-32-chars
|
||||||
|
|
||||||
|
# JWT token expiration (e.g., 1h, 7d, 30d)
|
||||||
|
jwt_expires_in: 7d
|
||||||
|
|
||||||
|
# Passphrase for additional encryption - CHANGE THIS IN PRODUCTION!
|
||||||
|
passphrase: change-this-to-a-secure-passphrase
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# NPM Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
npm:
|
||||||
|
# NPM token for private package access (if needed)
|
||||||
|
token: ""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# CORS Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
cors:
|
||||||
|
# Allowed origin for CORS requests
|
||||||
|
# In production, set this to your frontend URL
|
||||||
|
origin: http://localhost:3000
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Feature Flags
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
features:
|
||||||
|
# Enable user registration
|
||||||
|
registration: true
|
||||||
|
|
||||||
|
# Enable API rate limiting
|
||||||
|
rate_limiting: false
|
||||||
|
|
||||||
|
# Enable request logging
|
||||||
|
request_logging: true
|
||||||
|
|
||||||
|
# Enable MCP (Model Context Protocol) server
|
||||||
|
mcp_server: true
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Development overrides
|
||||||
|
# Copy this file to docker-compose.override.yml for local development
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cp docker-compose.override.yml.example docker-compose.override.yml
|
||||||
|
# docker compose up
|
||||||
|
#
|
||||||
|
# This enables:
|
||||||
|
# - Hot reload for both frontend and backend
|
||||||
|
# - Source code mounted as volumes
|
||||||
|
# - Debug ports exposed
|
||||||
|
# - Development environment settings
|
||||||
|
|
||||||
|
services:
|
||||||
|
honeycomb:
|
||||||
|
build:
|
||||||
|
context: ./honeycomb
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
volumes:
|
||||||
|
- ./honeycomb/src:/app/src:ro
|
||||||
|
- ./honeycomb/public:/app/public:ro
|
||||||
|
- ./honeycomb/index.html:/app/index.html:ro
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:4000
|
||||||
|
|
||||||
|
hive:
|
||||||
|
build:
|
||||||
|
context: ./hive
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
volumes:
|
||||||
|
- ./hive/src:/app/src:ro
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- LOG_LEVEL=debug
|
||||||
|
# Uncomment to enable Node.js debugging
|
||||||
|
# ports:
|
||||||
|
# - "9229:9229"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
services:
|
||||||
|
# Frontend - React application
|
||||||
|
honeycomb:
|
||||||
|
build:
|
||||||
|
context: ./honeycomb
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:4000}
|
||||||
|
container_name: honeycomb-frontend
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
hive:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- honeycomb-network
|
||||||
|
|
||||||
|
# Backend - Hive API (LLM observability & control plane)
|
||||||
|
hive:
|
||||||
|
build:
|
||||||
|
context: ./hive
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
|
container_name: honeycomb-backend
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-4000}:4000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
|
- PORT=4000
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
# PostgreSQL (TimescaleDB)
|
||||||
|
- TSDB_PG_URL=postgresql://postgres:postgres@timescaledb:5432/aden_tsdb
|
||||||
|
# MongoDB
|
||||||
|
- MONGODB_URL=mongodb://mongodb:27017
|
||||||
|
- MONGODB_DBNAME=${MONGODB_DBNAME:-aden}
|
||||||
|
- MONGODB_ERP_DBNAME=${MONGODB_ERP_DBNAME:-erp}
|
||||||
|
# Redis
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
# Authentication
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production-use-min-32-chars}
|
||||||
|
- PASSPHRASE=${PASSPHRASE:-change-me-in-production}
|
||||||
|
# Hive backend URL for SDK quickstart documents
|
||||||
|
- HIVE_HOST=${HIVE_HOST:-http://localhost:4000}
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- honeycomb-network
|
||||||
|
|
||||||
|
# TimescaleDB - Time series database for LLM metrics
|
||||||
|
timescaledb:
|
||||||
|
image: timescale/timescaledb:latest-pg16
|
||||||
|
container_name: honeycomb-timescaledb
|
||||||
|
ports:
|
||||||
|
- "${TSDB_PORT:-5432}:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=aden_tsdb
|
||||||
|
volumes:
|
||||||
|
- timescaledb_data:/var/lib/postgresql/data
|
||||||
|
# Auto-run schema files on first startup (alphabetical order)
|
||||||
|
- ./hive/src/services/tsdb/00-init-timescaledb.sql:/docker-entrypoint-initdb.d/00-init-timescaledb.sql:ro
|
||||||
|
- ./hive/src/services/tsdb/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||||
|
- ./hive/src/services/tsdb/users_schema.sql:/docker-entrypoint-initdb.d/02-users.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d aden_tsdb"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- honeycomb-network
|
||||||
|
|
||||||
|
# MongoDB - Policies, pricing, and control configuration
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: honeycomb-mongodb
|
||||||
|
ports:
|
||||||
|
- "${MONGODB_PORT:-27017}:27017"
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- honeycomb-network
|
||||||
|
|
||||||
|
# Redis - Caching and Socket.IO adapter
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: honeycomb-redis
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- honeycomb-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
honeycomb-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
timescaledb_data:
|
||||||
|
mongodb_data:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
This document describes the high-level architecture of Beeline.
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client │
|
||||||
|
│ (Web Browser) │
|
||||||
|
└─────────────────────────┬───────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Docker Network │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
||||||
|
│ │ honeycomb │ │ hive │ │
|
||||||
|
│ │ (Frontend) │ ───▶ │ (Backend) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ React + Vite │ │ Express + TypeScript │ │
|
||||||
|
│ │ Port: 3000 │ │ Port: 4000 │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ ┌───────────────┐ │ │ ┌─────────────────┐ │ │
|
||||||
|
│ │ │ Nginx │ │ │ │ Routes │ │ │
|
||||||
|
│ │ │ (production) │ │ │ │ /api, /health │ │ │
|
||||||
|
│ │ └───────────────┘ │ │ └────────┬────────┘ │ │
|
||||||
|
│ └─────────────────────┘ │ │ │ │
|
||||||
|
│ │ ▼ │ │
|
||||||
|
│ │ ┌─────────────────┐ │ │
|
||||||
|
│ │ │ Controllers │ │ │
|
||||||
|
│ │ └────────┬────────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ▼ │ │
|
||||||
|
│ │ ┌─────────────────┐ │ │
|
||||||
|
│ │ │ Services │ │ │
|
||||||
|
│ │ └────────┬────────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └───────────┼─────────────┘ │
|
||||||
|
└───────────────────────────────────────────┼────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Database │
|
||||||
|
│ (PostgreSQL/etc) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Frontend (honeycomb/)
|
||||||
|
|
||||||
|
The frontend is a single-page application built with:
|
||||||
|
|
||||||
|
- **React 18** - UI library
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Vite** - Build tool and dev server
|
||||||
|
- **React Router** - Client-side routing
|
||||||
|
|
||||||
|
**Key Directories:**
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `src/components/` | Reusable UI components |
|
||||||
|
| `src/pages/` | Page-level components (routes) |
|
||||||
|
| `src/hooks/` | Custom React hooks |
|
||||||
|
| `src/services/` | API client and external services |
|
||||||
|
| `src/types/` | TypeScript type definitions |
|
||||||
|
| `src/utils/` | Utility functions |
|
||||||
|
| `src/styles/` | Global styles and CSS |
|
||||||
|
|
||||||
|
**Production Build:**
|
||||||
|
- Vite builds static assets
|
||||||
|
- Nginx serves the built files
|
||||||
|
- API requests proxied to backend
|
||||||
|
|
||||||
|
### Backend (hive/)
|
||||||
|
|
||||||
|
The backend is a RESTful API built with:
|
||||||
|
|
||||||
|
- **Express** - Web framework
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Zod** - Runtime validation
|
||||||
|
- **Helmet** - Security headers
|
||||||
|
|
||||||
|
**Key Directories:**
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `src/routes/` | API route definitions |
|
||||||
|
| `src/controllers/` | Request handlers |
|
||||||
|
| `src/services/` | Business logic |
|
||||||
|
| `src/middleware/` | Express middleware |
|
||||||
|
| `src/models/` | Data models |
|
||||||
|
| `src/types/` | TypeScript types |
|
||||||
|
| `src/utils/` | Utility functions |
|
||||||
|
| `src/config/` | Configuration loading |
|
||||||
|
|
||||||
|
**API Structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health # Health check endpoints
|
||||||
|
GET /health/ready # Readiness probe
|
||||||
|
GET /health/live # Liveness probe
|
||||||
|
|
||||||
|
GET /api # API info
|
||||||
|
GET /api/users # Example resource
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
1. **Client** makes HTTP request
|
||||||
|
2. **Nginx** (production) or **Vite** (dev) receives request
|
||||||
|
3. Static assets served directly; API requests proxied
|
||||||
|
4. **Express** receives API request
|
||||||
|
5. **Middleware** processes (auth, logging, validation)
|
||||||
|
6. **Router** matches route to controller
|
||||||
|
7. **Controller** handles request, calls services
|
||||||
|
8. **Service** executes business logic
|
||||||
|
9. **Response** returned to client
|
||||||
|
|
||||||
|
## Configuration System
|
||||||
|
|
||||||
|
```
|
||||||
|
config.yaml
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
generate-env.ts ──────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
.env (root) honeycomb/.env
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
docker-compose.yml Vite (frontend)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
hive/.env
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Express (backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Architecture
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
```
|
||||||
|
docker-compose.yml
|
||||||
|
├── honeycomb (frontend)
|
||||||
|
│ └── Dockerfile (multi-stage: build → nginx)
|
||||||
|
└── hive (backend)
|
||||||
|
└── Dockerfile (multi-stage: build → node)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
```
|
||||||
|
docker-compose.yml + docker-compose.override.yml
|
||||||
|
├── honeycomb (frontend)
|
||||||
|
│ └── Dockerfile.dev (vite dev server)
|
||||||
|
└── hive (backend)
|
||||||
|
└── Dockerfile.dev (tsx watch)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scaling Considerations
|
||||||
|
|
||||||
|
### Horizontal Scaling
|
||||||
|
|
||||||
|
Both frontend and backend are stateless and can be scaled horizontally:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
hive:
|
||||||
|
deploy:
|
||||||
|
replicas: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- Use connection pooling
|
||||||
|
- Consider read replicas for heavy read loads
|
||||||
|
- Implement caching layer if needed
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
Options for caching:
|
||||||
|
- Redis for session/cache storage
|
||||||
|
- CDN for static assets
|
||||||
|
- HTTP caching headers
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Served over HTTPS (configure in nginx/reverse proxy)
|
||||||
|
- CSP headers via nginx
|
||||||
|
- No sensitive data in client code
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Helmet.js for security headers
|
||||||
|
- CORS configured for specific origins
|
||||||
|
- Input validation with Zod
|
||||||
|
- JWT for authentication
|
||||||
|
- Rate limiting (configurable)
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
- `/health` - Overall health
|
||||||
|
- `/health/ready` - Ready to accept traffic
|
||||||
|
- `/health/live` - Process is alive
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
- Structured JSON logs in production
|
||||||
|
- Configurable log levels
|
||||||
|
- Request logging via Morgan
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. Edit code in `honeycomb/` or `hive/`
|
||||||
|
2. Hot reload updates automatically
|
||||||
|
3. Run tests: `npm run test`
|
||||||
|
4. Lint: `npm run lint`
|
||||||
|
5. Build: `npm run build`
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Configuration Guide
|
||||||
|
|
||||||
|
Beeline uses a centralized configuration system based on a single `config.yaml` file. This makes it easy to configure the entire application from one place.
|
||||||
|
|
||||||
|
## Configuration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
config.yaml --> generate-env.ts --> .env files
|
||||||
|
├── .env (root, for Docker)
|
||||||
|
├── honeycomb/.env (frontend)
|
||||||
|
└── hive/.env (backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Copy the example configuration:
|
||||||
|
```bash
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `config.yaml` with your settings
|
||||||
|
|
||||||
|
3. Generate environment files:
|
||||||
|
```bash
|
||||||
|
npm run generate:env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Application Settings
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
# Application name - displayed in UI and logs
|
||||||
|
name: Beeline
|
||||||
|
|
||||||
|
# Environment mode
|
||||||
|
# - development: enables debug features, verbose logging
|
||||||
|
# - production: optimized for performance, minimal logging
|
||||||
|
# - test: for running tests
|
||||||
|
environment: development
|
||||||
|
|
||||||
|
# Log level: debug, info, warn, error
|
||||||
|
log_level: info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
frontend:
|
||||||
|
# Port for the React frontend
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
backend:
|
||||||
|
# Port for the Node.js API
|
||||||
|
port: 4000
|
||||||
|
|
||||||
|
# Host to bind (0.0.0.0 = all interfaces)
|
||||||
|
host: 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
# PostgreSQL connection URL
|
||||||
|
url: postgresql://user:password@localhost:5432/beeline
|
||||||
|
|
||||||
|
# For SQLite (local development)
|
||||||
|
# url: sqlite:./data/beeline.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Connection URL Format:**
|
||||||
|
```
|
||||||
|
postgresql://[user]:[password]@[host]:[port]/[database]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
# JWT secret key for signing tokens
|
||||||
|
# IMPORTANT: Change this in production!
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
jwt_secret: your-secret-key
|
||||||
|
|
||||||
|
# Token expiration time
|
||||||
|
# Examples: 1h, 7d, 30d
|
||||||
|
jwt_expires_in: 7d
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cors:
|
||||||
|
# Allowed origin for cross-origin requests
|
||||||
|
# Set to your frontend URL in production
|
||||||
|
origin: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Flags
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
features:
|
||||||
|
# Enable/disable user registration
|
||||||
|
registration: true
|
||||||
|
|
||||||
|
# Enable API rate limiting
|
||||||
|
rate_limiting: false
|
||||||
|
|
||||||
|
# Enable request logging
|
||||||
|
request_logging: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment-Specific Configuration
|
||||||
|
|
||||||
|
You can create environment-specific config files:
|
||||||
|
|
||||||
|
- `config.yaml` - Your main configuration (git-ignored)
|
||||||
|
- `config.yaml.example` - Template with safe defaults (committed)
|
||||||
|
|
||||||
|
For different environments, you might want separate files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
# Edit for development settings
|
||||||
|
|
||||||
|
# Production
|
||||||
|
cp config.yaml.example config.production.yaml
|
||||||
|
# Edit for production settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose Integration
|
||||||
|
|
||||||
|
The root `.env` file is used by Docker Compose. Key variables:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `FRONTEND_PORT` | Frontend container port | 3000 |
|
||||||
|
| `BACKEND_PORT` | Backend container port | 4000 |
|
||||||
|
| `NODE_ENV` | Node environment | production |
|
||||||
|
| `DATABASE_URL` | Database connection | - |
|
||||||
|
| `JWT_SECRET` | Auth secret key | - |
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Never commit `config.yaml`** - It may contain secrets
|
||||||
|
2. **Use strong JWT secrets** - Generate with `openssl rand -base64 32`
|
||||||
|
3. **Restrict CORS in production** - Set to your exact frontend URL
|
||||||
|
4. **Use environment variables for CI/CD** - Override config in deployments
|
||||||
|
|
||||||
|
## Updating Configuration
|
||||||
|
|
||||||
|
After changing `config.yaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate .env files
|
||||||
|
npm run generate:env
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker compose restart
|
||||||
|
# or
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Changes Not Taking Effect
|
||||||
|
|
||||||
|
1. Ensure you ran `npm run generate:env`
|
||||||
|
2. Restart the services
|
||||||
|
3. Check if the correct `.env` file is being loaded
|
||||||
|
|
||||||
|
### Configuration Validation Errors
|
||||||
|
|
||||||
|
The backend validates configuration on startup. Check logs for specific errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs hive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing Environment Variables
|
||||||
|
|
||||||
|
If a required variable is missing, add it to:
|
||||||
|
1. `config.yaml.example` (with safe default)
|
||||||
|
2. `config.yaml` (with your value)
|
||||||
|
3. `scripts/generate-env.ts` (to generate it)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
This guide will help you get Beeline running on your local machine.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Docker** (v20.10+) and **Docker Compose** (v2.0+) - for containerized deployment
|
||||||
|
- **Node.js** (v20+) - for local development without Docker
|
||||||
|
|
||||||
|
## Quick Start with Docker
|
||||||
|
|
||||||
|
The fastest way to get started is using Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone https://github.com/adenhq/beeline.git
|
||||||
|
cd beeline
|
||||||
|
|
||||||
|
# 2. Copy and configure
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
|
||||||
|
# 3. Run setup
|
||||||
|
npm run setup
|
||||||
|
|
||||||
|
# 4. Start services
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at:
|
||||||
|
- **Frontend**: http://localhost:3000
|
||||||
|
- **Backend API**: http://localhost:4000
|
||||||
|
- **Health Check**: http://localhost:4000/health
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
For local development with hot reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and configure (same as above)
|
||||||
|
git clone https://github.com/adenhq/beeline.git
|
||||||
|
cd beeline
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
|
||||||
|
# 2. Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 3. Generate environment files
|
||||||
|
npm run generate:env
|
||||||
|
|
||||||
|
# 4. Start frontend (terminal 1)
|
||||||
|
cd honeycomb
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 5. Start backend (terminal 2)
|
||||||
|
cd hive
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker for Development
|
||||||
|
|
||||||
|
You can also use Docker with hot reload enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy development overrides
|
||||||
|
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||||
|
|
||||||
|
# Start with hot reload
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
beeline/
|
||||||
|
├── honeycomb/ # Frontend (React + TypeScript + Vite)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ ├── services/ # API client and services
|
||||||
|
│ │ ├── types/ # TypeScript type definitions
|
||||||
|
│ │ └── utils/ # Utility functions
|
||||||
|
│ └── public/ # Static assets
|
||||||
|
│
|
||||||
|
├── hive/ # Backend (Node.js + TypeScript + Express)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── controllers/ # Request handlers
|
||||||
|
│ ├── middleware/ # Express middleware
|
||||||
|
│ ├── models/ # Data models
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ ├── services/ # Business logic
|
||||||
|
│ ├── types/ # TypeScript types
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
│
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── scripts/ # Build and utility scripts
|
||||||
|
└── config.yaml # Application configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Configure the Application**: See [Configuration Guide](configuration.md)
|
||||||
|
2. **Understand the Architecture**: See [Architecture Overview](architecture.md)
|
||||||
|
3. **Start Building**: Add your own components and API endpoints
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
If ports 3000 or 4000 are in use, update `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
frontend:
|
||||||
|
port: 3001 # Change to available port
|
||||||
|
backend:
|
||||||
|
port: 4001
|
||||||
|
```
|
||||||
|
|
||||||
|
Then regenerate environment files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Build Fails
|
||||||
|
|
||||||
|
Clear Docker cache and rebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies Issues
|
||||||
|
|
||||||
|
Clear node_modules and reinstall:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run clean
|
||||||
|
npm install
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=4000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# TSDB PostgreSQL (TimescaleDB)
|
||||||
|
TSDB_PG_URL=postgresql://user:password@localhost:5432/aden_tsdb
|
||||||
|
|
||||||
|
# User Database (MySQL - read-only access)
|
||||||
|
MYSQL_HOST=localhost
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=aden_reader
|
||||||
|
MYSQL_PASSWORD=
|
||||||
|
MYSQL_DATABASE=aden
|
||||||
|
|
||||||
|
# MongoDB (policies and pricing data)
|
||||||
|
MONGODB_URL=mongodb://localhost:27017
|
||||||
|
MONGODB_DBNAME=aden
|
||||||
|
MONGODB_ERP_DBNAME=erp
|
||||||
|
|
||||||
|
# Redis (caching and socket.io adapter)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT Authentication
|
||||||
|
JWT_SECRET=your-jwt-secret
|
||||||
|
PASSPHRASE=your-passphrase
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
ARG NPM_TOKEN
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Configure npm for private packages (@acho-inc/administration)
|
||||||
|
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Install all dependencies (including dev for TypeScript build)
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Copy docs for quickstart templates
|
||||||
|
COPY docs ./docs
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove npmrc after build
|
||||||
|
RUN rm -f .npmrc
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Copy package files for production deps
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Configure npm for private packages (needed for production install)
|
||||||
|
ARG NPM_TOKEN
|
||||||
|
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
|
||||||
|
npm install --omit=dev && \
|
||||||
|
rm -f .npmrc && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# Copy compiled JavaScript from builder
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy docs directory for quickstart templates
|
||||||
|
COPY --from=builder --chown=nodejs:nodejs /app/docs ./docs
|
||||||
|
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Default port (can be overridden via PORT env var)
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://localhost:' + (process.env.PORT || 4000) + '/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Development Dockerfile with hot reload
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
ARG NPM_TOKEN
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Configure npm for private packages (@acho-inc/administration)
|
||||||
|
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install && rm -f .npmrc
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose ports (app + debug)
|
||||||
|
EXPOSE 4000 9229
|
||||||
|
|
||||||
|
# Start development server with hot reload
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"generic": {
|
||||||
|
"name": "Generic",
|
||||||
|
"description": "Generic agent integration",
|
||||||
|
"pythonSupport": true,
|
||||||
|
"typescriptSupport": true,
|
||||||
|
"templateFile": "generic"
|
||||||
|
},
|
||||||
|
"langgraph": {
|
||||||
|
"name": "LangGraph",
|
||||||
|
"description": "LangGraph agent integration",
|
||||||
|
"pythonSupport": true,
|
||||||
|
"typescriptSupport": true,
|
||||||
|
"templateFile": "langgraph"
|
||||||
|
},
|
||||||
|
"livekit": {
|
||||||
|
"name": "LiveKit",
|
||||||
|
"description": "LiveKit voice agent integration",
|
||||||
|
"pythonSupport": true,
|
||||||
|
"typescriptSupport": false,
|
||||||
|
"adenPythonExtra": "livekit",
|
||||||
|
"templateFile": "livekit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"openai": {
|
||||||
|
"name": "OpenAI",
|
||||||
|
"envVarComment": "# or ANTHROPIC_API_KEY, GOOGLE_API_KEY"
|
||||||
|
},
|
||||||
|
"anthropic": {
|
||||||
|
"name": "Anthropic",
|
||||||
|
"envVarComment": "# or OPENAI_API_KEY, GOOGLE_API_KEY"
|
||||||
|
},
|
||||||
|
"google": {
|
||||||
|
"name": "Google",
|
||||||
|
"envVarComment": "# or OPENAI_API_KEY, ANTHROPIC_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"python": {
|
||||||
|
"name": "Python",
|
||||||
|
"adenPackage": "aden-py"
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"name": "JavaScript/TypeScript",
|
||||||
|
"adenPackage": "aden-ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into LangFlow applications.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx # or ANTHROPIC_API_KEY, GOOGLE_API_KEY
|
||||||
|
ADEN_API_URL=https://hive.adenhq.com
|
||||||
|
ADEN_API_KEY=your-aden-api-key
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aden-py langflow python-dotenv
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup (3 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup)
|
||||||
|
|
||||||
|
```python
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use LangFlow Components
|
||||||
|
|
||||||
|
```python
|
||||||
|
from langflow.components.models import LanguageModelComponent
|
||||||
|
|
||||||
|
comp = LanguageModelComponent()
|
||||||
|
comp.set_attributes({
|
||||||
|
"provider": "Google", # or "OpenAI"
|
||||||
|
"model_name": "gemini-2.0-flash",
|
||||||
|
"api_key": os.getenv("GOOGLE_API_KEY"),
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
model = comp.build_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = model.invoke("Hello!")
|
||||||
|
print(response.content)
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
print(f"Budget exceeded: {e}")
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```python
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""LangFlow with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, uninstrument, MeterOptions,
|
||||||
|
create_console_emitter, BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Initialize Aden
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
# === YOUR LANGFLOW CODE HERE ===
|
||||||
|
|
||||||
|
from langflow.components.models import LanguageModelComponent
|
||||||
|
|
||||||
|
def run_model(user_input: str):
|
||||||
|
try:
|
||||||
|
comp = LanguageModelComponent()
|
||||||
|
comp.set_attributes({
|
||||||
|
"provider": "Google",
|
||||||
|
"model_name": "gemini-2.0-flash",
|
||||||
|
"api_key": os.getenv("GOOGLE_API_KEY"),
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
model = comp.build_model()
|
||||||
|
return model.invoke(user_input).content
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
print(run_model("Say hello!"))
|
||||||
|
finally:
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Providers
|
||||||
|
|
||||||
|
| Provider | Model Example | Notes |
|
||||||
|
| --------- | ------------------- | -------------------------------- |
|
||||||
|
| OpenAI | gpt-4o, gpt-4o-mini | Direct SDK instrumentation |
|
||||||
|
| Google | gemini-2.0-flash | Uses gRPC client instrumentation |
|
||||||
|
| Anthropic | claude-3-opus | Direct SDK instrumentation |
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
| ----------------------------------------------- | ----------------- | ------------------------------ |
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- `before_request` callback enables budget enforcement
|
||||||
|
- Always wrap model calls in `try/except RequestCancelledError`
|
||||||
|
- Call `uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
- Google Gemini support works automatically via gRPC client instrumentation
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://pypi.org/project/aden-py](https://pypi.org/project/aden-py/)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into Python agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx # or ANTHROPIC_API_KEY, GOOGLE_API_KEY
|
||||||
|
ADEN_API_URL=https://hive.adenhq.com
|
||||||
|
ADEN_API_KEY=your-aden-api-key
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aden-py python-dotenv
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup (3 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup)
|
||||||
|
|
||||||
|
```python
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Budget Errors in Your Agent
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run_agent(user_input: str):
|
||||||
|
try:
|
||||||
|
# Your agent logic here
|
||||||
|
result = graph.invoke({"messages": [{"role": "user", "content": user_input}]})
|
||||||
|
return result["messages"][-1].content
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```python
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Agent with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, uninstrument, MeterOptions,
|
||||||
|
create_console_emitter, BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Initialize Aden
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
# === YOUR AGENT CODE HERE ===
|
||||||
|
|
||||||
|
def run_agent(user_input: str):
|
||||||
|
try:
|
||||||
|
# Your LLM calls here
|
||||||
|
pass
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
# Your main loop
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
| ----------------------------------------------- | ----------------- | ------------------------------ |
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- `before_request` callback enables budget enforcement
|
||||||
|
- Always wrap agent calls in `try/except RequestCancelledError`
|
||||||
|
- Call `uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://pypi.org/project/aden-py](https://pypi.org/project/aden-py/json)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Aden-py LiveKit Integration Guide
|
||||||
|
|
||||||
|
Quick reference for integrating Aden LLM observability & cost control into LiveKit voice agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx
|
||||||
|
ADEN_API_URL=https://hive.adenhq.com
|
||||||
|
ADEN_API_KEY=your-aden-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'aden-py[livekit]' python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup (4 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Worker Prewarm Function
|
||||||
|
|
||||||
|
**IMPORTANT:** LiveKit uses multiprocessing. Instrumentation must happen in each worker process, not the main process.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def initialize_aden_in_worker(proc):
|
||||||
|
"""Initialize Aden instrumentation in each worker process."""
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Pass Prewarm Function to WorkerOptions
|
||||||
|
|
||||||
|
```python
|
||||||
|
if __name__ == "__main__":
|
||||||
|
agents.cli.run_app(agents.WorkerOptions(
|
||||||
|
entrypoint_fnc=entrypoint,
|
||||||
|
agent_name="my-agent",
|
||||||
|
prewarm_fnc=initialize_aden_in_worker, # <-- This is the key!
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""LiveKit Voice Agent with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from livekit import agents
|
||||||
|
from livekit.plugins import openai
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, MeterOptions, create_console_emitter,
|
||||||
|
BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Worker initialization - runs in each spawned process
|
||||||
|
def initialize_aden_in_worker(proc):
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def entrypoint(ctx: agents.JobContext):
|
||||||
|
# Your agent logic here
|
||||||
|
session = agents.AgentSession(
|
||||||
|
llm=openai.LLM(model="gpt-4o-mini"),
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
await session.start(ctx.room)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
agents.cli.run_app(agents.WorkerOptions(
|
||||||
|
entrypoint_fnc=entrypoint,
|
||||||
|
agent_name="my-agent",
|
||||||
|
prewarm_fnc=initialize_aden_in_worker,
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
| ----------------------------------------------- | ------------------------ | ------------------------------ |
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit (95%+) | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit (80%+) | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- **Use `prewarm_fnc`** - LiveKit spawns worker processes; instrumentation must happen in each worker
|
||||||
|
- **Don't instrument in main process** - It won't affect the worker processes where LLM calls happen
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**No metrics showing?**
|
||||||
|
|
||||||
|
- Ensure `prewarm_fnc` is set in `WorkerOptions`
|
||||||
|
- Check that `ADEN_API_KEY` and `ADEN_API_URL` are in your `.env`
|
||||||
|
- Verify you're using `aden-py[livekit]` (with the livekit extra)
|
||||||
|
|
||||||
|
**Metrics in test but not in agent?**
|
||||||
|
|
||||||
|
- LiveKit uses multiprocessing - the main process instrumentation doesn't carry over
|
||||||
|
- The `prewarm_fnc` runs in each worker before your `entrypoint` is called
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into TypeScript/JavaScript agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx {{envVarComment}}
|
||||||
|
ADEN_API_URL={{serverUrl}}
|
||||||
|
ADEN_API_KEY={{apiKey}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install aden-ts dotenv
|
||||||
|
|
||||||
|
# Install the LLM SDKs you use
|
||||||
|
npm install openai # For OpenAI
|
||||||
|
npm install @anthropic-ai/sdk # For Anthropic
|
||||||
|
npm install @google/generative-ai # For Google Gemini
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
### 1. Import Aden and SDK (at top of file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import "dotenv/config";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import {
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
createConsoleEmitter,
|
||||||
|
RequestCancelledError,
|
||||||
|
} from "aden-ts";
|
||||||
|
import type { BeforeRequestContext, BeforeRequestResult } from "aden-ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Before Request Callback (optional)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Custom logic before each LLM request
|
||||||
|
// Budget enforcement is handled server-side by the control agent
|
||||||
|
function beforeRequest(
|
||||||
|
_params: Record<string, unknown>,
|
||||||
|
context: BeforeRequestContext
|
||||||
|
): BeforeRequestResult {
|
||||||
|
console.log(`[Aden] Request to model: ${context.model}`);
|
||||||
|
return { action: "proceed" };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup, BEFORE using SDK)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await instrument({
|
||||||
|
apiKey: process.env.ADEN_API_KEY,
|
||||||
|
serverUrl: process.env.ADEN_API_URL,
|
||||||
|
emitMetric: createConsoleEmitter({ pretty: true }),
|
||||||
|
onAlert: (alert: { level: string; message: string }) =>
|
||||||
|
console.log(`[Aden ${alert.level}] ${alert.message}`),
|
||||||
|
beforeRequest,
|
||||||
|
sdks: { OpenAI },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Budget Errors in Your Agent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function runAgent(userInput: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const openai = new OpenAI();
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o",
|
||||||
|
messages: [{ role: "user", content: userInput }],
|
||||||
|
});
|
||||||
|
return response.choices[0]?.message?.content ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RequestCancelledError) {
|
||||||
|
return `Sorry, your budget has been exhausted. ${e.message}`;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await uninstrument();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Agent with Aden instrumentation
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import {
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
createConsoleEmitter,
|
||||||
|
RequestCancelledError,
|
||||||
|
} from "aden-ts";
|
||||||
|
import type { BeforeRequestContext, BeforeRequestResult } from "aden-ts";
|
||||||
|
|
||||||
|
// Before request callback (optional)
|
||||||
|
function beforeRequest(
|
||||||
|
_params: Record<string, unknown>,
|
||||||
|
context: BeforeRequestContext
|
||||||
|
): BeforeRequestResult {
|
||||||
|
console.log(`[Aden] Request to model: ${context.model}`);
|
||||||
|
return { action: "proceed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Aden FIRST
|
||||||
|
await instrument({
|
||||||
|
apiKey: process.env.ADEN_API_KEY,
|
||||||
|
serverUrl: process.env.ADEN_API_URL,
|
||||||
|
emitMetric: createConsoleEmitter({ pretty: true }),
|
||||||
|
onAlert: (alert: { level: string; message: string }) =>
|
||||||
|
console.log(`[Aden ${alert.level}] ${alert.message}`),
|
||||||
|
beforeRequest,
|
||||||
|
sdks: { OpenAI },
|
||||||
|
});
|
||||||
|
|
||||||
|
// === YOUR AGENT CODE HERE ===
|
||||||
|
|
||||||
|
async function runAgent(userInput: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const openai = new OpenAI();
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o",
|
||||||
|
messages: [{ role: "user", content: userInput }],
|
||||||
|
});
|
||||||
|
return response.choices[0]?.message?.content ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RequestCancelledError) {
|
||||||
|
return `Sorry, your budget has been exhausted. ${e.message}`;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main entry point
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const result = await runAgent("Hello, world!");
|
||||||
|
console.log(result);
|
||||||
|
} finally {
|
||||||
|
await uninstrument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
```
|
||||||
|
|
||||||
|
## BeforeRequestContext Reference
|
||||||
|
|
||||||
|
The `context` parameter in `beforeRequest` contains:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `model` | string | Model being used for this request |
|
||||||
|
| `stream` | boolean | Whether this is a streaming request |
|
||||||
|
| `spanId` | string | Generated span ID (OTel standard) |
|
||||||
|
| `traceId` | string | Trace ID grouping related operations |
|
||||||
|
| `timestamp` | Date | When the request was initiated |
|
||||||
|
| `metadata` | Record<string, unknown> | Custom metadata (optional) |
|
||||||
|
|
||||||
|
## BeforeRequestResult Actions
|
||||||
|
|
||||||
|
| Action | Usage | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `{ action: "proceed" }` | Allow request | Request continues normally |
|
||||||
|
| `{ action: "cancel", reason: "..." }` | Block request | Throws `RequestCancelledError` |
|
||||||
|
| `{ action: "throttle", delayMs: N }` | Rate limit | Delays request by N ms |
|
||||||
|
| `{ action: "degrade", toModel: "...", reason: "..." }` | Downgrade | Switches to specified model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- Module name is `aden-ts` (not `aden`)
|
||||||
|
- `emitMetric` is **required** - use `createConsoleEmitter({ pretty: true })` for dev
|
||||||
|
- Budget enforcement is handled **server-side** by the control agent
|
||||||
|
- Always wrap agent calls in `try/catch` for `RequestCancelledError`
|
||||||
|
- Call `await uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `apiKey` + `serverUrl` are provided
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://www.npmjs.com/package/aden-ts](https://www.npmjs.com/package/aden-ts)
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into TypeScript/JavaScript agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx {{envVarComment}}
|
||||||
|
ADEN_API_URL={{serverUrl}}
|
||||||
|
ADEN_API_KEY={{apiKey}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install aden-ts dotenv
|
||||||
|
|
||||||
|
# Install the LLM SDKs you use
|
||||||
|
npm install openai # For OpenAI
|
||||||
|
npm install @anthropic-ai/sdk # For Anthropic
|
||||||
|
npm install @google/generative-ai # For Google Gemini
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
### 1. Import Aden and SDK (at top of file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import "dotenv/config";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import {
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
createConsoleEmitter,
|
||||||
|
RequestCancelledError,
|
||||||
|
} from "aden-ts";
|
||||||
|
import type { BeforeRequestContext, BeforeRequestResult } from "aden-ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Before Request Callback (optional)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Custom logic before each LLM request
|
||||||
|
// Budget enforcement is handled server-side by the control agent
|
||||||
|
function beforeRequest(
|
||||||
|
_params: Record<string, unknown>,
|
||||||
|
context: BeforeRequestContext
|
||||||
|
): BeforeRequestResult {
|
||||||
|
console.log(`[Aden] Request to model: ${context.model}`);
|
||||||
|
return { action: "proceed" };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup, BEFORE using SDK)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await instrument({
|
||||||
|
apiKey: process.env.ADEN_API_KEY,
|
||||||
|
serverUrl: process.env.ADEN_API_URL,
|
||||||
|
emitMetric: createConsoleEmitter({ pretty: true }),
|
||||||
|
onAlert: (alert: { level: string; message: string }) =>
|
||||||
|
console.log(`[Aden ${alert.level}] ${alert.message}`),
|
||||||
|
beforeRequest,
|
||||||
|
sdks: { OpenAI },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Budget Errors in Your Agent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function runAgent(userInput: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const openai = new OpenAI();
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o",
|
||||||
|
messages: [{ role: "user", content: userInput }],
|
||||||
|
});
|
||||||
|
return response.choices[0]?.message?.content ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RequestCancelledError) {
|
||||||
|
return `Sorry, your budget has been exhausted. ${e.message}`;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await uninstrument();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template (Direct SDK Usage)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Agent with Aden instrumentation - Direct SDK usage
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import {
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
createConsoleEmitter,
|
||||||
|
RequestCancelledError,
|
||||||
|
} from "aden-ts";
|
||||||
|
import type { BeforeRequestContext, BeforeRequestResult } from "aden-ts";
|
||||||
|
|
||||||
|
// Before request callback (optional)
|
||||||
|
function beforeRequest(
|
||||||
|
_params: Record<string, unknown>,
|
||||||
|
context: BeforeRequestContext
|
||||||
|
): BeforeRequestResult {
|
||||||
|
console.log(`[Aden] Request to model: ${context.model}`);
|
||||||
|
return { action: "proceed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Aden FIRST
|
||||||
|
await instrument({
|
||||||
|
apiKey: process.env.ADEN_API_KEY,
|
||||||
|
serverUrl: process.env.ADEN_API_URL,
|
||||||
|
emitMetric: createConsoleEmitter({ pretty: true }),
|
||||||
|
onAlert: (alert: { level: string; message: string }) =>
|
||||||
|
console.log(`[Aden ${alert.level}] ${alert.message}`),
|
||||||
|
beforeRequest,
|
||||||
|
sdks: { OpenAI },
|
||||||
|
});
|
||||||
|
|
||||||
|
// === YOUR AGENT CODE HERE ===
|
||||||
|
|
||||||
|
async function runAgent(userInput: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const openai = new OpenAI();
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o",
|
||||||
|
messages: [{ role: "user", content: userInput }],
|
||||||
|
});
|
||||||
|
return response.choices[0]?.message?.content ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RequestCancelledError) {
|
||||||
|
return `Sorry, your budget has been exhausted. ${e.message}`;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main entry point
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const result = await runAgent("Hello, world!");
|
||||||
|
console.log(result);
|
||||||
|
} finally {
|
||||||
|
await uninstrument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
```
|
||||||
|
|
||||||
|
## LangChain / LangGraph Integration
|
||||||
|
|
||||||
|
When using LangChain or LangGraph, you **MUST** use dynamic imports to ensure instrumentation is applied before LangChain loads the SDK.
|
||||||
|
|
||||||
|
### Critical: SDK Version Matching
|
||||||
|
|
||||||
|
LangChain bundles its own SDK dependencies. To ensure instrumentation works, your SDK version must match LangChain's:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what version LangChain uses
|
||||||
|
cat node_modules/@langchain/anthropic/node_modules/@anthropic-ai/sdk/package.json | grep version
|
||||||
|
|
||||||
|
# Update your package.json to match that version
|
||||||
|
# e.g., "@anthropic-ai/sdk": "^0.65.0"
|
||||||
|
|
||||||
|
# Reinstall to dedupe
|
||||||
|
rm -rf node_modules package-lock.json && npm install
|
||||||
|
|
||||||
|
# Verify no nested SDK (should show "No such file")
|
||||||
|
ls node_modules/@langchain/anthropic/node_modules 2>/dev/null || echo "OK: SDK is shared"
|
||||||
|
```
|
||||||
|
|
||||||
|
### LangChain Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* LangGraph Agent with Aden instrumentation
|
||||||
|
* Key: Use dynamic imports AFTER instrument()
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import {
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
createConsoleEmitter,
|
||||||
|
RequestCancelledError,
|
||||||
|
} from "aden-ts";
|
||||||
|
import type { BeforeRequestContext, BeforeRequestResult } from "aden-ts";
|
||||||
|
|
||||||
|
function beforeRequest(
|
||||||
|
_params: Record<string, unknown>,
|
||||||
|
context: BeforeRequestContext
|
||||||
|
): BeforeRequestResult {
|
||||||
|
console.log(`[Aden] Request to model: ${context.model}`);
|
||||||
|
return { action: "proceed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. Initialize Aden FIRST (before any LangChain imports)
|
||||||
|
await instrument({
|
||||||
|
apiKey: process.env.ADEN_API_KEY,
|
||||||
|
serverUrl: process.env.ADEN_API_URL,
|
||||||
|
emitMetric: createConsoleEmitter({ pretty: true }),
|
||||||
|
onAlert: (alert: { level: string; message: string }) =>
|
||||||
|
console.log(`[Aden ${alert.level}] ${alert.message}`),
|
||||||
|
beforeRequest,
|
||||||
|
sdks: { Anthropic },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Dynamic imports AFTER instrumentation
|
||||||
|
const { ChatAnthropic } = await import("@langchain/anthropic");
|
||||||
|
const { HumanMessage } = await import("@langchain/core/messages");
|
||||||
|
// ... other LangChain imports
|
||||||
|
|
||||||
|
// 3. Now create your LangChain components
|
||||||
|
const model = new ChatAnthropic({
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Your agent logic here
|
||||||
|
const response = await model.invoke([new HumanMessage("Hello!")]);
|
||||||
|
console.log(response.content);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RequestCancelledError) {
|
||||||
|
console.log(`Budget exhausted: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await uninstrument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
```
|
||||||
|
|
||||||
|
## BeforeRequestContext Reference
|
||||||
|
|
||||||
|
The `context` parameter in `beforeRequest` contains:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `model` | string | Model being used for this request |
|
||||||
|
| `stream` | boolean | Whether this is a streaming request |
|
||||||
|
| `spanId` | string | Generated span ID (OTel standard) |
|
||||||
|
| `traceId` | string | Trace ID grouping related operations |
|
||||||
|
| `timestamp` | Date | When the request was initiated |
|
||||||
|
| `metadata` | Record<string, unknown> | Custom metadata (optional) |
|
||||||
|
|
||||||
|
## BeforeRequestResult Actions
|
||||||
|
|
||||||
|
| Action | Usage | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `{ action: "proceed" }` | Allow request | Request continues normally |
|
||||||
|
| `{ action: "cancel", reason: "..." }` | Block request | Throws `RequestCancelledError` |
|
||||||
|
| `{ action: "throttle", delayMs: N }` | Rate limit | Delays request by N ms |
|
||||||
|
| `{ action: "degrade", toModel: "...", reason: "..." }` | Downgrade | Switches to specified model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- Module name is `aden-ts` (not `aden`)
|
||||||
|
- `emitMetric` is **required** - use `createConsoleEmitter({ pretty: true })` for dev
|
||||||
|
- Budget enforcement is handled **server-side** by the control agent
|
||||||
|
- Always wrap agent calls in `try/catch` for `RequestCancelledError`
|
||||||
|
- Call `await uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `apiKey` + `serverUrl` are provided
|
||||||
|
- **LangChain users**: Must use dynamic imports and match SDK versions
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No metrics being captured
|
||||||
|
|
||||||
|
1. **Check SDK version match**: Run `npm ls @anthropic-ai/sdk` - should show only ONE version
|
||||||
|
2. **Use dynamic imports**: Import LangChain modules AFTER `instrument()` is called
|
||||||
|
3. **Verify instrumentation**: Look for `[aden] Instrumented: anthropic + control agent` at startup
|
||||||
|
|
||||||
|
### RequestCancelledError not thrown
|
||||||
|
|
||||||
|
Budget enforcement is server-side. Ensure:
|
||||||
|
- `ADEN_API_KEY` and `ADEN_API_URL` are set correctly
|
||||||
|
- Control agent connection is established (check startup logs)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://www.npmjs.com/package/aden-ts](https://www.npmjs.com/package/aden-ts)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into Python agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx {{envVarComment}}
|
||||||
|
ADEN_API_URL={{serverUrl}}
|
||||||
|
ADEN_API_KEY={{apiKey}}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aden-py python-dotenv
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup (3 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup)
|
||||||
|
|
||||||
|
```python
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Budget Errors in Your Agent
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run_agent(user_input: str):
|
||||||
|
try:
|
||||||
|
# Your agent logic here
|
||||||
|
result = graph.invoke({"messages": [{"role": "user", "content": user_input}]})
|
||||||
|
return result["messages"][-1].content
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```python
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Agent with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, uninstrument, MeterOptions,
|
||||||
|
create_console_emitter, BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Initialize Aden
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
# === YOUR AGENT CODE HERE ===
|
||||||
|
|
||||||
|
def run_agent(user_input: str):
|
||||||
|
try:
|
||||||
|
# Your LLM calls here
|
||||||
|
pass
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
# Your main loop
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- `before_request` callback enables budget enforcement
|
||||||
|
- Always wrap agent calls in `try/except RequestCancelledError`
|
||||||
|
- Call `uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://pypi.org/project/aden-py](https://pypi.org/project/aden-py/)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into LangFlow applications.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx {{envVarComment}}
|
||||||
|
ADEN_API_URL={{serverUrl}}
|
||||||
|
ADEN_API_KEY={{apiKey}}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aden-py langflow python-dotenv
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup (3 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup)
|
||||||
|
|
||||||
|
```python
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use LangFlow Components
|
||||||
|
|
||||||
|
```python
|
||||||
|
from langflow.components.models import LanguageModelComponent
|
||||||
|
|
||||||
|
comp = LanguageModelComponent()
|
||||||
|
comp.set_attributes({
|
||||||
|
"provider": "Google", # or "OpenAI"
|
||||||
|
"model_name": "gemini-2.0-flash",
|
||||||
|
"api_key": os.getenv("GOOGLE_API_KEY"),
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
model = comp.build_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = model.invoke("Hello!")
|
||||||
|
print(response.content)
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
print(f"Budget exceeded: {e}")
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```python
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""LangFlow with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, uninstrument, MeterOptions,
|
||||||
|
create_console_emitter, BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Initialize Aden
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
# === YOUR LANGFLOW CODE HERE ===
|
||||||
|
|
||||||
|
from langflow.components.models import LanguageModelComponent
|
||||||
|
|
||||||
|
def run_model(user_input: str):
|
||||||
|
try:
|
||||||
|
comp = LanguageModelComponent()
|
||||||
|
comp.set_attributes({
|
||||||
|
"provider": "Google",
|
||||||
|
"model_name": "gemini-2.0-flash",
|
||||||
|
"api_key": os.getenv("GOOGLE_API_KEY"),
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
model = comp.build_model()
|
||||||
|
return model.invoke(user_input).content
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
print(run_model("Say hello!"))
|
||||||
|
finally:
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Providers
|
||||||
|
|
||||||
|
| Provider | Model Example | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OpenAI | gpt-4o, gpt-4o-mini | Direct SDK instrumentation |
|
||||||
|
| Google | gemini-2.0-flash | Uses gRPC client instrumentation |
|
||||||
|
| Anthropic | claude-3-opus | Direct SDK instrumentation |
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- `before_request` callback enables budget enforcement
|
||||||
|
- Always wrap model calls in `try/except RequestCancelledError`
|
||||||
|
- Call `uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
- Google Gemini support works automatically via gRPC client instrumentation
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://pypi.org/project/aden-py](https://pypi.org/project/aden-py/)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
Quick reference for integrating Aden LLM observability & cost control into Python agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx {{envVarComment}}
|
||||||
|
ADEN_API_URL={{serverUrl}}
|
||||||
|
ADEN_API_KEY={{apiKey}}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install aden-py python-dotenv
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Setup (3 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
uninstrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Aden (at startup)
|
||||||
|
|
||||||
|
```python
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Budget Errors in Your Agent
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run_agent(user_input: str):
|
||||||
|
try:
|
||||||
|
# Your agent logic here
|
||||||
|
result = graph.invoke({"messages": [{"role": "user", "content": user_input}]})
|
||||||
|
return result["messages"][-1].content
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cleanup (on exit)
|
||||||
|
|
||||||
|
```python
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Agent with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, uninstrument, MeterOptions,
|
||||||
|
create_console_emitter, BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Initialize Aden
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
# === YOUR AGENT CODE HERE ===
|
||||||
|
|
||||||
|
def run_agent(user_input: str):
|
||||||
|
try:
|
||||||
|
# Your LLM calls here
|
||||||
|
pass
|
||||||
|
except RequestCancelledError as e:
|
||||||
|
return f"Sorry, you have used up your allowance. {e}"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
# Your main loop
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
uninstrument()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- `before_request` callback enables budget enforcement
|
||||||
|
- Always wrap agent calls in `try/except RequestCancelledError`
|
||||||
|
- Call `uninstrument()` on exit to flush remaining metrics
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full docs: [https://pypi.org/project/aden-py](https://pypi.org/project/aden-py/)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Aden-py LiveKit Integration Guide
|
||||||
|
|
||||||
|
Quick reference for integrating Aden LLM observability & cost control into LiveKit voice agents.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` file should contain:
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=sk-xxx
|
||||||
|
ADEN_API_URL={{serverUrl}}
|
||||||
|
ADEN_API_KEY={{apiKey}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'aden-py[livekit]' python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup (4 Steps)
|
||||||
|
|
||||||
|
### 1. Import and Load Environment
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument,
|
||||||
|
MeterOptions,
|
||||||
|
create_console_emitter,
|
||||||
|
BeforeRequestResult,
|
||||||
|
RequestCancelledError,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Budget Check Callback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def budget_check(params, context):
|
||||||
|
"""Enforce budget limits before each LLM request."""
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Worker Prewarm Function
|
||||||
|
|
||||||
|
**IMPORTANT:** LiveKit uses multiprocessing. Instrumentation must happen in each worker process, not the main process.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def initialize_aden_in_worker(proc):
|
||||||
|
"""Initialize Aden instrumentation in each worker process."""
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Pass Prewarm Function to WorkerOptions
|
||||||
|
|
||||||
|
```python
|
||||||
|
if __name__ == "__main__":
|
||||||
|
agents.cli.run_app(agents.WorkerOptions(
|
||||||
|
entrypoint_fnc=entrypoint,
|
||||||
|
agent_name="my-agent",
|
||||||
|
prewarm_fnc=initialize_aden_in_worker, # <-- This is the key!
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Template
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""LiveKit Voice Agent with Aden instrumentation"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from livekit import agents
|
||||||
|
from livekit.plugins import openai
|
||||||
|
|
||||||
|
from aden import (
|
||||||
|
instrument, MeterOptions, create_console_emitter,
|
||||||
|
BeforeRequestResult, RequestCancelledError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget enforcement callback
|
||||||
|
def budget_check(params, context):
|
||||||
|
budget_info = getattr(context, 'budget', None)
|
||||||
|
if budget_info and budget_info.get('exhausted', False):
|
||||||
|
return BeforeRequestResult.cancel("Budget exhausted")
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 95:
|
||||||
|
return BeforeRequestResult.throttle(delay_ms=2000)
|
||||||
|
if budget_info and budget_info.get('percent_used', 0) >= 80:
|
||||||
|
return BeforeRequestResult.degrade(to_model="gpt-4o-mini", reason="Approaching limit")
|
||||||
|
return BeforeRequestResult.proceed()
|
||||||
|
|
||||||
|
# Worker initialization - runs in each spawned process
|
||||||
|
def initialize_aden_in_worker(proc):
|
||||||
|
instrument(MeterOptions(
|
||||||
|
api_key=os.environ.get("ADEN_API_KEY"),
|
||||||
|
server_url=os.environ.get("ADEN_API_URL"),
|
||||||
|
emit_metric=create_console_emitter(pretty=True),
|
||||||
|
on_alert=lambda alert: print(f"[Aden {alert.level}] {alert.message}"),
|
||||||
|
before_request=budget_check,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def entrypoint(ctx: agents.JobContext):
|
||||||
|
# Your agent logic here
|
||||||
|
session = agents.AgentSession(
|
||||||
|
llm=openai.LLM(model="gpt-4o-mini"),
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
await session.start(ctx.room)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
agents.cli.run_app(agents.WorkerOptions(
|
||||||
|
entrypoint_fnc=entrypoint,
|
||||||
|
agent_name="my-agent",
|
||||||
|
prewarm_fnc=initialize_aden_in_worker,
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budget Actions Reference
|
||||||
|
|
||||||
|
| Action | When | Behavior |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `BeforeRequestResult.proceed()` | Within budget | Request continues normally |
|
||||||
|
| `BeforeRequestResult.cancel(msg)` | Budget exhausted | Raises `RequestCancelledError` |
|
||||||
|
| `BeforeRequestResult.throttle(delay_ms=N)` | Near limit (95%+) | Delays request by N ms |
|
||||||
|
| `BeforeRequestResult.degrade(to_model, reason)` | Approaching limit (80%+) | Switches to cheaper model |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- **Use `prewarm_fnc`** - LiveKit spawns worker processes; instrumentation must happen in each worker
|
||||||
|
- **Don't instrument in main process** - It won't affect the worker processes where LLM calls happen
|
||||||
|
- `emit_metric` is **required** - use `create_console_emitter(pretty=True)` for dev
|
||||||
|
- Control agent connects automatically when `api_key` + `server_url` are provided
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**No metrics showing?**
|
||||||
|
- Ensure `prewarm_fnc` is set in `WorkerOptions`
|
||||||
|
- Check that `ADEN_API_KEY` and `ADEN_API_URL` are in your `.env`
|
||||||
|
- Verify you're using `aden-py[livekit]` (with the livekit extra)
|
||||||
|
|
||||||
|
**Metrics in test but not in agent?**
|
||||||
|
- LiveKit uses multiprocessing - the main process instrumentation doesn't carry over
|
||||||
|
- The `prewarm_fnc` runs in each worker before your `entrypoint` is called
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# User Authentication API
|
||||||
|
|
||||||
|
This document describes the user authentication endpoints available in the Hive backend.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:4000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Register a New User
|
||||||
|
|
||||||
|
Create a new user account and receive an authentication token.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /user/register
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request Headers
|
||||||
|
|
||||||
|
| Header | Value | Required |
|
||||||
|
| ------------ | ---------------- | -------- |
|
||||||
|
| Content-Type | application/json | Yes |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| --------- | ------ | -------- | ------------------------------- |
|
||||||
|
| email | string | Yes | User's email address |
|
||||||
|
| password | string | Yes | Password (minimum 8 characters) |
|
||||||
|
| name | string | No | Display name |
|
||||||
|
| firstname | string | No | First name |
|
||||||
|
| lastname | string | No | Last name |
|
||||||
|
|
||||||
|
#### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:4000/user/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securepassword123",
|
||||||
|
"firstname": "John",
|
||||||
|
"lastname": "Doe"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success Response (201 Created)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "John Doe",
|
||||||
|
"firstname": "John",
|
||||||
|
"lastname": "Doe",
|
||||||
|
"current_team_id": 1,
|
||||||
|
"create_time": "2026-01-13T01:52:56.604Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Responses
|
||||||
|
|
||||||
|
| Status | Code | Message |
|
||||||
|
| ------ | --------------------- | -------------------------------------- |
|
||||||
|
| 400 | Bad Request | Email and password are required |
|
||||||
|
| 400 | Bad Request | Please enter a valid email |
|
||||||
|
| 400 | Bad Request | Password must be at least 8 characters |
|
||||||
|
| 409 | Conflict | Email already registered |
|
||||||
|
| 500 | Internal Server Error | Registration failed. Please try again. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
Authenticate an existing user and receive an authentication token.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /user/login-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request Headers
|
||||||
|
|
||||||
|
| Header | Value | Required |
|
||||||
|
| ------------ | ---------------- | -------- |
|
||||||
|
| Content-Type | application/json | Yes |
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------- | ------ | -------- | -------------------- |
|
||||||
|
| email | string | Yes | User's email address |
|
||||||
|
| password | string | Yes | User's password |
|
||||||
|
|
||||||
|
#### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:4000/user/login-v2 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securepassword123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"firstname": "John",
|
||||||
|
"lastname": "Doe",
|
||||||
|
"name": "John Doe",
|
||||||
|
"current_team_id": 1,
|
||||||
|
"create_time": "2026-01-13T01:52:56.594Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Responses
|
||||||
|
|
||||||
|
| Status | Code | Message |
|
||||||
|
| ------ | --------------------- | -------------------------------------- |
|
||||||
|
| 400 | Bad Request | Email and password are required |
|
||||||
|
| 400 | Bad Request | Please enter a valid email |
|
||||||
|
| 400 | Bad Request | Password must be at least 6 characters |
|
||||||
|
| 400 | Bad Request | Please sign in with OAuth |
|
||||||
|
| 401 | Unauthorized | Invalid email or password |
|
||||||
|
| 403 | Forbidden | Your account has been disabled |
|
||||||
|
| 500 | Internal Server Error | Login failed. Please try again. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Current User
|
||||||
|
|
||||||
|
Retrieve information about the currently authenticated user.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /user/me
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request Headers
|
||||||
|
|
||||||
|
| Header | Value | Required |
|
||||||
|
| ------------- | ------- | -------- |
|
||||||
|
| Authorization | {token} | Yes |
|
||||||
|
|
||||||
|
#### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:4000/user/me \
|
||||||
|
-H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Success Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "John Doe",
|
||||||
|
"firstname": "John",
|
||||||
|
"lastname": "Doe",
|
||||||
|
"current_team_id": 1,
|
||||||
|
"avatar_url": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Responses
|
||||||
|
|
||||||
|
| Status | Code | Message |
|
||||||
|
| ------ | --------------------- | ----------------------- |
|
||||||
|
| 401 | Unauthorized | No token provided |
|
||||||
|
| 401 | Unauthorized | Invalid token |
|
||||||
|
| 500 | Internal Server Error | Failed to get user info |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
After successful login or registration, the API returns a JWT token. Include this token in the `Authorization` header for authenticated requests:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Structure
|
||||||
|
|
||||||
|
The JWT token contains the following claims:
|
||||||
|
|
||||||
|
| Claim | Description |
|
||||||
|
| --------------- | -------------------------------- |
|
||||||
|
| id | User ID |
|
||||||
|
| email | User email |
|
||||||
|
| firstname | User first name |
|
||||||
|
| lastname | User last name |
|
||||||
|
| current_team_id | User's current team ID |
|
||||||
|
| salt | Random salt for token validation |
|
||||||
|
| iat | Issued at timestamp |
|
||||||
|
| exp | Expiration timestamp |
|
||||||
|
|
||||||
|
### Token Expiration
|
||||||
|
|
||||||
|
By default, tokens expire after 7 days. This can be configured via the `JWT_EXPIRES_IN` environment variable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Credentials
|
||||||
|
|
||||||
|
For local development, the following default user is available:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| -------- | ------------------- |
|
||||||
|
| Email | dev@honeycomb.local |
|
||||||
|
| Password | honeycomb123 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Response Format
|
||||||
|
|
||||||
|
All error responses follow this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "Error message describing what went wrong"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Currently, rate limiting is not enabled by default. It can be enabled via the `features.rate_limiting` config option.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
|
||||||
|
The API supports CORS. Configure the allowed origin via the `cors.origin` config option (default: `http://localhost:3000`).
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
# Aden SDK Trace Event Specification
|
||||||
|
|
||||||
|
**Version:** 2.0.0
|
||||||
|
**Last Updated:** 2026-01-08
|
||||||
|
|
||||||
|
This document defines the authoritative specification for all events transmitted between the Aden SDK and the Aden Hive control server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Event Types](#event-types)
|
||||||
|
3. [MetricEvent](#metricevent)
|
||||||
|
4. [ContentCapture (Layer 0)](#contentcapture-layer-0)
|
||||||
|
5. [ToolCallCapture (Layer 6)](#toolcallcapture-layer-6)
|
||||||
|
6. [ControlEvent](#controlevent)
|
||||||
|
7. [HeartbeatEvent](#heartbeatevent)
|
||||||
|
8. [ErrorEvent](#errorevent)
|
||||||
|
9. [API Endpoints](#api-endpoints)
|
||||||
|
10. [Storage Architecture](#storage-architecture)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Aden SDK captures telemetry from LLM API calls and transmits events to the Aden Hive server for:
|
||||||
|
- **Observability**: Token usage, latency, cost tracking
|
||||||
|
- **Governance**: Content capture, tool call validation
|
||||||
|
- **Control**: Budget enforcement, rate limiting, model degradation
|
||||||
|
|
||||||
|
### Providers Supported
|
||||||
|
|
||||||
|
| Provider | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| OpenAI | `openai` |
|
||||||
|
| Anthropic | `anthropic` |
|
||||||
|
| Google Gemini | `gemini` |
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
Events are sent via:
|
||||||
|
- **HTTP POST** to `/v1/control/events` (batch)
|
||||||
|
- **WebSocket** for real-time policy sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
| Event Type | Description | Direction |
|
||||||
|
|------------|-------------|-----------|
|
||||||
|
| `metric` | LLM call telemetry | SDK → Server |
|
||||||
|
| `control` | Control action taken | SDK → Server |
|
||||||
|
| `heartbeat` | Health status | SDK → Server |
|
||||||
|
| `error` | Error report | SDK → Server |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MetricEvent
|
||||||
|
|
||||||
|
The primary event emitted after each LLM API call. Contains flat fields for consistent cross-provider analytics.
|
||||||
|
|
||||||
|
### Envelope Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "metric",
|
||||||
|
"timestamp": "2026-01-08T12:00:00.000Z",
|
||||||
|
"sdk_instance_id": "uuid-v4",
|
||||||
|
"data": { /* MetricEvent fields */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MetricEvent Fields
|
||||||
|
|
||||||
|
#### Identity (OpenTelemetry-compatible)
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `trace_id` | string | **Yes** | Trace ID grouping related operations |
|
||||||
|
| `span_id` | string | Yes | Unique span ID for this operation |
|
||||||
|
| `parent_span_id` | string | No | Parent span for nested calls |
|
||||||
|
| `request_id` | string | No | Provider-specific request ID |
|
||||||
|
| `call_sequence` | integer | Yes | Sequence number within the trace |
|
||||||
|
|
||||||
|
#### Provider & Model
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `provider` | string | **Yes** | `openai`, `anthropic`, `gemini` |
|
||||||
|
| `model` | string | **Yes** | Model identifier (e.g., `gpt-4o`, `claude-3-opus`) |
|
||||||
|
| `stream` | boolean | Yes | Whether streaming was enabled |
|
||||||
|
| `timestamp` | string | **Yes** | ISO 8601 timestamp of request start |
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `latency_ms` | float | Yes | Request latency in milliseconds |
|
||||||
|
| `status_code` | integer | No | HTTP status code |
|
||||||
|
| `error` | string | No | Error message if request failed |
|
||||||
|
|
||||||
|
#### Token Usage
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `input_tokens` | integer | Yes | Input/prompt tokens consumed |
|
||||||
|
| `output_tokens` | integer | Yes | Output/completion tokens consumed |
|
||||||
|
| `total_tokens` | integer | Yes | Total tokens (input + output) |
|
||||||
|
| `cached_tokens` | integer | No | Tokens served from cache |
|
||||||
|
| `reasoning_tokens` | integer | No | Reasoning tokens (o1/o3 models) |
|
||||||
|
|
||||||
|
#### Rate Limits
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `rate_limit_remaining_requests` | integer | No | Remaining requests in window |
|
||||||
|
| `rate_limit_remaining_tokens` | integer | No | Remaining tokens in window |
|
||||||
|
| `rate_limit_reset_requests` | float | No | Seconds until request limit resets |
|
||||||
|
| `rate_limit_reset_tokens` | float | No | Seconds until token limit resets |
|
||||||
|
|
||||||
|
#### Call Context
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `agent_stack` | string[] | No | Stack of agent names leading to this call |
|
||||||
|
| `call_site_file` | string | No | File path of immediate caller |
|
||||||
|
| `call_site_line` | integer | No | Line number |
|
||||||
|
| `call_site_column` | integer | No | Column number |
|
||||||
|
| `call_site_function` | string | No | Function name |
|
||||||
|
| `call_stack` | string[] | No | Full call stack (file:line:function) |
|
||||||
|
|
||||||
|
#### Tool Usage (Summary)
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `tool_call_count` | integer | No | Number of tool calls made |
|
||||||
|
| `tool_names` | string | No | Tool names (comma-separated) |
|
||||||
|
|
||||||
|
#### Provider-specific
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `service_tier` | string | No | Service tier (auto, default, flex, priority) |
|
||||||
|
| `metadata` | object | No | Custom metadata attached to request |
|
||||||
|
|
||||||
|
#### Layer 0: Content Capture
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `content_capture` | ContentCapture | No | Full content capture (see below) |
|
||||||
|
|
||||||
|
#### Layer 6: Tool Call Deep Inspection
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `tool_calls_captured` | ToolCallCapture[] | No | Detailed tool call captures |
|
||||||
|
| `tool_validation_errors_count` | integer | No | Count of validation errors |
|
||||||
|
|
||||||
|
### Example MetricEvent
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "metric",
|
||||||
|
"timestamp": "2026-01-08T12:00:00.000Z",
|
||||||
|
"sdk_instance_id": "abc123",
|
||||||
|
"data": {
|
||||||
|
"trace_id": "tr_abc123",
|
||||||
|
"span_id": "sp_def456",
|
||||||
|
"call_sequence": 1,
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"stream": false,
|
||||||
|
"latency_ms": 1234.5,
|
||||||
|
"input_tokens": 150,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"total_tokens": 200,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"agent_stack": ["main_agent", "sub_agent"],
|
||||||
|
"tool_call_count": 2,
|
||||||
|
"tool_names": "search,calculate",
|
||||||
|
"metadata": {
|
||||||
|
"user_id": "user_123",
|
||||||
|
"session_id": "sess_456"
|
||||||
|
},
|
||||||
|
"content_capture": {
|
||||||
|
"system_prompt": "You are a helpful assistant.",
|
||||||
|
"messages": [...],
|
||||||
|
"response_content": "Here is my response...",
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ContentCapture (Layer 0)
|
||||||
|
|
||||||
|
Full content capture for request and response. Enables governance, debugging, and compliance.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `system_prompt` | string \| ContentReference | System prompt |
|
||||||
|
| `messages` | MessageCapture[] \| ContentReference | Message history |
|
||||||
|
| `tools` | ToolSchemaCapture[] \| ContentReference | Tools schema |
|
||||||
|
| `params` | RequestParamsCapture | Request parameters |
|
||||||
|
| `response_content` | string \| ContentReference | Response text |
|
||||||
|
| `finish_reason` | string | Why response ended: `stop`, `length`, `tool_calls`, `content_filter` |
|
||||||
|
| `choice_count` | integer | Number of choices (for n > 1) |
|
||||||
|
| `has_images` | boolean | Whether request contained images |
|
||||||
|
| `image_urls` | string[] | Image URLs (never base64) |
|
||||||
|
|
||||||
|
### ContentReference
|
||||||
|
|
||||||
|
When content exceeds `max_content_bytes`, it's stored separately and referenced:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content_id": "uuid-v4",
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"byte_size": 12345,
|
||||||
|
"truncated_preview": "First 100 chars..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MessageCapture
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "user|assistant|system|tool",
|
||||||
|
"content": "string or ContentReference",
|
||||||
|
"name": "optional name",
|
||||||
|
"tool_call_id": "for tool results"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ToolSchemaCapture
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "function_name",
|
||||||
|
"description": "Tool description",
|
||||||
|
"parameters_schema": { /* JSON Schema */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RequestParamsCapture
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 1000,
|
||||||
|
"top_p": 1.0,
|
||||||
|
"frequency_penalty": 0,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
"stop": ["STOP"],
|
||||||
|
"seed": 12345,
|
||||||
|
"top_k": 40
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ToolCallCapture (Layer 6)
|
||||||
|
|
||||||
|
Detailed tool call capture with validation results.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | string | Tool call ID for correlation |
|
||||||
|
| `name` | string | Tool/function name |
|
||||||
|
| `arguments` | object \| ContentReference | Parsed arguments |
|
||||||
|
| `arguments_raw` | string \| ContentReference | Raw JSON string |
|
||||||
|
| `validation_errors` | ValidationError[] | Schema validation errors |
|
||||||
|
| `is_valid` | boolean | Whether arguments passed validation |
|
||||||
|
| `index` | integer | Position in tool_calls array |
|
||||||
|
|
||||||
|
### ValidationError
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "properties.name",
|
||||||
|
"message": "Required property missing",
|
||||||
|
"expected_type": "string",
|
||||||
|
"actual_type": "undefined"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ControlEvent
|
||||||
|
|
||||||
|
Emitted when a control action is taken on a request.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `event_type` | string | Yes | Always `"control"` |
|
||||||
|
| `timestamp` | string | Yes | ISO 8601 timestamp |
|
||||||
|
| `sdk_instance_id` | string | Yes | SDK instance identifier |
|
||||||
|
| `trace_id` | string | Yes | Associated trace ID |
|
||||||
|
| `span_id` | string | Yes | Associated span ID |
|
||||||
|
| `provider` | string | Yes | Provider name |
|
||||||
|
| `original_model` | string | Yes | Originally requested model |
|
||||||
|
| `action` | string | Yes | Action taken (see below) |
|
||||||
|
| `reason` | string | No | Human-readable reason |
|
||||||
|
| `degraded_to` | string | No | Model switched to (if degraded) |
|
||||||
|
| `throttle_delay_ms` | integer | No | Delay applied (if throttled) |
|
||||||
|
| `estimated_cost` | float | No | Estimated cost that triggered decision |
|
||||||
|
| `policy_id` | string | Yes | Policy ID (default: `"default"`) |
|
||||||
|
| `budget_id` | string | No | Budget that triggered action |
|
||||||
|
| `context_id` | string | No | Context ID (user, session, etc.) |
|
||||||
|
|
||||||
|
### Control Actions
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `allow` | Request proceeds normally |
|
||||||
|
| `block` | Request is rejected |
|
||||||
|
| `throttle` | Request is delayed before proceeding |
|
||||||
|
| `degrade` | Request uses a cheaper/fallback model |
|
||||||
|
| `alert` | Request proceeds but triggers alert |
|
||||||
|
|
||||||
|
### Example ControlEvent
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "control",
|
||||||
|
"timestamp": "2026-01-08T12:00:00.000Z",
|
||||||
|
"sdk_instance_id": "abc123",
|
||||||
|
"trace_id": "tr_abc123",
|
||||||
|
"span_id": "sp_def456",
|
||||||
|
"provider": "openai",
|
||||||
|
"original_model": "gpt-4o",
|
||||||
|
"action": "degrade",
|
||||||
|
"reason": "Budget limit exceeded",
|
||||||
|
"degraded_to": "gpt-4o-mini",
|
||||||
|
"estimated_cost": 0.05,
|
||||||
|
"policy_id": "default",
|
||||||
|
"budget_id": "budget_monthly"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HeartbeatEvent
|
||||||
|
|
||||||
|
Periodic health check sent by the SDK.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `event_type` | string | Yes | Always `"heartbeat"` |
|
||||||
|
| `timestamp` | string | Yes | ISO 8601 timestamp |
|
||||||
|
| `sdk_instance_id` | string | Yes | SDK instance identifier |
|
||||||
|
| `status` | string | Yes | `healthy`, `degraded`, `reconnecting` |
|
||||||
|
| `requests_since_last` | integer | Yes | Requests since last heartbeat |
|
||||||
|
| `errors_since_last` | integer | Yes | Errors since last heartbeat |
|
||||||
|
| `policy_cache_age_seconds` | integer | Yes | Policy cache age |
|
||||||
|
| `websocket_connected` | boolean | Yes | WebSocket connection status |
|
||||||
|
| `sdk_version` | string | Yes | SDK version |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ErrorEvent
|
||||||
|
|
||||||
|
Emitted when an error occurs in the SDK.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `event_type` | string | Yes | Always `"error"` |
|
||||||
|
| `timestamp` | string | Yes | ISO 8601 timestamp |
|
||||||
|
| `sdk_instance_id` | string | Yes | SDK instance identifier |
|
||||||
|
| `message` | string | Yes | Error message |
|
||||||
|
| `code` | string | No | Error code |
|
||||||
|
| `stack` | string | No | Stack trace |
|
||||||
|
| `trace_id` | string | No | Related trace ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### POST /v1/control/events
|
||||||
|
|
||||||
|
Submit events batch.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{ "event_type": "metric", "timestamp": "...", "data": {...} },
|
||||||
|
{ "event_type": "control", "timestamp": "...", ... }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"processed": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /v1/control/content
|
||||||
|
|
||||||
|
Store large content items (MongoDB - for SDK content references).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"content_id": "uuid",
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"content": "full content string",
|
||||||
|
"byte_size": 12345
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"stored": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /v1/control/content/:contentId
|
||||||
|
|
||||||
|
Retrieve stored content by ID (MongoDB).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content_id": "uuid",
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"content": "full content string",
|
||||||
|
"byte_size": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /v1/control/events/:traceId/:callSequence/content
|
||||||
|
|
||||||
|
Retrieve content for a specific event from TSDB warm/cold storage.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trace_id": "tr_abc123",
|
||||||
|
"call_sequence": 1,
|
||||||
|
"content_items": [
|
||||||
|
{
|
||||||
|
"content_type": "system_prompt",
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"byte_size": 256,
|
||||||
|
"truncated_preview": "You are a helpful...",
|
||||||
|
"content": "You are a helpful assistant..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content_type": "messages",
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"byte_size": 4096,
|
||||||
|
"message_count": 5,
|
||||||
|
"truncated_preview": "[{\"role\":\"user\"...",
|
||||||
|
"content": "[{\"role\":\"user\",\"content\":\"Hello\"}...]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content_type": "response",
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"byte_size": 512,
|
||||||
|
"truncated_preview": "Here is my response...",
|
||||||
|
"content": "Here is my response to your question..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /v1/control/content/hash/:contentHash
|
||||||
|
|
||||||
|
Retrieve content from cold storage by SHA-256 hash.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content_hash": "sha256-hex",
|
||||||
|
"content": "full content string",
|
||||||
|
"byte_size": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /v1/control/policy
|
||||||
|
|
||||||
|
Fetch current control policy.
|
||||||
|
|
||||||
|
### POST /v1/control/budget/validate
|
||||||
|
|
||||||
|
Server-side budget validation (hybrid enforcement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Architecture
|
||||||
|
|
||||||
|
The storage system uses a **hot/warm/cold** architecture optimized for time-series analytics with content deduplication.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SDK Event Ingestion │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Event Normalization & Content Extraction │
|
||||||
|
│ │
|
||||||
|
│ • Extract content_capture fields │
|
||||||
|
│ • Hash content with SHA-256 │
|
||||||
|
│ • Create lightweight content flags for hot table │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ HOT TABLE │ │ WARM TABLE │ │ COLD TABLE │
|
||||||
|
│ llm_events │ │llm_event_ │ │llm_content_ │
|
||||||
|
│ │ │ content │ │ store │
|
||||||
|
│ Metrics only │ │Content refs │ │ Deduplicated │
|
||||||
|
│ Fast queries │ │ per event │ │ content │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. **Hot/Cold Separation**: Metrics stay in the hot table for fast time-series queries; content is stored separately
|
||||||
|
2. **Content Deduplication**: Identical content (same SHA-256 hash) is stored once, regardless of how many events reference it
|
||||||
|
3. **Reference Counting**: Cold storage tracks how many events reference each piece of content
|
||||||
|
4. **Preview Without Fetch**: Warm table stores truncated previews for quick scanning without fetching full content
|
||||||
|
|
||||||
|
### TSDB Hot Table: `llm_events`
|
||||||
|
|
||||||
|
Stores metric events for fast time-series analytics. **Content is NOT stored here** (only lightweight flags).
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `timestamp` | timestamptz | Event timestamp (partition key) |
|
||||||
|
| `ingest_date` | date | Ingestion date |
|
||||||
|
| `team_id` | text | Team identifier |
|
||||||
|
| `user_id` | text | User identifier |
|
||||||
|
| `trace_id` | text | Trace ID |
|
||||||
|
| `span_id` | text | Span ID |
|
||||||
|
| `parent_span_id` | text | Parent span ID |
|
||||||
|
| `request_id` | text | Provider request ID |
|
||||||
|
| `provider` | text | Provider name |
|
||||||
|
| `call_sequence` | integer | Sequence within trace |
|
||||||
|
| `model` | text | Model identifier |
|
||||||
|
| `stream` | boolean | Streaming flag |
|
||||||
|
| `agent` | text | Primary agent name |
|
||||||
|
| `agent_stack` | jsonb | Full agent stack |
|
||||||
|
| `latency_ms` | double precision | Latency in ms |
|
||||||
|
| `usage_input_tokens` | double precision | Input tokens |
|
||||||
|
| `usage_output_tokens` | double precision | Output tokens |
|
||||||
|
| `usage_total_tokens` | double precision | Total tokens |
|
||||||
|
| `usage_cached_tokens` | double precision | Cached tokens |
|
||||||
|
| `usage_reasoning_tokens` | double precision | Reasoning tokens |
|
||||||
|
| `cost_total` | numeric | Calculated cost |
|
||||||
|
| `metadata` | jsonb | Custom metadata |
|
||||||
|
| `call_site` | jsonb | Call site info |
|
||||||
|
| `has_content` | boolean | Whether content was captured |
|
||||||
|
| `finish_reason` | text | Response finish reason |
|
||||||
|
| `tool_call_count` | integer | Number of tool calls |
|
||||||
|
| `created_at` | timestamptz | Record creation time |
|
||||||
|
|
||||||
|
**Primary Key:** `(timestamp, trace_id, call_sequence)`
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_llm_events_ts` - timestamp DESC
|
||||||
|
- `idx_llm_events_team_ts` - team_id, timestamp DESC
|
||||||
|
- `idx_llm_events_model` - model
|
||||||
|
- `idx_llm_events_agent` - agent
|
||||||
|
- `idx_llm_events_trace` - trace_id
|
||||||
|
|
||||||
|
### TSDB Warm Table: `llm_event_content`
|
||||||
|
|
||||||
|
Links events to deduplicated content in cold storage. One row per content type per event.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | bigserial | Auto-increment ID |
|
||||||
|
| `timestamp` | timestamptz | Event timestamp |
|
||||||
|
| `trace_id` | text | Trace ID |
|
||||||
|
| `call_sequence` | integer | Sequence within trace |
|
||||||
|
| `team_id` | text | Team identifier |
|
||||||
|
| `content_type` | text | Type: `system_prompt`, `messages`, `response`, `tools`, `params` |
|
||||||
|
| `content_hash` | text | SHA-256 hash (FK to cold store) |
|
||||||
|
| `byte_size` | integer | Content size in bytes |
|
||||||
|
| `message_count` | integer | Number of messages (for `messages` type) |
|
||||||
|
| `truncated_preview` | text | First 200 chars for quick preview |
|
||||||
|
| `created_at` | timestamptz | Record creation time |
|
||||||
|
|
||||||
|
**Primary Key:** `(id)`
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_llm_event_content_event` - trace_id, call_sequence, timestamp
|
||||||
|
- `idx_llm_event_content_type` - team_id, content_type, timestamp DESC
|
||||||
|
- `idx_llm_event_content_hash` - content_hash
|
||||||
|
|
||||||
|
### TSDB Cold Table: `llm_content_store`
|
||||||
|
|
||||||
|
Content-addressable storage with SHA-256 hashes. Deduplicated across all events.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `content_hash` | text | SHA-256 hash of content (PK) |
|
||||||
|
| `team_id` | text | Team identifier (PK) |
|
||||||
|
| `content` | text | Full content string |
|
||||||
|
| `byte_size` | integer | Content size in bytes |
|
||||||
|
| `ref_count` | integer | Number of events referencing this content |
|
||||||
|
| `first_seen_at` | timestamptz | When content was first stored |
|
||||||
|
| `last_seen_at` | timestamptz | When content was last referenced |
|
||||||
|
|
||||||
|
**Primary Key:** `(content_hash, team_id)`
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_llm_content_store_refs` - team_id, ref_count, last_seen_at (for cleanup)
|
||||||
|
|
||||||
|
### MongoDB: `aden_control_content`
|
||||||
|
|
||||||
|
Stores large content items from SDK's content reference system (separate from TSDB storage).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `content_id` | string | Unique content identifier |
|
||||||
|
| `team_id` | string | Team identifier |
|
||||||
|
| `content_hash` | string | SHA-256 hash |
|
||||||
|
| `content` | string | Full content |
|
||||||
|
| `byte_size` | number | Content size in bytes |
|
||||||
|
| `created_at` | string | Creation timestamp |
|
||||||
|
| `updated_at` | string | Last update timestamp |
|
||||||
|
|
||||||
|
**Index:** `{ content_id: 1, team_id: 1 }` (unique)
|
||||||
|
|
||||||
|
### MongoDB: `aden_control_policies`
|
||||||
|
|
||||||
|
Stores control policies for teams.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Types
|
||||||
|
|
||||||
|
The warm table stores references to different content types:
|
||||||
|
|
||||||
|
| Type | Description | Example |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `system_prompt` | System/developer message | "You are a helpful assistant..." |
|
||||||
|
| `messages` | Full conversation history | JSON array of messages |
|
||||||
|
| `response` | Model's response content | "Here is my response..." |
|
||||||
|
| `tools` | Tool/function schemas | JSON array of tool definitions |
|
||||||
|
| `params` | Request parameters | `{"temperature": 0.7, "max_tokens": 1000}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deduplication Example
|
||||||
|
|
||||||
|
When the same system prompt is used across multiple requests:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request 1: system_prompt = "You are a helpful assistant."
|
||||||
|
→ Hash: abc123...
|
||||||
|
→ Cold store: INSERT (ref_count = 1)
|
||||||
|
→ Warm store: INSERT reference for event 1
|
||||||
|
|
||||||
|
Request 2: system_prompt = "You are a helpful assistant." (same)
|
||||||
|
→ Hash: abc123... (same hash)
|
||||||
|
→ Cold store: UPDATE ref_count = 2
|
||||||
|
→ Warm store: INSERT reference for event 2
|
||||||
|
|
||||||
|
Request 3: system_prompt = "You are a code reviewer."
|
||||||
|
→ Hash: def456... (different)
|
||||||
|
→ Cold store: INSERT (ref_count = 1)
|
||||||
|
→ Warm store: INSERT reference for event 3
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the first system prompt is stored **once** but referenced by two events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
| Version | Date | Changes |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 2.0.0 | 2026-01-08 | Hot/warm/cold storage architecture; content deduplication |
|
||||||
|
| 1.0.0 | 2026-01-08 | Initial specification |
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: aden-hive
|
||||||
|
labels:
|
||||||
|
app: aden-hive
|
||||||
|
app.kubernetes.io/name: aden-hive
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: aden-hive
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 25%
|
||||||
|
maxUnavailable: 25%
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: aden-hive
|
||||||
|
app.kubernetes.io/name: aden-hive
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: aden-hive
|
||||||
|
image: aden-hive
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3001
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: POD_NAMESPACE
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: MYSQL_SSL_CA
|
||||||
|
value: /mnt/certs/mysql/server-ca.pem
|
||||||
|
- name: MYSQL_SSL_KEY
|
||||||
|
value: /mnt/certs/mysql/client-key.pem
|
||||||
|
- name: MYSQL_SSL_CERT
|
||||||
|
value: /mnt/certs/mysql/client-cert.pem
|
||||||
|
volumeMounts:
|
||||||
|
- name: mysql-ssl-certs
|
||||||
|
mountPath: /mnt/certs/mysql
|
||||||
|
readOnly: true
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: aden-hive-config
|
||||||
|
- secretRef:
|
||||||
|
name: aden-hive-secrets
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 512Mi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 60
|
||||||
|
timeoutSeconds: 15
|
||||||
|
failureThreshold: 5
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 10
|
||||||
|
failureThreshold: 5
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumes:
|
||||||
|
- name: mysql-ssl-certs
|
||||||
|
secret:
|
||||||
|
secretName: mysql-ssl-certs
|
||||||
|
defaultMode: 0444
|
||||||
|
items:
|
||||||
|
- key: server-ca.pem
|
||||||
|
path: server-ca.pem
|
||||||
|
- key: client-key.pem
|
||||||
|
path: client-key.pem
|
||||||
|
- key: client-cert.pem
|
||||||
|
path: client-cert.pem
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: aden-hive
|
||||||
|
labels:
|
||||||
|
app: aden-hive
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3001
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: aden-hive
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: production
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
- namespace.yaml
|
||||||
|
|
||||||
|
namePrefix: prod-
|
||||||
|
|
||||||
|
commonLabels:
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: aden-hive
|
||||||
|
newName: gcr.io/tool-for-analyst/aden-hive
|
||||||
|
newTag: latest
|
||||||
|
|
||||||
|
patches:
|
||||||
|
- path: patches/deployment.yaml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: production
|
||||||
|
labels:
|
||||||
|
environment: production
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: aden-hive
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: aden-hive
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: production
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: aden-api-server-config
|
||||||
|
- secretRef:
|
||||||
|
name: aden-api-server-secrets
|
||||||
|
- secretRef:
|
||||||
|
name: database-secrets
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: staging
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
- namespace.yaml
|
||||||
|
|
||||||
|
namePrefix: staging-
|
||||||
|
|
||||||
|
commonLabels:
|
||||||
|
environment: staging
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: aden-hive
|
||||||
|
newName: gcr.io/acho-alpha-project/aden-hive
|
||||||
|
newTag: latest
|
||||||
|
|
||||||
|
patches:
|
||||||
|
- path: patches/deployment.yaml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: staging
|
||||||
|
labels:
|
||||||
|
environment: staging
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: aden-hive
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: aden-hive
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: staging
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: aden-api-server-config
|
||||||
|
- secretRef:
|
||||||
|
name: aden-api-server-secrets
|
||||||
|
- secretRef:
|
||||||
|
name: database-secrets
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "hive",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Aden Hive - LLM observability and control plane backend",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"build": "tsc && npm run build:copy-sql",
|
||||||
|
"build:copy-sql": "find src -name '*.sql' -exec sh -c 'mkdir -p dist/$(dirname ${1#src/}) && cp \"$1\" dist/${1#src/}' _ {} \\;",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:mcp": "ts-node --transpile-only scripts/test-mcp.ts",
|
||||||
|
"test:mcp:quick": "./scripts/test-mcp-curl.sh",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@acho-inc/administration": "^1.0.7",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
|
"@socket.io/redis-adapter": "^8.2.1",
|
||||||
|
"@socket.io/redis-emitter": "^5.1.0",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongodb": "^6.3.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"socket.io": "^4.6.1",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/passport": "^1.0.16",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add agent_name column to llm_events table
|
||||||
|
*
|
||||||
|
* This script adds the `agent_name` column to all existing team schemas.
|
||||||
|
* Run with: npx ts-node scripts/migrate-add-agent-name.ts
|
||||||
|
*
|
||||||
|
* Environment variables required:
|
||||||
|
* - PGHOST, PGUSER, PGPASSWORD, PGDATABASE, PGPORT (or PG_CONNECTION_STRING)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const getPool = (): Pool => {
|
||||||
|
// Support multiple env var names
|
||||||
|
const connectionString =
|
||||||
|
process.env.TSDB_PG_URL ||
|
||||||
|
process.env.PG_CONNECTION_STRING ||
|
||||||
|
process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (connectionString) {
|
||||||
|
return new Pool({ connectionString });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Pool({
|
||||||
|
host: process.env.PGHOST || "localhost",
|
||||||
|
user: process.env.PGUSER || "postgres",
|
||||||
|
password: process.env.PGPASSWORD || "postgres",
|
||||||
|
database: process.env.PGDATABASE || "aden",
|
||||||
|
port: parseInt(process.env.PGPORT || "5432", 10),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[Migration] Starting agent_name column migration...");
|
||||||
|
|
||||||
|
// Find all team schemas (schemas starting with 'team_')
|
||||||
|
const schemasResult = await pool.query(`
|
||||||
|
SELECT schema_name
|
||||||
|
FROM information_schema.schemata
|
||||||
|
WHERE schema_name LIKE 'team_%'
|
||||||
|
ORDER BY schema_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const schemas = schemasResult.rows.map((r) => r.schema_name as string);
|
||||||
|
console.log(`[Migration] Found ${schemas.length} team schemas`);
|
||||||
|
|
||||||
|
if (schemas.length === 0) {
|
||||||
|
console.log("[Migration] No team schemas found. Nothing to migrate.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let skipCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const schema of schemas) {
|
||||||
|
try {
|
||||||
|
// Check if llm_events table exists in this schema
|
||||||
|
const tableExists = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_name = 'llm_events'
|
||||||
|
`,
|
||||||
|
[schema]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableExists.rows.length === 0) {
|
||||||
|
console.log(`[Migration] ${schema}: No llm_events table, skipping`);
|
||||||
|
skipCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent_name column already exists
|
||||||
|
const columnExists = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1
|
||||||
|
AND table_name = 'llm_events'
|
||||||
|
AND column_name = 'agent_name'
|
||||||
|
`,
|
||||||
|
[schema]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnExists.rows.length > 0) {
|
||||||
|
console.log(`[Migration] ${schema}: agent_name column already exists, skipping`);
|
||||||
|
skipCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the agent_name column after agent column
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE ${schema}.llm_events
|
||||||
|
ADD COLUMN agent_name text
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(`[Migration] ${schema}: Added agent_name column`);
|
||||||
|
successCount++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Migration] ${schema}: Error - ${(err as Error).message}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n[Migration] Summary:");
|
||||||
|
console.log(` - Schemas processed: ${schemas.length}`);
|
||||||
|
console.log(` - Successfully migrated: ${successCount}`);
|
||||||
|
console.log(` - Skipped (already migrated or no table): ${skipCount}`);
|
||||||
|
console.log(` - Errors: ${errorCount}`);
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
console.log("\n[Migration] Completed successfully!");
|
||||||
|
} else {
|
||||||
|
console.log("\n[Migration] Completed with errors. Please review above.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Migration] Fatal error:", (err as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Quick MCP Server Test using curl
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ADEN_AUTH_TOKEN=your-jwt-token ./scripts/test-mcp-curl.sh
|
||||||
|
#
|
||||||
|
# The script tests basic connectivity and endpoints.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
API_URL="${ADEN_API_URL:-http://localhost:3000}"
|
||||||
|
TOKEN="${ADEN_AUTH_TOKEN}"
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Error: ADEN_AUTH_TOKEN environment variable is required"
|
||||||
|
echo "Usage: ADEN_AUTH_TOKEN=your-jwt-token ./scripts/test-mcp-curl.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo "MCP Server Quick Test"
|
||||||
|
echo "============================================================"
|
||||||
|
echo "API URL: $API_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 1: Health check
|
||||||
|
echo "1. Health Check (GET /mcp/health)"
|
||||||
|
curl -s "$API_URL/mcp/health" | jq .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 2: List sessions (should be empty or show existing)
|
||||||
|
echo "2. List Sessions (GET /mcp/sessions)"
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$API_URL/mcp/sessions" | jq .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 3: Start SSE connection and capture session ID
|
||||||
|
echo "3. Testing SSE Connection (GET /mcp)"
|
||||||
|
echo " Starting connection (will timeout after 2s)..."
|
||||||
|
|
||||||
|
# Use timeout to limit the SSE connection
|
||||||
|
SESSION_ID=$(timeout 2s curl -s -N \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: text/event-stream" \
|
||||||
|
"$API_URL/mcp" 2>&1 | head -5 || true)
|
||||||
|
|
||||||
|
echo " Response (first 5 lines):"
|
||||||
|
echo "$SESSION_ID" | head -5
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 4: Check sessions again
|
||||||
|
echo "4. Sessions After Connection (GET /mcp/sessions)"
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$API_URL/mcp/sessions" | jq .
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo "Quick test completed!"
|
||||||
|
echo ""
|
||||||
|
echo "For full tool testing, use the TypeScript test client:"
|
||||||
|
echo " ADEN_AUTH_TOKEN=\$TOKEN npx ts-node scripts/test-mcp.ts"
|
||||||
|
echo "============================================================"
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* MCP Server Test Script
|
||||||
|
*
|
||||||
|
* Tests the MCP server by connecting via HTTP/SSE and invoking tools.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node scripts/test-mcp.ts
|
||||||
|
*
|
||||||
|
* Environment:
|
||||||
|
* ADEN_API_URL - Base URL (default: http://localhost:3000)
|
||||||
|
* ADEN_AUTH_TOKEN - JWT token for authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
|
|
||||||
|
const API_URL = process.env.ADEN_API_URL || "http://localhost:3000";
|
||||||
|
const AUTH_TOKEN = process.env.ADEN_AUTH_TOKEN;
|
||||||
|
|
||||||
|
if (!AUTH_TOKEN) {
|
||||||
|
console.error("Error: ADEN_AUTH_TOKEN environment variable is required");
|
||||||
|
console.error("Usage: ADEN_AUTH_TOKEN=your-jwt-token npx ts-node scripts/test-mcp.ts");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("MCP Server Test");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log(`API URL: ${API_URL}`);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// Create MCP client
|
||||||
|
const client = new Client({
|
||||||
|
name: "mcp-test-client",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create SSE transport with auth headers
|
||||||
|
const transport = new SSEClientTransport(new URL(`${API_URL}/mcp`), {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${AUTH_TOKEN}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to MCP server
|
||||||
|
console.log("Connecting to MCP server...");
|
||||||
|
await client.connect(transport);
|
||||||
|
console.log("✓ Connected successfully\n");
|
||||||
|
|
||||||
|
// List available tools
|
||||||
|
console.log("Listing available tools...");
|
||||||
|
const tools = await client.listTools();
|
||||||
|
console.log(`✓ Found ${tools.tools.length} tools:\n`);
|
||||||
|
|
||||||
|
// Group tools by category
|
||||||
|
const categories: Record<string, string[]> = {
|
||||||
|
budget: [],
|
||||||
|
agents: [],
|
||||||
|
analytics: [],
|
||||||
|
policies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tool of tools.tools) {
|
||||||
|
if (tool.name.includes("budget")) {
|
||||||
|
categories.budget.push(tool.name);
|
||||||
|
} else if (tool.name.includes("agent")) {
|
||||||
|
categories.agents.push(tool.name);
|
||||||
|
} else if (
|
||||||
|
tool.name.includes("analytics") ||
|
||||||
|
tool.name.includes("insights") ||
|
||||||
|
tool.name.includes("metrics") ||
|
||||||
|
tool.name.includes("logs")
|
||||||
|
) {
|
||||||
|
categories.analytics.push(tool.name);
|
||||||
|
} else if (tool.name.includes("polic")) {
|
||||||
|
categories.policies.push(tool.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [category, toolNames] of Object.entries(categories)) {
|
||||||
|
console.log(` ${category.toUpperCase()} (${toolNames.length}):`);
|
||||||
|
for (const name of toolNames) {
|
||||||
|
console.log(` - ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// Run test scenarios
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("Running Test Scenarios");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// Test 1: Get policy
|
||||||
|
await runTest(client, "hive_policy_get", { policyId: "default" }, "Get default policy");
|
||||||
|
|
||||||
|
// Test 2: List agents
|
||||||
|
await runTest(client, "hive_agents_summary", {}, "Get agent fleet summary");
|
||||||
|
|
||||||
|
// Test 3: Get insights
|
||||||
|
await runTest(client, "hive_insights", { days: 7 }, "Get 7-day insights");
|
||||||
|
|
||||||
|
// Test 4: Get metrics
|
||||||
|
await runTest(client, "hive_metrics", { days: 30 }, "Get 30-day metrics");
|
||||||
|
|
||||||
|
// Test 5: Budget validation (dry run)
|
||||||
|
await runTest(
|
||||||
|
client,
|
||||||
|
"hive_budget_validate",
|
||||||
|
{
|
||||||
|
estimatedCost: 0.01,
|
||||||
|
context: { agent: "test-agent" },
|
||||||
|
},
|
||||||
|
"Validate budget (dry run)"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("All tests completed!");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(
|
||||||
|
client: Client,
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
console.log(`Test: ${description}`);
|
||||||
|
console.log(` Tool: ${toolName}`);
|
||||||
|
console.log(` Args: ${JSON.stringify(args)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await client.callTool({ name: toolName, arguments: args });
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
console.log(` Status: ✓ Success (${duration}ms)`);
|
||||||
|
|
||||||
|
// Parse and display result
|
||||||
|
if (result.content && result.content.length > 0) {
|
||||||
|
const textContent = result.content.find((c) => c.type === "text");
|
||||||
|
if (textContent && "text" in textContent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(textContent.text);
|
||||||
|
console.log(` Result: ${JSON.stringify(parsed, null, 2).split("\n").slice(0, 10).join("\n")}`);
|
||||||
|
if (JSON.stringify(parsed, null, 2).split("\n").length > 10) {
|
||||||
|
console.log(" ... (truncated)");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(` Result: ${textContent.text.slice(0, 200)}...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
console.log(` Warning: Tool returned isError=true`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Status: ✗ Failed`);
|
||||||
|
console.log(` Error: ${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Express App Configuration
|
||||||
|
*
|
||||||
|
* Sets up Express with middleware and routes.
|
||||||
|
* No global state - uses dependency injection.
|
||||||
|
* Supports both MySQL (production) and PostgreSQL (local development) for user auth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
|
import cors from 'cors';
|
||||||
|
import passport from 'passport';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
import { auth, database, models } from '@acho-inc/administration';
|
||||||
|
import config from './config';
|
||||||
|
import routes from './routes';
|
||||||
|
import { errorHandler } from './middleware/error-handler.middleware';
|
||||||
|
import { createMcpRouter } from './mcp';
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Middleware
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
app.use(compression({
|
||||||
|
filter: (req, res) => {
|
||||||
|
// Don't compress SSE responses - compression breaks streaming
|
||||||
|
if (req.headers.accept === 'text/event-stream' ||
|
||||||
|
req.path.endsWith('/stream')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return compression.filter(req, res);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Skip body parsing for MCP message route (SDK's handlePostMessage reads raw body stream)
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path === '/mcp/message') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
express.json({ limit: '10mb' })(req, res, next);
|
||||||
|
});
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path === '/mcp/message') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
express.urlencoded({ extended: true })(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable x-powered-by header
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Database Connections
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
let userDbService: ReturnType<typeof models.createUserDbService>;
|
||||||
|
|
||||||
|
if (config.userDbType === 'postgres') {
|
||||||
|
// PostgreSQL for local development
|
||||||
|
console.log('[App] Using PostgreSQL for user authentication');
|
||||||
|
|
||||||
|
const pgPool = new Pool({
|
||||||
|
connectionString: config.userDb.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
userDbService = models.createUserDbService({
|
||||||
|
pgPool,
|
||||||
|
dbType: 'postgres',
|
||||||
|
tables: {
|
||||||
|
USER: 'users',
|
||||||
|
DEVELOPERS: 'developers',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.locals.pgPool = pgPool;
|
||||||
|
} else {
|
||||||
|
// MySQL for production
|
||||||
|
console.log('[App] Using MySQL for user authentication');
|
||||||
|
|
||||||
|
const mysqlPool = database.createMySQLPool(config.mysql);
|
||||||
|
|
||||||
|
userDbService = models.createUserDbService({
|
||||||
|
mysqlPool,
|
||||||
|
tables: {
|
||||||
|
USER: 'user',
|
||||||
|
DEVELOPERS: 'developers',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.locals.mysqlPool = mysqlPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user service in app.locals for access in routes
|
||||||
|
app.locals.userDbService = userDbService;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Passport Authentication
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const passportStrategy = auth.createPassportStrategy({
|
||||||
|
findSaltByToken: userDbService.findSaltByToken,
|
||||||
|
jwtSecret: config.jwt.secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.use(passportStrategy);
|
||||||
|
app.use(passport.initialize());
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Routes
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Health check (unauthenticated)
|
||||||
|
app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'aden-hive',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
userDbType: config.userDbType,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/', routes);
|
||||||
|
|
||||||
|
// MCP Server routes (Model Context Protocol)
|
||||||
|
// The controlEmitter is set in index.ts after WebSocket initialization
|
||||||
|
const mcpRouter = createMcpRouter(() => app.locals.controlEmitter);
|
||||||
|
app.use('/mcp', mcpRouter);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Error Handling
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req: Request, res: Response) => {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'not_found',
|
||||||
|
message: `Route ${req.method} ${req.path} not found`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Configuration Module
|
||||||
|
*
|
||||||
|
* Centralizes all configuration loading and validation.
|
||||||
|
* Supports both MySQL (production) and PostgreSQL (local development) for user database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to safely read SSL certificates
|
||||||
|
* @param {string} envKey - Environment variable containing cert path
|
||||||
|
* @param {string} fallbackPath - Fallback path if env var not set
|
||||||
|
* @returns {Buffer|null} Certificate content or null
|
||||||
|
*/
|
||||||
|
function readCertificate(envKey: string, fallbackPath: string): Buffer | null {
|
||||||
|
const certPath = process.env[envKey];
|
||||||
|
if (certPath && fs.existsSync(certPath)) {
|
||||||
|
return fs.readFileSync(certPath);
|
||||||
|
}
|
||||||
|
if (fallbackPath && fs.existsSync(fallbackPath)) {
|
||||||
|
return fs.readFileSync(fallbackPath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load MySQL SSL certificates from environment or default paths
|
||||||
|
* @returns {Object|null} SSL config object or null if certs not found
|
||||||
|
*/
|
||||||
|
function loadMySQLSSL(): { ca: Buffer; key: Buffer; cert: Buffer } | null {
|
||||||
|
const ca = readCertificate('MYSQL_SSL_CA', '/mnt/certs/mysql/server-ca.pem');
|
||||||
|
const key = readCertificate('MYSQL_SSL_KEY', '/mnt/certs/mysql/client-key.pem');
|
||||||
|
const cert = readCertificate('MYSQL_SSL_CERT', '/mnt/certs/mysql/client-cert.pem');
|
||||||
|
|
||||||
|
return ca && key && cert ? { ca, key, cert } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which database type to use for user authentication
|
||||||
|
* Priority: USER_DB_TYPE env var > MySQL if configured > PostgreSQL fallback
|
||||||
|
*/
|
||||||
|
function getUserDbType(): 'mysql' | 'postgres' {
|
||||||
|
const explicit = process.env.USER_DB_TYPE?.toLowerCase();
|
||||||
|
if (explicit === 'mysql' || explicit === 'postgres') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
// Default to MySQL if MySQL host is configured, otherwise use PostgreSQL
|
||||||
|
return process.env.MYSQL_HOST ? 'mysql' : 'postgres';
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
// Server
|
||||||
|
port: parseInt(process.env.PORT as string, 10) || 4000,
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
|
||||||
|
// TSDB PostgreSQL (metrics storage)
|
||||||
|
tsdb: {
|
||||||
|
url: process.env.TSDB_PG_URL,
|
||||||
|
},
|
||||||
|
|
||||||
|
// User Database Type ('mysql' or 'postgres')
|
||||||
|
userDbType: getUserDbType(),
|
||||||
|
|
||||||
|
// User Database (MySQL) - for production
|
||||||
|
mysql: {
|
||||||
|
host: process.env.MYSQL_HOST,
|
||||||
|
port: parseInt(process.env.MYSQL_PORT as string, 10) || 3306,
|
||||||
|
user: process.env.MYSQL_USER,
|
||||||
|
password: process.env.MYSQL_PASSWORD,
|
||||||
|
database: process.env.MYSQL_DATABASE,
|
||||||
|
ssl: loadMySQLSSL(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// User Database (PostgreSQL) - for local development
|
||||||
|
// Defaults to same DB as TSDB if not specified
|
||||||
|
userDb: {
|
||||||
|
url: process.env.USER_DB_PG_URL || process.env.TSDB_PG_URL,
|
||||||
|
},
|
||||||
|
|
||||||
|
// MongoDB
|
||||||
|
mongodb: {
|
||||||
|
url: process.env.MONGODB_URL,
|
||||||
|
dbName: process.env.MONGODB_DBNAME || 'aden',
|
||||||
|
erpDbName: process.env.MONGODB_ERP_DBNAME || 'erp',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
redis: {
|
||||||
|
url: process.env.REDIS_URL,
|
||||||
|
},
|
||||||
|
|
||||||
|
// JWT
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
|
passphrase: process.env.PASSPHRASE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates required configuration
|
||||||
|
* @throws {Error} If required config is missing
|
||||||
|
*/
|
||||||
|
function validateConfig(): void {
|
||||||
|
const required: [string, string | undefined][] = [
|
||||||
|
['TSDB_PG_URL', config.tsdb.url],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add database-specific requirements
|
||||||
|
if (config.userDbType === 'mysql') {
|
||||||
|
required.push(
|
||||||
|
['MYSQL_HOST', config.mysql.host],
|
||||||
|
['MYSQL_USER', config.mysql.user],
|
||||||
|
['MYSQL_DATABASE', config.mysql.database],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
required.push(['USER_DB_PG_URL or TSDB_PG_URL', config.userDb.url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = required.filter(([name, value]) => !value);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const names = missing.map(([name]) => name).join(', ');
|
||||||
|
console.warn(`[Config] Warning: Missing environment variables: ${names}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Config] User database type: ${config.userDbType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate on load
|
||||||
|
validateConfig();
|
||||||
|
|
||||||
|
export default config;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* IAM Controller
|
||||||
|
*
|
||||||
|
* Handles Identity and Access Management endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract token from Authorization header
|
||||||
|
* Supports: "jwt <token>", "Bearer <token>", or raw "<token>"
|
||||||
|
*/
|
||||||
|
function extractToken(authHeader: string): string {
|
||||||
|
if (authHeader.startsWith('jwt ')) {
|
||||||
|
return authHeader.slice(4);
|
||||||
|
}
|
||||||
|
if (authHeader.startsWith('Bearer ')) {
|
||||||
|
return authHeader.slice(7);
|
||||||
|
}
|
||||||
|
return authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /iam/get-current-team
|
||||||
|
*
|
||||||
|
* Get the current team/organization for the authenticated user.
|
||||||
|
*/
|
||||||
|
router.get('/get-current-team', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: 'No token provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: 'Invalid token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pgPool = req.app.locals.pgPool;
|
||||||
|
if (!pgPool) {
|
||||||
|
// Return default team if no database
|
||||||
|
return res.json({
|
||||||
|
orgId: user.current_team_id || 1,
|
||||||
|
orgName: 'Default Organization',
|
||||||
|
teamId: user.current_team_id || 1,
|
||||||
|
teamName: 'Default Team',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team info from database
|
||||||
|
const result = await pgPool.query(
|
||||||
|
`SELECT id, name, slug FROM teams WHERE id = $1`,
|
||||||
|
[user.current_team_id || 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const team = result.rows[0];
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
// Return default if team not found
|
||||||
|
return res.json({
|
||||||
|
orgId: user.current_team_id || 1,
|
||||||
|
orgName: 'Default Organization',
|
||||||
|
teamId: user.current_team_id || 1,
|
||||||
|
teamName: 'Default Team',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
orgId: team.id,
|
||||||
|
orgName: team.name,
|
||||||
|
teamId: team.id,
|
||||||
|
teamName: team.name,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[IAMController] /get-current-team error:', err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: 'Failed to get current team',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /iam/team/get-team-role-by-id/:teamId
|
||||||
|
*
|
||||||
|
* Get the user's role in a specific team.
|
||||||
|
*/
|
||||||
|
router.get('/team/get-team-role-by-id/:teamId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: 'No token provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: 'Invalid token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamId = parseInt(req.params.teamId, 10);
|
||||||
|
|
||||||
|
const pgPool = req.app.locals.pgPool;
|
||||||
|
if (!pgPool) {
|
||||||
|
// Return default role if no database
|
||||||
|
return res.json({ roleId: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's role in this team
|
||||||
|
const result = await pgPool.query(
|
||||||
|
`SELECT role FROM team_members WHERE user_id = $1 AND team_id = $2`,
|
||||||
|
[user.id, teamId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const membership = result.rows[0];
|
||||||
|
|
||||||
|
// Map role name to roleId (admin=1, member=2, viewer=3)
|
||||||
|
const roleMap: Record<string, number> = {
|
||||||
|
admin: 1,
|
||||||
|
member: 2,
|
||||||
|
viewer: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleId = membership ? (roleMap[membership.role] || 2) : 2;
|
||||||
|
|
||||||
|
res.json({ roleId });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[IAMController] /team/get-team-role-by-id error:', err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: 'Failed to get team role',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Quickstart Documentation API Controller
|
||||||
|
* Generates SDK quickstart documentation based on agent framework
|
||||||
|
*/
|
||||||
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
|
import passport from "passport";
|
||||||
|
// Passport is initialized in app.js
|
||||||
|
|
||||||
|
import * as quickstartService from "../services/quickstart/quickstart_service";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
interface AuthenticatedUser {
|
||||||
|
id: number;
|
||||||
|
current_team_id: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /quickstart/options:
|
||||||
|
* get:
|
||||||
|
* summary: Get available options for quickstart generation
|
||||||
|
* tags:
|
||||||
|
* - Quickstart
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Available options for quickstart document generation
|
||||||
|
*/
|
||||||
|
router.get("/options", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const options = quickstartService.getQuickstartOptions();
|
||||||
|
res.send(options);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /quickstart/generate:
|
||||||
|
* post:
|
||||||
|
* summary: Generate quickstart documentation with user's system token
|
||||||
|
* tags:
|
||||||
|
* - Quickstart
|
||||||
|
* security:
|
||||||
|
* - jwtAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - agentFramework
|
||||||
|
* properties:
|
||||||
|
* agentFramework:
|
||||||
|
* type: string
|
||||||
|
* enum: [generic, langgraph, livekit]
|
||||||
|
* description: The agent framework to use
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Generated quickstart documentation
|
||||||
|
* 400:
|
||||||
|
* description: Invalid parameters
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized - JWT token required
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/generate",
|
||||||
|
passport.authenticate("jwt", { session: false }),
|
||||||
|
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { user, body } = req;
|
||||||
|
const { agentFramework, llmVendor, sdkLanguage } = body;
|
||||||
|
|
||||||
|
// Get the user's latest non-system API key
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const tokenObj = user ? await userDbService.getLatestUserDevToken(user) : null;
|
||||||
|
|
||||||
|
let apiKey: string;
|
||||||
|
let tokenName: string;
|
||||||
|
if (tokenObj) {
|
||||||
|
apiKey = tokenObj.token;
|
||||||
|
tokenName = tokenObj.label;
|
||||||
|
} else {
|
||||||
|
// No user API key - use placeholder
|
||||||
|
apiKey = "eyJ-xxx";
|
||||||
|
tokenName = "No Key";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the quickstart document
|
||||||
|
const markdown = quickstartService.generateQuickstart({
|
||||||
|
agentFramework,
|
||||||
|
llmVendor,
|
||||||
|
sdkLanguage,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
markdown,
|
||||||
|
metadata: {
|
||||||
|
agentFramework,
|
||||||
|
llmVendor,
|
||||||
|
sdkLanguage,
|
||||||
|
tokenName,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).message.includes("Invalid")) {
|
||||||
|
return res.status(400).send({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /quickstart/generate-with-key:
|
||||||
|
* post:
|
||||||
|
* summary: Generate quickstart documentation with a provided API key
|
||||||
|
* description: Generate documentation without requiring authentication - API key is provided directly
|
||||||
|
* tags:
|
||||||
|
* - Quickstart
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - agentFramework
|
||||||
|
* - apiKey
|
||||||
|
* properties:
|
||||||
|
* agentFramework:
|
||||||
|
* type: string
|
||||||
|
* enum: [generic, livekit]
|
||||||
|
* apiKey:
|
||||||
|
* type: string
|
||||||
|
* description: The Aden API key to embed in the documentation
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Generated quickstart documentation
|
||||||
|
* 400:
|
||||||
|
* description: Invalid parameters
|
||||||
|
*/
|
||||||
|
router.post("/generate-with-key", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { agentFramework, llmVendor, sdkLanguage, apiKey } = req.body;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: "API key is required",
|
||||||
|
message: "Please provide an apiKey in the request body",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the quickstart document
|
||||||
|
const markdown = quickstartService.generateQuickstart({
|
||||||
|
agentFramework,
|
||||||
|
llmVendor,
|
||||||
|
sdkLanguage,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
markdown,
|
||||||
|
metadata: {
|
||||||
|
agentFramework,
|
||||||
|
llmVendor,
|
||||||
|
sdkLanguage,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
(error as Error).message.includes("Invalid") ||
|
||||||
|
(error as Error).message.includes("required")
|
||||||
|
) {
|
||||||
|
return res.status(400).send({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* User Controller
|
||||||
|
*
|
||||||
|
* Handles user authentication endpoints including login-v2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from "express";
|
||||||
|
import config from "../config";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract token from Authorization header
|
||||||
|
* Supports: "jwt <token>", "Bearer <token>", or raw "<token>"
|
||||||
|
*/
|
||||||
|
function extractToken(authHeader: string): string {
|
||||||
|
if (authHeader.startsWith("jwt ")) {
|
||||||
|
return authHeader.slice(4);
|
||||||
|
}
|
||||||
|
if (authHeader.startsWith("Bearer ")) {
|
||||||
|
return authHeader.slice(7);
|
||||||
|
}
|
||||||
|
return authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation regex
|
||||||
|
const EMAIL_REGEX =
|
||||||
|
/[\w!#$%&'*+/=?^_`{|}~-]+(?:\.[\w!#$%&'*+/=?^_`{|}~-]+)*@(?:[\w](?:[\w-]*[\w])?\.)+[\w](?:[\w-]*[\w])?/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /user/login-v2
|
||||||
|
*
|
||||||
|
* Authenticate a user with email and password.
|
||||||
|
* Returns a JWT token on success.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/login-v2",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
let { email, password } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (
|
||||||
|
!email ||
|
||||||
|
typeof email !== "string" ||
|
||||||
|
!password ||
|
||||||
|
typeof password !== "string"
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Email and password are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!EMAIL_REGEX.test(email)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Please enter a valid email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim email
|
||||||
|
email = email.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Password must be at least 6 characters",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get userDbService from app.locals
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
if (!userDbService) {
|
||||||
|
console.error("[UserController] userDbService not found in app.locals");
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt login
|
||||||
|
const result = await userDbService.login(email, password, {
|
||||||
|
jwtSecret: config.jwt.secret,
|
||||||
|
expiresIn: config.jwt.expiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[UserController] login-v2: User ${email} logged in successfully`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token: result.token,
|
||||||
|
email: result.email,
|
||||||
|
firstname: result.firstname,
|
||||||
|
lastname: result.lastname,
|
||||||
|
name: result.name,
|
||||||
|
current_team_id: result.current_team_id,
|
||||||
|
create_time: result.created_at,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] login-v2 error:", err.message);
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if (err.code === "USER_NOT_FOUND" || err.code === "INVALID_CREDENTIALS") {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Invalid email or password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.code === "OAUTH_REQUIRED") {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.code === "ACCOUNT_DISABLED") {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Your account has been disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Login failed. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /user/register
|
||||||
|
*
|
||||||
|
* Register a new user account.
|
||||||
|
* Returns a JWT token on success.
|
||||||
|
*/
|
||||||
|
router.post("/register", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
let { email, password, name, firstname, lastname } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (
|
||||||
|
!email ||
|
||||||
|
typeof email !== "string" ||
|
||||||
|
!password ||
|
||||||
|
typeof password !== "string"
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Email and password are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!EMAIL_REGEX.test(email)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Please enter a valid email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim and lowercase email
|
||||||
|
email = email.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (password.length < 8) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Password must be at least 8 characters",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get userDbService from app.locals
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
if (!userDbService) {
|
||||||
|
console.error("[UserController] userDbService not found in app.locals");
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Internal server error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt registration
|
||||||
|
const result = await userDbService.register(
|
||||||
|
{ email, password, name, firstname, lastname },
|
||||||
|
{
|
||||||
|
jwtSecret: config.jwt.secret,
|
||||||
|
expiresIn: config.jwt.expiresIn,
|
||||||
|
defaultTeamId: 1, // Default to team 1 for local dev
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[UserController] register: User ${email} registered successfully`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
token: result.token,
|
||||||
|
email: result.email,
|
||||||
|
name: result.name,
|
||||||
|
firstname: result.firstname,
|
||||||
|
lastname: result.lastname,
|
||||||
|
current_team_id: result.current_team_id,
|
||||||
|
create_time: result.created_at,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] register error:", err.message);
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if (err.code === "EMAIL_EXISTS") {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Email already registered",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Registration failed. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /user/profile
|
||||||
|
*
|
||||||
|
* Get current user profile.
|
||||||
|
* Requires authentication.
|
||||||
|
*/
|
||||||
|
router.get("/profile", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "No token provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Invalid token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return in format expected by frontend
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
firstname: user.firstname || "",
|
||||||
|
lastname: user.lastname || "",
|
||||||
|
email: user.email,
|
||||||
|
company_name: user.company_name || null,
|
||||||
|
profile_img_url: user.avatar_url || null,
|
||||||
|
roleId: user.role_id || 1,
|
||||||
|
user_id: String(user.id),
|
||||||
|
team_id: String(user.current_team_id || 1),
|
||||||
|
roles: user.roles || ["user"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] /profile error:", err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Failed to get user profile",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /user/profile
|
||||||
|
*
|
||||||
|
* Update current user profile.
|
||||||
|
* Requires authentication.
|
||||||
|
*/
|
||||||
|
router.put("/profile", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "No token provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Invalid token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstname, lastname } = req.body;
|
||||||
|
|
||||||
|
// Update user profile (basic implementation)
|
||||||
|
if (userDbService.updateProfile) {
|
||||||
|
await userDbService.updateProfile(user.id, { firstname, lastname });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Profile updated successfully" });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] PUT /profile error:", err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Failed to update profile",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /user/me
|
||||||
|
*
|
||||||
|
* Get current user info from token.
|
||||||
|
* Requires authentication.
|
||||||
|
*/
|
||||||
|
router.get("/me", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "No token provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Invalid token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
current_team_id: user.current_team_id,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] /me error:", err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Failed to get user info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /user/get-dev-tokens
|
||||||
|
*
|
||||||
|
* Get all developer API tokens for the current user.
|
||||||
|
* Requires authentication.
|
||||||
|
*/
|
||||||
|
router.get("/get-dev-tokens", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "No token provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Invalid token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await userDbService.getDevTokens(user);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: tokens,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] /get-dev-tokens error:", err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Failed to get API tokens",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /user/generate-dev-token
|
||||||
|
*
|
||||||
|
* Generate a new developer API token.
|
||||||
|
* Requires authentication.
|
||||||
|
*/
|
||||||
|
router.post("/generate-dev-token", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "No token provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDbService = req.app.locals.userDbService;
|
||||||
|
const user = await userDbService.findByToken(extractToken(authHeader));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Invalid token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, ttl } = req.body;
|
||||||
|
|
||||||
|
const tokenResult = await userDbService.generateDevToken(user, {
|
||||||
|
label,
|
||||||
|
ttl,
|
||||||
|
jwtSecret: config.jwt.secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[UserController] generate-dev-token: Created token for user ${user.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: tokenResult,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[UserController] /generate-dev-token error:", err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
msg: "Failed to generate API token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Aden Hive - DevTool Backend Entry Point
|
||||||
|
*
|
||||||
|
* LLM observability and control plane service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
import http from "http";
|
||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
import app from "./app";
|
||||||
|
import config from "./config";
|
||||||
|
import { initializeSockets, setUserDbService } from "./sockets/control.socket";
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 4000;
|
||||||
|
|
||||||
|
// Declare globals for MongoDB (used by services)
|
||||||
|
declare global {
|
||||||
|
var _ACHO_MG_DB: MongoClient;
|
||||||
|
var _ACHO_MDB_CONFIG: { ERP_DBNAME: string; DBNAME: string };
|
||||||
|
var _ACHO_MDB_COLLECTIONS: {
|
||||||
|
ADEN_CONTROL_POLICIES: string;
|
||||||
|
ADEN_CONTROL_CONTENT: string;
|
||||||
|
LLM_PRICING: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize MongoDB connection
|
||||||
|
*/
|
||||||
|
async function initMongoDB(): Promise<void> {
|
||||||
|
if (!config.mongodb.url) {
|
||||||
|
console.warn(
|
||||||
|
"[MongoDB] No MONGODB_URL configured, skipping MongoDB initialization"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new MongoClient(config.mongodb.url);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Set global MongoDB client and config
|
||||||
|
global._ACHO_MG_DB = client;
|
||||||
|
global._ACHO_MDB_CONFIG = {
|
||||||
|
ERP_DBNAME: config.mongodb.erpDbName,
|
||||||
|
DBNAME: config.mongodb.dbName,
|
||||||
|
};
|
||||||
|
global._ACHO_MDB_COLLECTIONS = {
|
||||||
|
ADEN_CONTROL_POLICIES: "aden_control_policies",
|
||||||
|
ADEN_CONTROL_CONTENT: "aden_control_content",
|
||||||
|
LLM_PRICING: "llm_pricing",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[MongoDB] Connected successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[MongoDB] Connection error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server
|
||||||
|
*/
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
// Initialize MongoDB
|
||||||
|
await initMongoDB();
|
||||||
|
|
||||||
|
// Pass userDbService to socket layer for JWT verification
|
||||||
|
if (app.locals.userDbService) {
|
||||||
|
setUserDbService(app.locals.userDbService, config.jwt.secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebSockets
|
||||||
|
const { controlEmitter } = await initializeSockets(server);
|
||||||
|
|
||||||
|
// Make control emitter available for policy updates
|
||||||
|
app.locals.controlEmitter = controlEmitter;
|
||||||
|
console.log("[Aden Hive] WebSocket initialized");
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[Aden Hive] Server running on port ${PORT}`);
|
||||||
|
console.log(
|
||||||
|
`[Aden Hive] Environment: ${process.env.NODE_ENV || "development"}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the application
|
||||||
|
start().catch((error) => {
|
||||||
|
console.error("[Aden Hive] Failed to start:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
console.log("[Aden Hive] SIGTERM received, shutting down gracefully");
|
||||||
|
server.close(() => {
|
||||||
|
console.log("[Aden Hive] Server closed");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
console.log("[Aden Hive] SIGINT received, shutting down gracefully");
|
||||||
|
server.close(() => {
|
||||||
|
console.log("[Aden Hive] Server closed");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default server;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Aden Hive MCP Server
|
||||||
|
*
|
||||||
|
* Model Context Protocol server for LLM governance.
|
||||||
|
* Exposes 19 tools:
|
||||||
|
*
|
||||||
|
* Budget Tools (6):
|
||||||
|
* - hive_budget_get, hive_budget_reset, hive_budget_validate
|
||||||
|
* - hive_budget_rule_create, hive_budget_rule_update, hive_budget_rule_delete
|
||||||
|
*
|
||||||
|
* Agent Status Tools (3):
|
||||||
|
* - hive_agents_list, hive_agent_health_check, hive_agents_summary
|
||||||
|
*
|
||||||
|
* Analytics Tools (5):
|
||||||
|
* - hive_analytics_wide, hive_analytics_narrow, hive_insights
|
||||||
|
* - hive_metrics, hive_logs
|
||||||
|
*
|
||||||
|
* Policy Tools (5):
|
||||||
|
* - hive_policies_list, hive_policy_get, hive_policy_create
|
||||||
|
* - hive_policy_update, hive_policy_clear
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { createMcpRouter } from './mcp';
|
||||||
|
* app.use('/mcp', createMcpRouter(getControlEmitter));
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Server creation
|
||||||
|
export { createHiveMcpServer, TOOL_CATALOG } from "./server";
|
||||||
|
export type { HiveMcpServerOptions } from "./server";
|
||||||
|
|
||||||
|
// HTTP transport
|
||||||
|
export {
|
||||||
|
createMcpRouter,
|
||||||
|
getActiveMcpSessionCount,
|
||||||
|
getTeamMcpSessions,
|
||||||
|
} from "./transport/http";
|
||||||
|
|
||||||
|
// API client for direct usage
|
||||||
|
export { createApiClient } from "./utils/api-client";
|
||||||
|
export type { ApiClient, ApiContext } from "./utils/api-client";
|
||||||
|
|
||||||
|
// Response helpers
|
||||||
|
export {
|
||||||
|
createSuccessResponse,
|
||||||
|
createErrorResponse,
|
||||||
|
handleToolError,
|
||||||
|
} from "./utils/response-helpers";
|
||||||
|
|
||||||
|
// Schema helpers
|
||||||
|
export {
|
||||||
|
idSchema,
|
||||||
|
dateSchema,
|
||||||
|
dateTimeSchema,
|
||||||
|
amountSchema,
|
||||||
|
budgetTypeSchema,
|
||||||
|
limitActionSchema,
|
||||||
|
analyticsWindowSchema,
|
||||||
|
validationContextSchema,
|
||||||
|
budgetAlertSchema,
|
||||||
|
budgetNotificationsSchema,
|
||||||
|
paginationSchema,
|
||||||
|
} from "./utils/schema-helpers";
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Aden Hive MCP Server
|
||||||
|
*
|
||||||
|
* MCP server with tools for:
|
||||||
|
* - Cost control (budget management)
|
||||||
|
* - Agent status (fleet monitoring)
|
||||||
|
* - Analytics (insights, metrics, logs)
|
||||||
|
* - Policy management
|
||||||
|
*/
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { createApiClient, type ApiContext } from "./utils/api-client";
|
||||||
|
import { registerBudgetTools } from "./tools/budget";
|
||||||
|
import { registerAgentTools, type ControlEmitter } from "./tools/agents";
|
||||||
|
import { registerAnalyticsTools } from "./tools/analytics";
|
||||||
|
import { registerPolicyTools } from "./tools/policies";
|
||||||
|
|
||||||
|
export interface HiveMcpServerOptions {
|
||||||
|
context: ApiContext;
|
||||||
|
getControlEmitter?: () => ControlEmitter | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and configure the Aden Hive MCP server
|
||||||
|
*/
|
||||||
|
export function createHiveMcpServer(options: HiveMcpServerOptions): McpServer {
|
||||||
|
const { context, getControlEmitter } = options;
|
||||||
|
|
||||||
|
// Create MCP server
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "aden-hive",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create API client bound to team context
|
||||||
|
const api = createApiClient(context);
|
||||||
|
|
||||||
|
// Register all tool categories
|
||||||
|
registerBudgetTools(server, api);
|
||||||
|
registerAgentTools(server, api, getControlEmitter || (() => undefined));
|
||||||
|
registerAnalyticsTools(server, api);
|
||||||
|
registerPolicyTools(server, api);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[MCP] Aden Hive server created with ${19} tools for team ${context.teamId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool categories and counts for reference
|
||||||
|
*/
|
||||||
|
export const TOOL_CATALOG = {
|
||||||
|
budget: {
|
||||||
|
count: 6,
|
||||||
|
tools: [
|
||||||
|
"hive_budget_get",
|
||||||
|
"hive_budget_reset",
|
||||||
|
"hive_budget_validate",
|
||||||
|
"hive_budget_rule_create",
|
||||||
|
"hive_budget_rule_update",
|
||||||
|
"hive_budget_rule_delete",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
count: 3,
|
||||||
|
tools: ["hive_agents_list", "hive_agent_health_check", "hive_agents_summary"],
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
count: 5,
|
||||||
|
tools: [
|
||||||
|
"hive_analytics_wide",
|
||||||
|
"hive_analytics_narrow",
|
||||||
|
"hive_insights",
|
||||||
|
"hive_metrics",
|
||||||
|
"hive_logs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
policies: {
|
||||||
|
count: 5,
|
||||||
|
tools: [
|
||||||
|
"hive_policies_list",
|
||||||
|
"hive_policy_get",
|
||||||
|
"hive_policy_create",
|
||||||
|
"hive_policy_update",
|
||||||
|
"hive_policy_clear",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 19,
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Agent Status MCP Tools
|
||||||
|
*
|
||||||
|
* Tools for monitoring connected SDK agent instances:
|
||||||
|
* - hive_agents_list: List all connected SDK instances
|
||||||
|
* - hive_agent_health_check: Check health of specific agent
|
||||||
|
* - hive_agents_summary: Get fleet health overview
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import type { ApiClient } from "../utils/api-client";
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
handleToolError,
|
||||||
|
} from "../utils/response-helpers";
|
||||||
|
|
||||||
|
export interface ControlEmitter {
|
||||||
|
getConnectedCount: (teamId: string) => number;
|
||||||
|
getConnectedInstances: (teamId: string) => Array<{
|
||||||
|
instance_id: string;
|
||||||
|
agent?: string;
|
||||||
|
policy_id?: string | null;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAgentTools(
|
||||||
|
server: McpServer,
|
||||||
|
api: ApiClient,
|
||||||
|
getControlEmitter: () => ControlEmitter | undefined
|
||||||
|
) {
|
||||||
|
// ==================== hive_agents_list ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_agents_list",
|
||||||
|
"Get list of all connected SDK agent instances with health status and connection details",
|
||||||
|
{
|
||||||
|
includeMetrics: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe("Include per-agent metrics (connection duration, heartbeat lag)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const controlEmitter = getControlEmitter();
|
||||||
|
const result = api.agents.getList(controlEmitter);
|
||||||
|
|
||||||
|
if (params.includeMetrics && result.instances) {
|
||||||
|
const now = Date.now();
|
||||||
|
const enrichedInstances = (result.instances as Array<{
|
||||||
|
instance_id: string;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
}>).map((instance) => {
|
||||||
|
const connectedAt = new Date(instance.connected_at).getTime();
|
||||||
|
const lastHeartbeat = new Date(instance.last_heartbeat).getTime();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...instance,
|
||||||
|
metrics: {
|
||||||
|
connection_duration_ms: now - connectedAt,
|
||||||
|
connection_duration_seconds: Math.round((now - connectedAt) / 1000),
|
||||||
|
heartbeat_lag_ms: now - lastHeartbeat,
|
||||||
|
heartbeat_lag_seconds: Math.round((now - lastHeartbeat) / 1000),
|
||||||
|
is_healthy: now - lastHeartbeat < 60000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
...result,
|
||||||
|
instances: enrichedInstances,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_agents_list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_agent_health_check ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_agent_health_check",
|
||||||
|
"Check health of a specific agent by instance ID or agent name. Returns health status, last heartbeat, and connection details.",
|
||||||
|
{
|
||||||
|
instanceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("SDK instance ID to check"),
|
||||||
|
agentName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Agent name to filter (returns all instances with this name)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
if (!params.instanceId && !params.agentName) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("Either instanceId or agentName is required"),
|
||||||
|
"hive_agent_health_check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlEmitter = getControlEmitter();
|
||||||
|
const result = api.agents.getList(controlEmitter);
|
||||||
|
|
||||||
|
if (!result.instances || result.instances.length === 0) {
|
||||||
|
return createSuccessResponse({
|
||||||
|
found: false,
|
||||||
|
message: "No agents connected",
|
||||||
|
query: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const STALE_THRESHOLD_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
|
// Filter instances based on query
|
||||||
|
const instances = (result.instances as Array<{
|
||||||
|
instance_id: string;
|
||||||
|
agent?: string;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
}>).filter((instance) => {
|
||||||
|
if (params.instanceId && instance.instance_id === params.instanceId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (params.agentName && instance.agent === params.agentName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (instances.length === 0) {
|
||||||
|
return createSuccessResponse({
|
||||||
|
found: false,
|
||||||
|
message: params.instanceId
|
||||||
|
? `Instance ${params.instanceId} not found`
|
||||||
|
: `No instances found for agent ${params.agentName}`,
|
||||||
|
query: params,
|
||||||
|
total_connected: result.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with health status
|
||||||
|
const healthResults = instances.map((instance) => {
|
||||||
|
const lastHeartbeat = new Date(instance.last_heartbeat).getTime();
|
||||||
|
const heartbeatLag = now - lastHeartbeat;
|
||||||
|
const isHealthy = heartbeatLag < STALE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
instance_id: instance.instance_id,
|
||||||
|
agent_name: instance.agent || "unknown",
|
||||||
|
status: isHealthy ? "healthy" : "unhealthy",
|
||||||
|
last_heartbeat: instance.last_heartbeat,
|
||||||
|
last_heartbeat_ago_seconds: Math.round(heartbeatLag / 1000),
|
||||||
|
connected_at: instance.connected_at,
|
||||||
|
connection_duration_seconds: Math.round(
|
||||||
|
(now - new Date(instance.connected_at).getTime()) / 1000
|
||||||
|
),
|
||||||
|
health_threshold_seconds: STALE_THRESHOLD_MS / 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
found: true,
|
||||||
|
count: healthResults.length,
|
||||||
|
instances: healthResults,
|
||||||
|
summary: {
|
||||||
|
healthy: healthResults.filter((h) => h.status === "healthy").length,
|
||||||
|
unhealthy: healthResults.filter((h) => h.status === "unhealthy").length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_agent_health_check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_agents_summary ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_agents_summary",
|
||||||
|
"Get summary of agent fleet health: total active, healthy count, unhealthy count, and breakdown by agent name",
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const controlEmitter = getControlEmitter();
|
||||||
|
const result = api.agents.getSummary(controlEmitter);
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_agents_summary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Analytics MCP Tools
|
||||||
|
*
|
||||||
|
* Tools for querying analytics and insights:
|
||||||
|
* - hive_analytics_wide: Dashboard analytics with daily resolution
|
||||||
|
* - hive_analytics_narrow: Hourly analytics for today
|
||||||
|
* - hive_insights: Actionable insights and anomalies
|
||||||
|
* - hive_metrics: Summary metrics with period-over-period change
|
||||||
|
* - hive_logs: Raw or aggregated event logs
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import type { ApiClient } from "../utils/api-client";
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
handleToolError,
|
||||||
|
} from "../utils/response-helpers";
|
||||||
|
import { analyticsWindowSchema, dateTimeSchema } from "../utils/schema-helpers";
|
||||||
|
|
||||||
|
export function registerAnalyticsTools(server: McpServer, api: ApiClient) {
|
||||||
|
// ==================== hive_analytics_wide ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_analytics_wide",
|
||||||
|
"Get dashboard analytics with daily resolution. Use for trend analysis over days/weeks/months. Returns volume, cost, tokens, and performance data points by day.",
|
||||||
|
{
|
||||||
|
window: analyticsWindowSchema.describe(
|
||||||
|
"Time window: all_time, this_month, this_week, last_2_weeks, or today"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await api.analytics.getWide(params.window);
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_analytics_wide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_analytics_narrow ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_analytics_narrow",
|
||||||
|
"Get hourly analytics for today. Use for intraday monitoring, detecting recent spikes, and real-time cost tracking.",
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.analytics.getNarrow();
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_analytics_narrow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_insights ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_insights",
|
||||||
|
"Get actionable insights: cost spikes, anomalies, trends, cache efficiency, and recommendations. Critical for autonomous monitoring and cost control.",
|
||||||
|
{
|
||||||
|
days: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(90)
|
||||||
|
.default(30)
|
||||||
|
.describe("Analysis period in days (1-90)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await api.analytics.getInsights(params.days);
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_insights");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_metrics ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_metrics",
|
||||||
|
"Get summary metrics with period-over-period percentage change. Good for quick health checks and comparing current vs previous period.",
|
||||||
|
{
|
||||||
|
days: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(365)
|
||||||
|
.default(30)
|
||||||
|
.describe("Period in days for current window and comparison"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await api.analytics.getMetrics(params.days);
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_metrics");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_logs ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_logs",
|
||||||
|
"Query raw or aggregated event logs. Use for investigation, drill-down, and detailed analysis. Supports grouping by model, agent, or provider.",
|
||||||
|
{
|
||||||
|
start: dateTimeSchema.describe("Start time (ISO 8601 format)"),
|
||||||
|
end: dateTimeSchema.describe("End time (ISO 8601 format)"),
|
||||||
|
groupBy: z
|
||||||
|
.enum(["model", "agent", "provider", "model,agent", "model,provider"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Aggregate by field(s). If not specified, returns raw log rows."
|
||||||
|
),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(5000)
|
||||||
|
.default(500)
|
||||||
|
.describe("Maximum rows/aggregations to return"),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.default(0)
|
||||||
|
.describe("Number of rows to skip (for pagination)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
// Validate date range
|
||||||
|
const startDate = new Date(params.start);
|
||||||
|
const endDate = new Date(params.end);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("Invalid date format. Use ISO 8601 format."),
|
||||||
|
"hive_logs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate < startDate) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("End date must be after start date"),
|
||||||
|
"hive_logs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if range is too large
|
||||||
|
const rangeDays =
|
||||||
|
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (rangeDays > 90 && !params.groupBy) {
|
||||||
|
console.warn(
|
||||||
|
`[MCP] hive_logs: Large date range (${rangeDays.toFixed(
|
||||||
|
0
|
||||||
|
)} days) without aggregation may be slow`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.analytics.getLogs({
|
||||||
|
start: params.start,
|
||||||
|
end: params.end,
|
||||||
|
groupBy: params.groupBy,
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_logs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Budget MCP Tools
|
||||||
|
*
|
||||||
|
* Tools for cost control and budget management:
|
||||||
|
* - hive_budget_get: Get budget status
|
||||||
|
* - hive_budget_reset: Reset budget spend
|
||||||
|
* - hive_budget_validate: Validate request against budgets
|
||||||
|
* - hive_budget_rule_create: Create budget rule
|
||||||
|
* - hive_budget_rule_update: Update budget rule
|
||||||
|
* - hive_budget_rule_delete: Delete budget rule
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import type { ApiClient } from "../utils/api-client";
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
handleToolError,
|
||||||
|
} from "../utils/response-helpers";
|
||||||
|
import {
|
||||||
|
idSchema,
|
||||||
|
budgetTypeSchema,
|
||||||
|
limitActionSchema,
|
||||||
|
validationContextSchema,
|
||||||
|
budgetAlertSchema,
|
||||||
|
budgetNotificationsSchema,
|
||||||
|
} from "../utils/schema-helpers";
|
||||||
|
|
||||||
|
export function registerBudgetTools(server: McpServer, api: ApiClient) {
|
||||||
|
// ==================== hive_budget_get ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_budget_get",
|
||||||
|
"Get budget status including spend, limit, burn rate, and projected spend for a specific budget ID",
|
||||||
|
{
|
||||||
|
budgetId: idSchema.describe("Budget ID to query"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await api.budget.getStatus(params.budgetId);
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_budget_get");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_budget_reset ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_budget_reset",
|
||||||
|
"Reset a budget spend counter to zero. Use when starting new billing cycle or after resolving overage.",
|
||||||
|
{
|
||||||
|
budgetId: idSchema.describe("Budget ID to reset"),
|
||||||
|
reason: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Reason for reset (for audit trail)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await api.budget.reset(params.budgetId);
|
||||||
|
return createSuccessResponse({
|
||||||
|
...result,
|
||||||
|
reason: params.reason,
|
||||||
|
reset_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_budget_reset");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_budget_validate ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_budget_validate",
|
||||||
|
"Validate if a request should be allowed based on budget constraints. Returns allow/throttle/degrade/block decision with authoritative spend data.",
|
||||||
|
{
|
||||||
|
budgetId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Specific budget ID to validate against"),
|
||||||
|
estimatedCost: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.describe("Estimated cost of the request in USD"),
|
||||||
|
context: validationContextSchema
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Context for multi-budget matching (agent, tenant_id, customer_id, feature, tags)"
|
||||||
|
),
|
||||||
|
localSpend: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Local spend tracked by SDK (for drift detection)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const result = await api.budget.validate({
|
||||||
|
budgetId: params.budgetId,
|
||||||
|
estimatedCost: params.estimatedCost,
|
||||||
|
context: params.context,
|
||||||
|
localSpend: params.localSpend,
|
||||||
|
});
|
||||||
|
return createSuccessResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_budget_validate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_budget_rule_create ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_budget_rule_create",
|
||||||
|
"Create a new budget rule within a policy. Budget rules define spending limits and actions when exceeded.",
|
||||||
|
{
|
||||||
|
policyId: z
|
||||||
|
.string()
|
||||||
|
.default("default")
|
||||||
|
.describe('Policy ID (use "default" for default policy)'),
|
||||||
|
id: idSchema.describe("Unique budget rule ID"),
|
||||||
|
name: z.string().min(1).describe("Human-readable budget name"),
|
||||||
|
type: budgetTypeSchema.describe("Budget scope type"),
|
||||||
|
limit: z.number().min(0).describe("Budget limit in USD"),
|
||||||
|
limitAction: limitActionSchema
|
||||||
|
.default("kill")
|
||||||
|
.describe("Action when limit exceeded"),
|
||||||
|
degradeToModel: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Target model for degradation (required when limitAction is "degrade")'),
|
||||||
|
degradeToProvider: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Target provider for degradation (required when limitAction is "degrade")'),
|
||||||
|
tags: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('Tags for tag-type budgets (required when type is "tag")'),
|
||||||
|
alerts: z
|
||||||
|
.array(budgetAlertSchema)
|
||||||
|
.default([
|
||||||
|
{ threshold: 80, enabled: true },
|
||||||
|
{ threshold: 95, enabled: true },
|
||||||
|
])
|
||||||
|
.describe("Alert thresholds as percentage of limit"),
|
||||||
|
notifications: budgetNotificationsSchema
|
||||||
|
.default({
|
||||||
|
inApp: true,
|
||||||
|
email: false,
|
||||||
|
emailRecipients: [],
|
||||||
|
webhook: false,
|
||||||
|
})
|
||||||
|
.describe("Notification settings"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
// Validate degradation requirements
|
||||||
|
if (params.limitAction === "degrade") {
|
||||||
|
if (!params.degradeToModel || !params.degradeToProvider) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error(
|
||||||
|
"degradeToModel and degradeToProvider are required when limitAction is 'degrade'"
|
||||||
|
),
|
||||||
|
"hive_budget_rule_create"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tag requirements
|
||||||
|
if (params.type === "tag") {
|
||||||
|
if (!params.tags || params.tags.length === 0) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("tags array is required when type is 'tag'"),
|
||||||
|
"hive_budget_rule_create"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.policy.addBudgetRule(params.policyId, {
|
||||||
|
id: params.id,
|
||||||
|
name: params.name,
|
||||||
|
type: params.type,
|
||||||
|
limit: params.limit,
|
||||||
|
spent: 0,
|
||||||
|
limitAction: params.limitAction,
|
||||||
|
degradeToModel: params.degradeToModel,
|
||||||
|
degradeToProvider: params.degradeToProvider,
|
||||||
|
tags: params.tags,
|
||||||
|
alerts: params.alerts,
|
||||||
|
notifications: params.notifications,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
budget_id: params.id,
|
||||||
|
policy: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_budget_rule_create");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_budget_rule_update ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_budget_rule_update",
|
||||||
|
"Update an existing budget rule. Only provided fields will be updated.",
|
||||||
|
{
|
||||||
|
policyId: z
|
||||||
|
.string()
|
||||||
|
.default("default")
|
||||||
|
.describe('Policy ID (use "default" for default policy)'),
|
||||||
|
budgetId: idSchema.describe("Budget rule ID to update"),
|
||||||
|
name: z.string().optional().describe("New budget name"),
|
||||||
|
limit: z.number().min(0).optional().describe("New budget limit in USD"),
|
||||||
|
limitAction: limitActionSchema.optional().describe("New action when limit exceeded"),
|
||||||
|
degradeToModel: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("New target model for degradation"),
|
||||||
|
degradeToProvider: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("New target provider for degradation"),
|
||||||
|
alerts: z
|
||||||
|
.array(budgetAlertSchema)
|
||||||
|
.optional()
|
||||||
|
.describe("New alert thresholds"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
// Get current policy to find and update the budget
|
||||||
|
const policy = await api.policy.get(params.policyId);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("Policy not found"),
|
||||||
|
"hive_budget_rule_update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgets = policy.budgets || [];
|
||||||
|
const budgetIndex = budgets.findIndex(
|
||||||
|
(b: { id: string }) => b.id === params.budgetId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (budgetIndex === -1) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error(`Budget ${params.budgetId} not found in policy`),
|
||||||
|
"hive_budget_rule_update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the budget with new values
|
||||||
|
const updatedBudget = {
|
||||||
|
...budgets[budgetIndex],
|
||||||
|
...(params.name && { name: params.name }),
|
||||||
|
...(params.limit !== undefined && { limit: params.limit }),
|
||||||
|
...(params.limitAction && { limitAction: params.limitAction }),
|
||||||
|
...(params.degradeToModel && { degradeToModel: params.degradeToModel }),
|
||||||
|
...(params.degradeToProvider && { degradeToProvider: params.degradeToProvider }),
|
||||||
|
...(params.alerts && { alerts: params.alerts }),
|
||||||
|
};
|
||||||
|
|
||||||
|
budgets[budgetIndex] = updatedBudget;
|
||||||
|
|
||||||
|
const result = await api.policy.update(params.policyId, { budgets });
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
budget_id: params.budgetId,
|
||||||
|
updated_fields: Object.keys(params).filter(
|
||||||
|
(k) =>
|
||||||
|
k !== "policyId" &&
|
||||||
|
k !== "budgetId" &&
|
||||||
|
params[k as keyof typeof params] !== undefined
|
||||||
|
),
|
||||||
|
policy: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_budget_rule_update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_budget_rule_delete ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_budget_rule_delete",
|
||||||
|
"Delete a budget rule from a policy",
|
||||||
|
{
|
||||||
|
policyId: z
|
||||||
|
.string()
|
||||||
|
.default("default")
|
||||||
|
.describe('Policy ID (use "default" for default policy)'),
|
||||||
|
budgetId: idSchema.describe("Budget rule ID to delete"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
// Get current policy to remove the budget
|
||||||
|
const policy = await api.policy.get(params.policyId);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("Policy not found"),
|
||||||
|
"hive_budget_rule_delete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgets = policy.budgets || [];
|
||||||
|
const budgetIndex = budgets.findIndex(
|
||||||
|
(b: { id: string }) => b.id === params.budgetId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (budgetIndex === -1) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error(`Budget ${params.budgetId} not found in policy`),
|
||||||
|
"hive_budget_rule_delete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the budget
|
||||||
|
budgets.splice(budgetIndex, 1);
|
||||||
|
|
||||||
|
const result = await api.policy.update(params.policyId, { budgets });
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
deleted_budget_id: params.budgetId,
|
||||||
|
remaining_budgets: budgets.length,
|
||||||
|
policy: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_budget_rule_delete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Policy Management MCP Tools
|
||||||
|
*
|
||||||
|
* Tools for managing control policies:
|
||||||
|
* - hive_policies_list: List all policies
|
||||||
|
* - hive_policy_get: Get specific policy with rules
|
||||||
|
* - hive_policy_create: Create new policy
|
||||||
|
* - hive_policy_update: Update policy
|
||||||
|
* - hive_policy_clear: Clear all rules from policy
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import type { ApiClient } from "../utils/api-client";
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
handleToolError,
|
||||||
|
} from "../utils/response-helpers";
|
||||||
|
import { idSchema, paginationSchema } from "../utils/schema-helpers";
|
||||||
|
|
||||||
|
export function registerPolicyTools(server: McpServer, api: ApiClient) {
|
||||||
|
// ==================== hive_policies_list ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_policies_list",
|
||||||
|
"List all policies for the team. Returns policy IDs, names, and rule counts.",
|
||||||
|
{
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(100)
|
||||||
|
.describe("Maximum policies to return"),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.default(0)
|
||||||
|
.describe("Number of policies to skip"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const policies = await api.policy.list({
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summarize policies
|
||||||
|
const summary = (policies as unknown as Array<{
|
||||||
|
_id?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
budgets?: unknown[];
|
||||||
|
throttles?: unknown[];
|
||||||
|
blocks?: unknown[];
|
||||||
|
degradations?: unknown[];
|
||||||
|
}>).map((p) => ({
|
||||||
|
id: p._id || p.id || "unknown",
|
||||||
|
name: p.name || "Unnamed Policy",
|
||||||
|
rule_counts: {
|
||||||
|
budgets: p.budgets?.length || 0,
|
||||||
|
throttles: p.throttles?.length || 0,
|
||||||
|
blocks: p.blocks?.length || 0,
|
||||||
|
degradations: p.degradations?.length || 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
count: policies.length,
|
||||||
|
policies: summary,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_policies_list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_policy_get ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_policy_get",
|
||||||
|
'Get a specific policy with all rules (budgets, throttles, blocks, degradations). Use "default" to get the team\'s default policy.',
|
||||||
|
{
|
||||||
|
policyId: z
|
||||||
|
.string()
|
||||||
|
.default("default")
|
||||||
|
.describe('Policy ID or "default" for team default'),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const policy = await api.policy.get(params.policyId);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error(`Policy ${params.policyId} not found`),
|
||||||
|
"hive_policy_get"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse(policy);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_policy_get");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_policy_create ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_policy_create",
|
||||||
|
"Create a new policy for the team. New policies start empty (no rules).",
|
||||||
|
{
|
||||||
|
name: z.string().min(1).describe("Policy name"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const policy = await api.policy.create(params.name);
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
message: "Policy created",
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_policy_create");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_policy_update ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_policy_update",
|
||||||
|
"Update a policy's name or replace all rules. For individual rule changes, use budget/throttle/block rule tools.",
|
||||||
|
{
|
||||||
|
policyId: z
|
||||||
|
.string()
|
||||||
|
.default("default")
|
||||||
|
.describe('Policy ID or "default" for team default'),
|
||||||
|
name: z.string().optional().describe("New policy name"),
|
||||||
|
budgets: z
|
||||||
|
.array(z.any())
|
||||||
|
.optional()
|
||||||
|
.describe("Complete budgets array (replaces all budgets)"),
|
||||||
|
throttles: z
|
||||||
|
.array(z.any())
|
||||||
|
.optional()
|
||||||
|
.describe("Complete throttles array (replaces all throttles)"),
|
||||||
|
blocks: z
|
||||||
|
.array(z.any())
|
||||||
|
.optional()
|
||||||
|
.describe("Complete blocks array (replaces all blocks)"),
|
||||||
|
degradations: z
|
||||||
|
.array(z.any())
|
||||||
|
.optional()
|
||||||
|
.describe("Complete degradations array (replaces all degradations)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
// Only pass defined fields
|
||||||
|
const updates: {
|
||||||
|
name?: string;
|
||||||
|
budgets?: unknown[];
|
||||||
|
throttles?: unknown[];
|
||||||
|
blocks?: unknown[];
|
||||||
|
degradations?: unknown[];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (params.name !== undefined) updates.name = params.name;
|
||||||
|
if (params.budgets !== undefined) updates.budgets = params.budgets;
|
||||||
|
if (params.throttles !== undefined) updates.throttles = params.throttles;
|
||||||
|
if (params.blocks !== undefined) updates.blocks = params.blocks;
|
||||||
|
if (params.degradations !== undefined)
|
||||||
|
updates.degradations = params.degradations;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return handleToolError(
|
||||||
|
new Error("No updates provided"),
|
||||||
|
"hive_policy_update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await api.policy.update(params.policyId, updates);
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
updated_fields: Object.keys(updates),
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_policy_update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== hive_policy_clear ====================
|
||||||
|
server.tool(
|
||||||
|
"hive_policy_clear",
|
||||||
|
"Clear all rules from a policy (budgets, throttles, blocks, degradations). The policy itself is preserved.",
|
||||||
|
{
|
||||||
|
policyId: z
|
||||||
|
.string()
|
||||||
|
.default("default")
|
||||||
|
.describe('Policy ID or "default" for team default'),
|
||||||
|
confirm: z
|
||||||
|
.boolean()
|
||||||
|
.describe("Set to true to confirm clearing all rules"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
if (!params.confirm) {
|
||||||
|
return createSuccessResponse({
|
||||||
|
warning:
|
||||||
|
"This will delete ALL rules from the policy. Set confirm=true to proceed.",
|
||||||
|
policy_id: params.policyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await api.policy.clear(params.policyId);
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
message: "All rules cleared from policy",
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error, "hive_policy_clear");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* HTTP/SSE Transport for Aden Hive MCP Server
|
||||||
|
*
|
||||||
|
* Provides HTTP-based transport for autonomous LLM agents:
|
||||||
|
* - GET /mcp - SSE stream for server-to-client messages
|
||||||
|
* - POST /mcp/message - Client-to-server messages
|
||||||
|
*/
|
||||||
|
import express, { Request, Response, NextFunction, Router } from "express";
|
||||||
|
import passport from "passport";
|
||||||
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import { createHiveMcpServer, type HiveMcpServerOptions } from "../server";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
current_team_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpSession {
|
||||||
|
server: McpServer;
|
||||||
|
transport: SSEServerTransport;
|
||||||
|
teamId: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active MCP sessions by session ID
|
||||||
|
const sessions = new Map<string, McpSession>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create MCP HTTP router
|
||||||
|
*/
|
||||||
|
export function createMcpRouter(
|
||||||
|
getControlEmitter?: HiveMcpServerOptions["getControlEmitter"]
|
||||||
|
): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All MCP routes require authentication
|
||||||
|
const authMiddleware = passport.authenticate("jwt", { session: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp
|
||||||
|
* SSE endpoint - establishes persistent connection for server-to-client messages
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
authMiddleware,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const teamId = req.user?.current_team_id;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
res.status(401).json({ error: "Team ID required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set custom headers (SSE headers are set by the transport)
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
// Create MCP server for this session
|
||||||
|
const server = createHiveMcpServer({
|
||||||
|
context: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
getControlEmitter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create SSE transport - it generates its own sessionId internally
|
||||||
|
const transport = new SSEServerTransport("/mcp/message", res);
|
||||||
|
|
||||||
|
// Get the SDK's session ID (used in query params for POST requests)
|
||||||
|
const sdkSessionId = transport.sessionId;
|
||||||
|
|
||||||
|
console.log(`[MCP] New SSE connection: session=${sdkSessionId}, team=${teamId}`);
|
||||||
|
|
||||||
|
// Store session by the SDK's session ID
|
||||||
|
sessions.set(sdkSessionId, {
|
||||||
|
server,
|
||||||
|
transport,
|
||||||
|
teamId,
|
||||||
|
userId: userId || "unknown",
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect server to transport
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
req.on("close", () => {
|
||||||
|
console.log(`[MCP] SSE connection closed: session=${sdkSessionId}`);
|
||||||
|
sessions.delete(sdkSessionId);
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mcp/message
|
||||||
|
* Receives messages from client
|
||||||
|
*/
|
||||||
|
// Note: Do NOT use express.json() here - handlePostMessage reads the raw body stream
|
||||||
|
router.post(
|
||||||
|
"/message",
|
||||||
|
authMiddleware,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
// SDK passes session ID as query parameter: /mcp/message?sessionId=xxx
|
||||||
|
const sessionId = req.query.sessionId as string;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ error: "sessionId query parameter required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: "Session not found",
|
||||||
|
hint: "Establish SSE connection first via GET /mcp",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify team ID matches
|
||||||
|
if (session.teamId !== req.user?.current_team_id) {
|
||||||
|
res.status(403).json({ error: "Session team mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle the message through the transport
|
||||||
|
await session.transport.handlePostMessage(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MCP] Error handling message:`, error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to process message",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/sessions
|
||||||
|
* List active MCP sessions (admin/debug endpoint)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/sessions",
|
||||||
|
authMiddleware,
|
||||||
|
(req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const teamId = req.user?.current_team_id;
|
||||||
|
|
||||||
|
// Only show sessions for the requesting team
|
||||||
|
const teamSessions = Array.from(sessions.entries())
|
||||||
|
.filter(([_, session]) => session.teamId === teamId)
|
||||||
|
.map(([id, session]) => ({
|
||||||
|
session_id: id,
|
||||||
|
team_id: session.teamId,
|
||||||
|
user_id: session.userId,
|
||||||
|
created_at: session.createdAt.toISOString(),
|
||||||
|
age_seconds: Math.round(
|
||||||
|
(Date.now() - session.createdAt.getTime()) / 1000
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count: teamSessions.length,
|
||||||
|
sessions: teamSessions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /mcp/sessions/:sessionId
|
||||||
|
* Close a specific MCP session
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/sessions/:sessionId",
|
||||||
|
authMiddleware,
|
||||||
|
(req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const teamId = req.user?.current_team_id;
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ error: "Session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify team ID matches
|
||||||
|
if (session.teamId !== teamId) {
|
||||||
|
res.status(403).json({ error: "Cannot close session from another team" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the session
|
||||||
|
session.server.close();
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Session ${sessionId} closed`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/health
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
router.get("/health", (_req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
status: "healthy",
|
||||||
|
active_sessions: sessions.size,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of active MCP sessions
|
||||||
|
*/
|
||||||
|
export function getActiveMcpSessionCount(): number {
|
||||||
|
return sessions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active sessions for a specific team
|
||||||
|
*/
|
||||||
|
export function getTeamMcpSessions(teamId: string): McpSession[] {
|
||||||
|
return Array.from(sessions.values()).filter((s) => s.teamId === teamId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
/**
|
||||||
|
* Internal API client for MCP tools
|
||||||
|
*
|
||||||
|
* This client makes direct calls to the control and tsdb services
|
||||||
|
* rather than HTTP calls, since we're running inside the same process.
|
||||||
|
*/
|
||||||
|
import controlService from "../../services/control/control_service";
|
||||||
|
import * as tsdbService from "../../services/tsdb/tsdb_service";
|
||||||
|
import { buildAnalytics } from "../../services/tsdb/analytics_service";
|
||||||
|
import { getTeamPool, buildSchemaName } from "../../services/tsdb/team_context";
|
||||||
|
import type { PoolClient } from "pg";
|
||||||
|
|
||||||
|
export interface ApiContext {
|
||||||
|
teamId: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetRule {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
tags?: string[];
|
||||||
|
limit?: number;
|
||||||
|
spent?: number;
|
||||||
|
limitAction?: string;
|
||||||
|
degradeToModel?: string;
|
||||||
|
degradeToProvider?: string;
|
||||||
|
alerts?: Array<{ threshold: number; enabled: boolean }>;
|
||||||
|
notifications?: {
|
||||||
|
inApp: boolean;
|
||||||
|
email: boolean;
|
||||||
|
emailRecipients: string[];
|
||||||
|
webhook: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationContext {
|
||||||
|
agent?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
feature?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API client bound to a specific team context
|
||||||
|
*/
|
||||||
|
export function createApiClient(context: ApiContext) {
|
||||||
|
const userContext = {
|
||||||
|
user_id: context.userId || "mcp-agent",
|
||||||
|
team_id: context.teamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ==================== Budget Operations ====================
|
||||||
|
budget: {
|
||||||
|
/**
|
||||||
|
* Get budget status by ID
|
||||||
|
*/
|
||||||
|
async getStatus(budgetId: string) {
|
||||||
|
return controlService.getBudgetStatus(budgetId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset budget spend to zero
|
||||||
|
*/
|
||||||
|
async reset(budgetId: string) {
|
||||||
|
await controlService.resetBudget(budgetId);
|
||||||
|
return { success: true, id: budgetId };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a request against budgets
|
||||||
|
*/
|
||||||
|
async validate(params: {
|
||||||
|
budgetId?: string;
|
||||||
|
estimatedCost: number;
|
||||||
|
context?: ValidationContext;
|
||||||
|
localSpend?: number;
|
||||||
|
}) {
|
||||||
|
// Get the policy to validate against
|
||||||
|
const policy = await controlService.getPolicy(
|
||||||
|
context.teamId,
|
||||||
|
null,
|
||||||
|
userContext
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
action: "allow",
|
||||||
|
reason: "No policy found",
|
||||||
|
budgets_checked: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-budget validation using context
|
||||||
|
if (params.context && typeof params.context === "object") {
|
||||||
|
const matchingBudgets = controlService.findMatchingBudgetsForContext(
|
||||||
|
policy.budgets || [],
|
||||||
|
params.context
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingBudgets.length === 0) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
action: "allow",
|
||||||
|
reason: "No budgets match the provided context",
|
||||||
|
authoritative_spend: 0,
|
||||||
|
budget_limit: 0,
|
||||||
|
usage_percent: 0,
|
||||||
|
projected_percent: 0,
|
||||||
|
budgets_checked: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return controlService.validateMultipleBudgets(
|
||||||
|
matchingBudgets,
|
||||||
|
params.estimatedCost,
|
||||||
|
params.localSpend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single budget validation
|
||||||
|
if (params.budgetId) {
|
||||||
|
const budget = policy.budgets?.find(
|
||||||
|
(b: { id: string }) => b.id === params.budgetId
|
||||||
|
);
|
||||||
|
if (!budget) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
action: "allow",
|
||||||
|
reason: "Budget not found in policy",
|
||||||
|
budgets_checked: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return controlService.validateMultipleBudgets(
|
||||||
|
[budget],
|
||||||
|
params.estimatedCost,
|
||||||
|
params.localSpend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
action: "allow",
|
||||||
|
reason: "No budget_id or context provided",
|
||||||
|
budgets_checked: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Policy Operations ====================
|
||||||
|
policy: {
|
||||||
|
/**
|
||||||
|
* Get all policies for the team
|
||||||
|
*/
|
||||||
|
async list(pagination?: { limit?: number; offset?: number }) {
|
||||||
|
return controlService.getPoliciesByTeam(context.teamId, {
|
||||||
|
limit: pagination?.limit || 100,
|
||||||
|
offset: pagination?.offset || 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific policy
|
||||||
|
*/
|
||||||
|
async get(policyId: string | null) {
|
||||||
|
const resolvedId =
|
||||||
|
policyId === "default" || !policyId ? null : policyId;
|
||||||
|
return controlService.getPolicy(context.teamId, resolvedId, userContext);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new policy
|
||||||
|
*/
|
||||||
|
async create(name: string) {
|
||||||
|
return controlService.updatePolicy(
|
||||||
|
context.teamId,
|
||||||
|
null,
|
||||||
|
{ name },
|
||||||
|
userContext
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a policy
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
policyId: string | null,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
budgets?: unknown[];
|
||||||
|
throttles?: unknown[];
|
||||||
|
blocks?: unknown[];
|
||||||
|
degradations?: unknown[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const resolvedId =
|
||||||
|
policyId === "default" || !policyId ? null : policyId;
|
||||||
|
return controlService.updatePolicy(
|
||||||
|
context.teamId,
|
||||||
|
resolvedId,
|
||||||
|
updates as Record<string, unknown>,
|
||||||
|
userContext
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all rules from a policy
|
||||||
|
*/
|
||||||
|
async clear(policyId: string | null) {
|
||||||
|
const resolvedId =
|
||||||
|
policyId === "default" || !policyId ? null : policyId;
|
||||||
|
return controlService.clearPolicy(
|
||||||
|
context.teamId,
|
||||||
|
resolvedId,
|
||||||
|
userContext
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a policy
|
||||||
|
*/
|
||||||
|
async delete(policyId: string) {
|
||||||
|
return controlService.deletePolicy(
|
||||||
|
context.teamId,
|
||||||
|
policyId,
|
||||||
|
userContext
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a budget rule to a policy
|
||||||
|
*/
|
||||||
|
async addBudgetRule(policyId: string | null, rule: BudgetRule) {
|
||||||
|
const resolvedId =
|
||||||
|
policyId === "default" || !policyId ? null : policyId;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return controlService.addBudgetRule(
|
||||||
|
context.teamId,
|
||||||
|
resolvedId,
|
||||||
|
rule as any,
|
||||||
|
userContext
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Analytics Operations ====================
|
||||||
|
analytics: {
|
||||||
|
/**
|
||||||
|
* Get wide analytics (daily resolution)
|
||||||
|
*/
|
||||||
|
async getWide(window: string = "this_month") {
|
||||||
|
const pool = await getTeamPool(context.teamId);
|
||||||
|
const schema = buildSchemaName(context.teamId);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`SET search_path TO ${schema}, public`);
|
||||||
|
await tsdbService.ensureSchema(client);
|
||||||
|
|
||||||
|
return buildAnalytics({
|
||||||
|
windowLabel: window,
|
||||||
|
client,
|
||||||
|
resolution: "day",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get narrow analytics (hourly resolution for today)
|
||||||
|
*/
|
||||||
|
async getNarrow() {
|
||||||
|
const pool = await getTeamPool(context.teamId);
|
||||||
|
const schema = buildSchemaName(context.teamId);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`SET search_path TO ${schema}, public`);
|
||||||
|
await tsdbService.ensureSchema(client);
|
||||||
|
|
||||||
|
return buildAnalytics({
|
||||||
|
windowLabel: "today",
|
||||||
|
client,
|
||||||
|
resolution: "hour",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get actionable insights
|
||||||
|
*/
|
||||||
|
async getInsights(days: number = 30) {
|
||||||
|
const pool = await getTeamPool(context.teamId);
|
||||||
|
const schema = buildSchemaName(context.teamId);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`SET search_path TO ${schema}, public`);
|
||||||
|
await tsdbService.ensureSchema(client);
|
||||||
|
|
||||||
|
// Use the insights generation logic from tsdb controller
|
||||||
|
// This is a simplified version - full implementation would mirror the controller
|
||||||
|
return this._generateInsights(client, days);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary metrics with period-over-period change
|
||||||
|
*/
|
||||||
|
async getMetrics(days: number = 30) {
|
||||||
|
const pool = await getTeamPool(context.teamId);
|
||||||
|
const schema = buildSchemaName(context.teamId);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`SET search_path TO ${schema}, public`);
|
||||||
|
await tsdbService.ensureSchema(client);
|
||||||
|
|
||||||
|
return this._generateMetrics(client, days);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logs (raw or aggregated)
|
||||||
|
*/
|
||||||
|
async getLogs(params: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
groupBy?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const pool = await getTeamPool(context.teamId);
|
||||||
|
const schema = buildSchemaName(context.teamId);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`SET search_path TO ${schema}, public`);
|
||||||
|
await tsdbService.ensureSchema(client);
|
||||||
|
|
||||||
|
return this._getLogs(client, params);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Internal helper methods
|
||||||
|
async _generateInsights(client: PoolClient, days: number) {
|
||||||
|
// Simplified insights generation
|
||||||
|
const now = new Date();
|
||||||
|
const periodStart = new Date(now);
|
||||||
|
periodStart.setDate(periodStart.getDate() - days);
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(cost_total), 0) as total_cost,
|
||||||
|
COALESCE(AVG(latency_ms), 0) as avg_latency
|
||||||
|
FROM llm_events
|
||||||
|
WHERE "timestamp" >= $1 AND "timestamp" <= $2
|
||||||
|
`,
|
||||||
|
[periodStart.toISOString(), now.toISOString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = rows[0];
|
||||||
|
const insights = [];
|
||||||
|
|
||||||
|
// Basic usage summary insight
|
||||||
|
insights.push({
|
||||||
|
id: "usage_snapshot",
|
||||||
|
severity: "summary",
|
||||||
|
title: "Period usage summary",
|
||||||
|
description: `${parseInt(stats.total_requests).toLocaleString()} requests totaling $${parseFloat(stats.total_cost).toFixed(2)} over the last ${days} days.`,
|
||||||
|
metric: {
|
||||||
|
total_requests: parseInt(stats.total_requests),
|
||||||
|
total_cost: parseFloat(stats.total_cost),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { days, start: periodStart.toISOString(), end: now.toISOString() },
|
||||||
|
insights,
|
||||||
|
summary: {
|
||||||
|
total: insights.length,
|
||||||
|
critical: insights.filter((i) => i.severity === "critical").length,
|
||||||
|
warning: insights.filter((i) => i.severity === "warning").length,
|
||||||
|
info: insights.filter((i) => i.severity === "info").length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async _generateMetrics(client: PoolClient, days: number) {
|
||||||
|
const now = new Date();
|
||||||
|
const currentStart = new Date(now);
|
||||||
|
currentStart.setDate(currentStart.getDate() - days);
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COUNT(DISTINCT trace_id) as unique_traces,
|
||||||
|
COALESCE(SUM(usage_input_tokens), 0) as total_input_tokens,
|
||||||
|
COALESCE(SUM(usage_output_tokens), 0) as total_output_tokens,
|
||||||
|
COALESCE(SUM(cost_total), 0) as total_cost,
|
||||||
|
COALESCE(AVG(latency_ms), 0) as avg_latency_ms
|
||||||
|
FROM llm_events
|
||||||
|
WHERE "timestamp" >= $1 AND "timestamp" <= $2
|
||||||
|
`,
|
||||||
|
[currentStart.toISOString(), now.toISOString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = rows[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { days, start: currentStart.toISOString(), end: now.toISOString() },
|
||||||
|
volume: {
|
||||||
|
total_requests: parseInt(stats.total_requests),
|
||||||
|
unique_traces: parseInt(stats.unique_traces),
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
total_input_tokens: parseInt(stats.total_input_tokens),
|
||||||
|
total_output_tokens: parseInt(stats.total_output_tokens),
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
total_cost: parseFloat(stats.total_cost),
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
avg_latency_ms: parseFloat(stats.avg_latency_ms),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async _getLogs(
|
||||||
|
client: PoolClient,
|
||||||
|
params: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
groupBy?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { start, end, groupBy, limit = 500, offset = 0 } = params;
|
||||||
|
|
||||||
|
if (groupBy) {
|
||||||
|
const validFields = ["model", "agent", "provider"];
|
||||||
|
const groupFields = groupBy
|
||||||
|
.split(",")
|
||||||
|
.map((f) => f.trim())
|
||||||
|
.filter((f) => validFields.includes(f));
|
||||||
|
|
||||||
|
if (groupFields.length > 0) {
|
||||||
|
const selectFields = groupFields.join(", ");
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
${selectFields},
|
||||||
|
COUNT(*) as request_count,
|
||||||
|
COALESCE(SUM(cost_total), 0) as total_cost
|
||||||
|
FROM llm_events
|
||||||
|
WHERE "timestamp" >= $1 AND "timestamp" <= $2
|
||||||
|
GROUP BY ${selectFields}
|
||||||
|
ORDER BY total_cost DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`,
|
||||||
|
[start, end, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: { start, end },
|
||||||
|
group_by: groupFields,
|
||||||
|
count: rows.length,
|
||||||
|
aggregations: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw logs
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM llm_events
|
||||||
|
WHERE "timestamp" >= $1 AND "timestamp" <= $2
|
||||||
|
ORDER BY "timestamp" DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`,
|
||||||
|
[start, end, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: { start, end },
|
||||||
|
count: rows.length,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Agent Status Operations ====================
|
||||||
|
agents: {
|
||||||
|
/**
|
||||||
|
* Get connected agent instances
|
||||||
|
* This requires access to the controlEmitter which is set on the Express app
|
||||||
|
*/
|
||||||
|
getList(controlEmitter?: {
|
||||||
|
getConnectedCount: (teamId: string) => number;
|
||||||
|
getConnectedInstances: (teamId: string) => unknown[];
|
||||||
|
}) {
|
||||||
|
if (!controlEmitter) {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
count: 0,
|
||||||
|
instances: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: "WebSocket not initialized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = controlEmitter.getConnectedCount(context.teamId);
|
||||||
|
const instances = controlEmitter.getConnectedInstances(context.teamId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: count > 0,
|
||||||
|
count,
|
||||||
|
instances,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get agent fleet summary
|
||||||
|
*/
|
||||||
|
getSummary(controlEmitter?: {
|
||||||
|
getConnectedCount: (teamId: string) => number;
|
||||||
|
getConnectedInstances: (teamId: string) => Array<{
|
||||||
|
instance_id: string;
|
||||||
|
agent?: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
if (!controlEmitter) {
|
||||||
|
return {
|
||||||
|
total_active: 0,
|
||||||
|
healthy: 0,
|
||||||
|
unhealthy: 0,
|
||||||
|
stale_connections: 0,
|
||||||
|
by_agent_name: {},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: "WebSocket not initialized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const instances = controlEmitter.getConnectedInstances(context.teamId);
|
||||||
|
const now = Date.now();
|
||||||
|
const STALE_THRESHOLD_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
|
let healthy = 0;
|
||||||
|
let unhealthy = 0;
|
||||||
|
const byAgentName: Record<
|
||||||
|
string,
|
||||||
|
{ count: number; healthy: number; unhealthy: number }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
const lastHeartbeat = new Date(instance.last_heartbeat).getTime();
|
||||||
|
const isHealthy = now - lastHeartbeat < STALE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
if (isHealthy) {
|
||||||
|
healthy++;
|
||||||
|
} else {
|
||||||
|
unhealthy++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentName = instance.agent || "unknown";
|
||||||
|
if (!byAgentName[agentName]) {
|
||||||
|
byAgentName[agentName] = { count: 0, healthy: 0, unhealthy: 0 };
|
||||||
|
}
|
||||||
|
byAgentName[agentName].count++;
|
||||||
|
if (isHealthy) {
|
||||||
|
byAgentName[agentName].healthy++;
|
||||||
|
} else {
|
||||||
|
byAgentName[agentName].unhealthy++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_active: instances.length,
|
||||||
|
healthy,
|
||||||
|
unhealthy,
|
||||||
|
stale_connections: unhealthy,
|
||||||
|
by_agent_name: byAgentName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* MCP response formatting helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MCPResponse {
|
||||||
|
[key: string]: unknown;
|
||||||
|
content: Array<{
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a successful MCP response
|
||||||
|
*/
|
||||||
|
export function createSuccessResponse(data: unknown): MCPResponse {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(data, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error MCP response
|
||||||
|
*/
|
||||||
|
export function createErrorResponse(
|
||||||
|
error: string,
|
||||||
|
details?: unknown
|
||||||
|
): MCPResponse {
|
||||||
|
const errorData = {
|
||||||
|
error,
|
||||||
|
...(details && { details }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(errorData, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle tool errors consistently
|
||||||
|
*/
|
||||||
|
export function handleToolError(error: unknown, toolName: string): MCPResponse {
|
||||||
|
console.error(`[MCP] Error in ${toolName}:`, error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return createErrorResponse(error.message, {
|
||||||
|
tool: toolName,
|
||||||
|
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createErrorResponse("Unknown error occurred", { tool: toolName });
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Zod schema helpers for MCP tools
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Basic types
|
||||||
|
export const idSchema = z.string().min(1).describe("Unique identifier");
|
||||||
|
export const dateSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.describe("Date in YYYY-MM-DD format");
|
||||||
|
export const dateTimeSchema = z
|
||||||
|
.string()
|
||||||
|
.datetime()
|
||||||
|
.describe("ISO 8601 datetime string");
|
||||||
|
export const amountSchema = z.number().describe("Monetary amount in USD");
|
||||||
|
|
||||||
|
// Budget types
|
||||||
|
export const budgetTypeSchema = z
|
||||||
|
.enum(["global", "agent", "tenant", "customer", "feature", "tag"])
|
||||||
|
.describe("Type of budget scope");
|
||||||
|
|
||||||
|
export const limitActionSchema = z
|
||||||
|
.enum(["kill", "throttle", "degrade"])
|
||||||
|
.describe("Action when budget limit exceeded");
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
export const paginationSchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(1000)
|
||||||
|
.default(100)
|
||||||
|
.describe("Max items to return"),
|
||||||
|
offset: z.number().min(0).default(0).describe("Number of items to skip"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analytics window
|
||||||
|
export const analyticsWindowSchema = z
|
||||||
|
.enum(["all_time", "this_month", "this_week", "last_2_weeks", "today"])
|
||||||
|
.default("this_month")
|
||||||
|
.describe("Time window for analytics data");
|
||||||
|
|
||||||
|
// Budget validation context
|
||||||
|
export const validationContextSchema = z
|
||||||
|
.object({
|
||||||
|
agent: z.string().optional().describe("Agent name for agent-type budgets"),
|
||||||
|
tenant_id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Tenant ID for tenant-type budgets"),
|
||||||
|
customer_id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Customer ID for customer-type budgets"),
|
||||||
|
feature: z.string().optional().describe("Feature name for feature-type budgets"),
|
||||||
|
tags: z.array(z.string()).optional().describe("Tags for tag-type budgets"),
|
||||||
|
})
|
||||||
|
.describe("Context for multi-budget matching");
|
||||||
|
|
||||||
|
// Budget alert configuration
|
||||||
|
export const budgetAlertSchema = z.object({
|
||||||
|
threshold: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(100)
|
||||||
|
.describe("Alert threshold as percentage of limit"),
|
||||||
|
enabled: z.boolean().describe("Whether alert is enabled"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Budget notifications configuration
|
||||||
|
export const budgetNotificationsSchema = z.object({
|
||||||
|
inApp: z.boolean().default(true).describe("Enable in-app notifications"),
|
||||||
|
email: z.boolean().default(false).describe("Enable email notifications"),
|
||||||
|
emailRecipients: z
|
||||||
|
.array(z.string().email())
|
||||||
|
.default([])
|
||||||
|
.describe("Email recipients"),
|
||||||
|
webhook: z.boolean().default(false).describe("Enable webhook notifications"),
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Global Error Handler Middleware
|
||||||
|
*
|
||||||
|
* Handles all errors and sends consistent JSON responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
interface HttpError extends Error {
|
||||||
|
status?: number;
|
||||||
|
statusCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handler middleware
|
||||||
|
* @param {Error} err - Error object
|
||||||
|
* @param {Object} req - Express request
|
||||||
|
* @param {Object} res - Express response
|
||||||
|
* @param {Function} next - Next middleware
|
||||||
|
*/
|
||||||
|
function errorHandler(err: HttpError, req: Request, res: Response, next: NextFunction): void {
|
||||||
|
// Log error
|
||||||
|
console.error('[Error]', {
|
||||||
|
message: err.message,
|
||||||
|
status: err.status || err.statusCode || 500,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get status code
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
|
||||||
|
// Send error response
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.name || 'Error',
|
||||||
|
message: err.message || 'An unexpected error occurred',
|
||||||
|
status,
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { errorHandler };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Route Definitions
|
||||||
|
*
|
||||||
|
* Central route registration for all DevTool APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
import tsdbController from './controllers/tsdb.controller';
|
||||||
|
import controlController from './controllers/control.controller';
|
||||||
|
import quickstartController from './controllers/quickstart.controller';
|
||||||
|
import userController from './controllers/user.controller';
|
||||||
|
import iamController from './controllers/iam.controller';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// User Routes - Authentication and user management
|
||||||
|
// =============================================================================
|
||||||
|
router.use('/user', userController);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// IAM Routes - Identity and Access Management
|
||||||
|
// =============================================================================
|
||||||
|
router.use('/iam', iamController);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TSDB Routes - Time Series Database for LLM metrics
|
||||||
|
// =============================================================================
|
||||||
|
router.use('/tsdb', tsdbController);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Control Routes - SDK control plane
|
||||||
|
// =============================================================================
|
||||||
|
router.use('/v1/control', controlController);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Quickstart Routes - SDK documentation generation
|
||||||
|
// =============================================================================
|
||||||
|
router.use('/quickstart', quickstartController);
|
||||||
|
|
||||||
|
export default router;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
|||||||
|
/**
|
||||||
|
* Aden Control Sockets
|
||||||
|
*
|
||||||
|
* WebSocket namespace for real-time control plane communication.
|
||||||
|
* Handles:
|
||||||
|
* - SDK connections and authentication
|
||||||
|
* - Real-time policy updates
|
||||||
|
* - Event ingestion
|
||||||
|
* - Heartbeat monitoring
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
// Note: userDB.findSaltByToken will be injected via initialization
|
||||||
|
import controlService from "./control_service";
|
||||||
|
import llmEventBatcher from "./llm_event_batcher";
|
||||||
|
import type { Server, Socket, Namespace } from "socket.io";
|
||||||
|
|
||||||
|
interface UserDbService {
|
||||||
|
findSaltByToken: (token: string) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userDbService: UserDbService | null = null;
|
||||||
|
let jwtSecret: string = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user DB service for JWT verification
|
||||||
|
* @param service - User DB service with findSaltByToken method
|
||||||
|
* @param secret - JWT secret for token verification
|
||||||
|
*/
|
||||||
|
function setUserDbService(service: UserDbService, secret?: string): void {
|
||||||
|
userDbService = service;
|
||||||
|
if (secret) {
|
||||||
|
jwtSecret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstanceInfo {
|
||||||
|
socket: Socket;
|
||||||
|
instanceId: string;
|
||||||
|
policyId: string | null;
|
||||||
|
connectedAt: Date;
|
||||||
|
lastHeartbeat: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP-only agents (no socket connection)
|
||||||
|
interface HttpInstanceInfo {
|
||||||
|
instanceId: string;
|
||||||
|
policyId: string | null;
|
||||||
|
agentName: string | null;
|
||||||
|
status: string;
|
||||||
|
firstSeen: Date;
|
||||||
|
lastHeartbeat: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track connected SDK instances (WebSocket)
|
||||||
|
// teamId -> Map<socketId, { socket, instanceId, policyId, connectedAt }>
|
||||||
|
const connectedInstances = new Map<string, Map<string, InstanceInfo>>();
|
||||||
|
|
||||||
|
// Track HTTP-only SDK instances (no WebSocket, identified by heartbeats)
|
||||||
|
// teamId -> Map<instanceId, { instanceId, policyId, status, firstSeen, lastHeartbeat }>
|
||||||
|
const httpInstances = new Map<string, Map<string, HttpInstanceInfo>>();
|
||||||
|
|
||||||
|
// TTL for HTTP agents (remove if no heartbeat for this duration)
|
||||||
|
const HTTP_AGENT_TTL_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register or update an HTTP-only agent from heartbeat
|
||||||
|
* Called from control_service when processing heartbeat events
|
||||||
|
*/
|
||||||
|
function registerHttpAgent(
|
||||||
|
teamId: string | number,
|
||||||
|
instanceId: string,
|
||||||
|
policyId: string | null,
|
||||||
|
agentName: string | null,
|
||||||
|
status: string
|
||||||
|
): void {
|
||||||
|
const teamKey = String(teamId);
|
||||||
|
|
||||||
|
// Check if this instance is already connected via WebSocket
|
||||||
|
const wsInstances = connectedInstances.get(teamKey);
|
||||||
|
if (wsInstances) {
|
||||||
|
for (const info of wsInstances.values()) {
|
||||||
|
if (info.instanceId === instanceId) {
|
||||||
|
// Already tracked via WebSocket, just update heartbeat there
|
||||||
|
info.lastHeartbeat = new Date();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track as HTTP-only agent
|
||||||
|
if (!httpInstances.has(teamKey)) {
|
||||||
|
httpInstances.set(teamKey, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = httpInstances.get(teamKey)!.get(instanceId);
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
existing.lastHeartbeat = new Date();
|
||||||
|
existing.status = status;
|
||||||
|
existing.policyId = policyId;
|
||||||
|
existing.agentName = agentName;
|
||||||
|
} else {
|
||||||
|
// New HTTP agent
|
||||||
|
httpInstances.get(teamKey)!.set(instanceId, {
|
||||||
|
instanceId,
|
||||||
|
policyId,
|
||||||
|
agentName,
|
||||||
|
status,
|
||||||
|
firstSeen: new Date(),
|
||||||
|
lastHeartbeat: new Date(),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[Aden Control] HTTP agent registered: ${agentName || instanceId.slice(0, 8)}... (team: ${teamKey})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stale HTTP agents that haven't sent heartbeats
|
||||||
|
*/
|
||||||
|
function cleanupStaleHttpAgents(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [teamId, instances] of httpInstances) {
|
||||||
|
for (const [instanceId, info] of instances) {
|
||||||
|
if (now - info.lastHeartbeat.getTime() > HTTP_AGENT_TTL_MS) {
|
||||||
|
instances.delete(instanceId);
|
||||||
|
console.log(
|
||||||
|
`[Aden Control] HTTP agent expired: ${instanceId.slice(0, 8)}... (team: ${teamId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty team maps
|
||||||
|
if (instances.size === 0) {
|
||||||
|
httpInstances.delete(teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup every 30 seconds
|
||||||
|
setInterval(cleanupStaleHttpAgents, 30000);
|
||||||
|
|
||||||
|
interface AdenSocket extends Socket {
|
||||||
|
user?: Record<string, unknown>;
|
||||||
|
teamId?: string;
|
||||||
|
policyId?: string | null;
|
||||||
|
sdkInstanceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisEmitter {
|
||||||
|
of: (namespace: string) => ControlEmitterInner;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlEmitterInner {
|
||||||
|
to: (room: string) => { emit: (event: string, payload: unknown) => void };
|
||||||
|
emit: (event: string, payload: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageData {
|
||||||
|
event_type?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlEmitter {
|
||||||
|
emitPolicyUpdate: (teamId: string | number, policyId: string | null, policy: unknown) => void;
|
||||||
|
emitCommand: (teamId: string | number, command: { action: string; [key: string]: unknown }) => void;
|
||||||
|
emitAlert: (teamId: string | number, policyId: string | null, alert: unknown) => void;
|
||||||
|
emitToInstance: (teamId: string | number, instanceId: string, message: unknown) => boolean;
|
||||||
|
getConnectedCount: (teamId: string | number) => number;
|
||||||
|
getConnectedInstances: (teamId: string | number) => Array<{
|
||||||
|
instance_id: string;
|
||||||
|
policy_id: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
connection_type: "websocket" | "http";
|
||||||
|
status?: string;
|
||||||
|
}>;
|
||||||
|
getTotalConnectedCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Aden Control WebSocket namespace
|
||||||
|
* @param io - Socket.IO server instance
|
||||||
|
* @param rootEmitter - Redis emitter for cross-instance communication
|
||||||
|
* @returns Control emitter for sending updates
|
||||||
|
*/
|
||||||
|
function initAdenControlSockets(io: Server, rootEmitter: RedisEmitter): ControlEmitter {
|
||||||
|
// Create namespace for control plane
|
||||||
|
const controlNamespace: Namespace = io.of("/v1/control/ws");
|
||||||
|
|
||||||
|
// Create emitter for this namespace
|
||||||
|
const controlEmitter: ControlEmitterInner = rootEmitter.of("/v1/control/ws");
|
||||||
|
|
||||||
|
// Initialize LLM event batcher with emitter for real-time streaming
|
||||||
|
llmEventBatcher.setEmitter(controlEmitter as unknown as { to: (room: string) => { emit: (event: string, payload: unknown) => void } });
|
||||||
|
|
||||||
|
// Authentication middleware - verify JWT token
|
||||||
|
controlNamespace.use(async (socket: AdenSocket, next: (err?: Error) => void) => {
|
||||||
|
try {
|
||||||
|
let token: string | undefined =
|
||||||
|
socket.handshake.auth?.token ||
|
||||||
|
socket.handshake.headers?.authorization ||
|
||||||
|
(socket.handshake.query?.token as string | undefined);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error("[Aden Control WS] No authorization provided");
|
||||||
|
return next(new Error("Authentication required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token (support "Bearer <token>" and "jwt <token>" formats)
|
||||||
|
if (token.startsWith("Bearer ")) {
|
||||||
|
token = token.slice(7);
|
||||||
|
} else if (token.startsWith("jwt ")) {
|
||||||
|
token = token.slice(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return next(new Error("Invalid token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token using user's salt
|
||||||
|
if (!userDbService) {
|
||||||
|
console.error("[Aden Control WS] userDbService not initialized");
|
||||||
|
return next(new Error("Server configuration error"));
|
||||||
|
}
|
||||||
|
const salt = await userDbService.findSaltByToken(token);
|
||||||
|
if (!salt) {
|
||||||
|
console.error("[Aden Control WS] No salt found for token");
|
||||||
|
return next(new Error("Invalid token"));
|
||||||
|
}
|
||||||
|
// Token is signed with jwtSecret + salt
|
||||||
|
const verifySecret = jwtSecret ? jwtSecret + salt : salt;
|
||||||
|
const decoded = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||||
|
jwt.verify(token!, verifySecret, (err, decoded) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(decoded as Record<string, unknown>);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store user info on socket
|
||||||
|
socket.user = decoded;
|
||||||
|
socket.teamId = decoded.current_team_id as string;
|
||||||
|
socket.policyId =
|
||||||
|
(socket.handshake.headers?.["x-policy-id"] as string) ||
|
||||||
|
(socket.handshake.query?.policy_id as string) ||
|
||||||
|
null;
|
||||||
|
socket.sdkInstanceId =
|
||||||
|
(socket.handshake.headers?.["x-sdk-instance-id"] as string) ||
|
||||||
|
(socket.handshake.query?.instance_id as string) ||
|
||||||
|
socket.id;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Aden Control WS] SDK connecting: ${socket.sdkInstanceId!.slice(0, 8)}... (team: ${socket.teamId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Aden Control WS] Auth error:", (error as Error).message);
|
||||||
|
next(new Error("Authentication failed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connections
|
||||||
|
controlNamespace.on("connection", async (socket: AdenSocket) => {
|
||||||
|
const { teamId, policyId, sdkInstanceId } = socket;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Aden Control WS] SDK connected: ${sdkInstanceId!.slice(0, 8)}... (socket: ${socket.id}, team: ${teamId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track this instance by team
|
||||||
|
if (!connectedInstances.has(teamId!)) {
|
||||||
|
connectedInstances.set(teamId!, new Map());
|
||||||
|
}
|
||||||
|
connectedInstances.get(teamId!)!.set(socket.id, {
|
||||||
|
socket,
|
||||||
|
instanceId: sdkInstanceId!,
|
||||||
|
policyId: policyId || null,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
lastHeartbeat: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join room for this team (for policy broadcasts)
|
||||||
|
socket.join(`team:${teamId}`);
|
||||||
|
// Also join policy-specific room if policy specified
|
||||||
|
if (policyId) {
|
||||||
|
socket.join(`team:${teamId}:policy:${policyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send current policy immediately
|
||||||
|
try {
|
||||||
|
const policy = await controlService.getPolicy(teamId!, policyId || null);
|
||||||
|
socket.emit("message", {
|
||||||
|
type: "policy",
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Aden Control WS] Error sending initial policy:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages from SDK
|
||||||
|
socket.on("message", async (data: MessageData | string) => {
|
||||||
|
try {
|
||||||
|
await handleSdkMessage(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Aden Control WS] Error handling message:", error);
|
||||||
|
socket.emit("message", {
|
||||||
|
type: "error",
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle direct event submission (alternative to message)
|
||||||
|
socket.on("event", async (event: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await controlService.processEvents(teamId!, policyId || null, [event as any]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Aden Control WS] Error processing event:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle disconnection
|
||||||
|
socket.on("disconnect", (reason: string) => {
|
||||||
|
console.log(
|
||||||
|
`[Aden Control WS] SDK disconnected: ${sdkInstanceId!.slice(0, 8)}... (reason: ${reason})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
const instances = connectedInstances.get(teamId!);
|
||||||
|
if (instances) {
|
||||||
|
instances.delete(socket.id);
|
||||||
|
if (instances.size === 0) {
|
||||||
|
connectedInstances.delete(teamId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
socket.on("error", (error: Error) => {
|
||||||
|
console.error(
|
||||||
|
`[Aden Control WS] Socket error for ${sdkInstanceId!.slice(0, 8)}...:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle LLM events stream subscription (for dashboard real-time updates)
|
||||||
|
socket.on("subscribe-llm-events", () => {
|
||||||
|
const room = `team:${teamId}:llm-events`;
|
||||||
|
socket.join(room);
|
||||||
|
console.log(`[Aden Control WS] Socket ${socket.id} subscribed to ${room}`);
|
||||||
|
socket.emit("message", {
|
||||||
|
type: "subscribed",
|
||||||
|
stream: "llm-events",
|
||||||
|
teamId: teamId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("unsubscribe-llm-events", () => {
|
||||||
|
const room = `team:${teamId}:llm-events`;
|
||||||
|
socket.leave(room);
|
||||||
|
console.log(`[Aden Control WS] Socket ${socket.id} unsubscribed from ${room}`);
|
||||||
|
socket.emit("message", {
|
||||||
|
type: "unsubscribed",
|
||||||
|
stream: "llm-events",
|
||||||
|
teamId: teamId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming message from SDK
|
||||||
|
*/
|
||||||
|
async function handleSdkMessage(socket: AdenSocket, data: MessageData | string): Promise<void> {
|
||||||
|
// Parse if string
|
||||||
|
let parsedData: MessageData;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
parsedData = JSON.parse(data);
|
||||||
|
} else {
|
||||||
|
parsedData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId, policyId, sdkInstanceId } = socket;
|
||||||
|
|
||||||
|
// Route based on event type
|
||||||
|
switch (parsedData.event_type) {
|
||||||
|
case "metric":
|
||||||
|
case "control":
|
||||||
|
case "heartbeat":
|
||||||
|
case "error":
|
||||||
|
// Process as event
|
||||||
|
await controlService.processEvents(teamId!, policyId || null, [parsedData as any]);
|
||||||
|
|
||||||
|
// Update last heartbeat time
|
||||||
|
if (parsedData.event_type === "heartbeat") {
|
||||||
|
const instances = connectedInstances.get(teamId!);
|
||||||
|
const instance = instances?.get(socket.id);
|
||||||
|
if (instance) {
|
||||||
|
instance.lastHeartbeat = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_policy":
|
||||||
|
// Request for current policy
|
||||||
|
const policy = await controlService.getPolicy(teamId!, policyId || null);
|
||||||
|
socket.emit("message", {
|
||||||
|
type: "policy",
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(
|
||||||
|
`[Aden Control WS] Unknown event type from ${sdkInstanceId!.slice(0, 8)}...: ${parsedData.event_type}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create emitter object for external use
|
||||||
|
*/
|
||||||
|
const emitter: ControlEmitter = {
|
||||||
|
/**
|
||||||
|
* Emit policy update to all SDK instances for a team/policy
|
||||||
|
* @param teamId - The team ID
|
||||||
|
* @param policyId - The policy ID (optional, broadcasts to all team instances if not specified)
|
||||||
|
* @param policy - The policy object
|
||||||
|
*/
|
||||||
|
emitPolicyUpdate(teamId: string | number, policyId: string | null, policy: unknown): void {
|
||||||
|
console.log(`[Aden Control WS] Broadcasting policy update for team ${teamId}`);
|
||||||
|
|
||||||
|
// If policyId specified, emit only to instances using that policy
|
||||||
|
if (policyId) {
|
||||||
|
controlEmitter.to(`team:${teamId}:policy:${policyId}`).emit("message", {
|
||||||
|
type: "policy",
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Broadcast to all team instances
|
||||||
|
controlEmitter.to(`team:${teamId}`).emit("message", {
|
||||||
|
type: "policy",
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a command to all SDK instances for a team
|
||||||
|
*/
|
||||||
|
emitCommand(teamId: string | number, command: { action: string; [key: string]: unknown }): void {
|
||||||
|
console.log(`[Aden Control WS] Broadcasting command: ${command.action}`);
|
||||||
|
|
||||||
|
controlEmitter.to(`team:${teamId}`).emit("message", {
|
||||||
|
type: "command",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit alert to team instances
|
||||||
|
*/
|
||||||
|
emitAlert(teamId: string | number, policyId: string | null, alert: unknown): void {
|
||||||
|
console.log(`[Aden Control WS] Broadcasting alert for team ${teamId}`);
|
||||||
|
|
||||||
|
const room = policyId ? `team:${teamId}:policy:${policyId}` : `team:${teamId}`;
|
||||||
|
controlEmitter.to(room).emit("message", {
|
||||||
|
type: "alert",
|
||||||
|
alert,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit to a specific SDK instance
|
||||||
|
*/
|
||||||
|
emitToInstance(teamId: string | number, instanceId: string, message: unknown): boolean {
|
||||||
|
const instances = connectedInstances.get(String(teamId));
|
||||||
|
if (!instances) return false;
|
||||||
|
|
||||||
|
for (const [, info] of instances) {
|
||||||
|
if (info.instanceId === instanceId) {
|
||||||
|
info.socket.emit("message", message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connected instance count for a team (WebSocket + HTTP)
|
||||||
|
*/
|
||||||
|
getConnectedCount(teamId: string | number): number {
|
||||||
|
const teamKey = String(teamId);
|
||||||
|
const wsCount = connectedInstances.get(teamKey)?.size || 0;
|
||||||
|
const httpCount = httpInstances.get(teamKey)?.size || 0;
|
||||||
|
return wsCount + httpCount;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all connected instances info (for dashboard)
|
||||||
|
* Includes both WebSocket and HTTP-only agents
|
||||||
|
*/
|
||||||
|
getConnectedInstances(teamId: string | number): Array<{
|
||||||
|
instance_id: string;
|
||||||
|
policy_id: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
connection_type: "websocket" | "http";
|
||||||
|
status?: string;
|
||||||
|
}> {
|
||||||
|
const teamKey = String(teamId);
|
||||||
|
const results: Array<{
|
||||||
|
instance_id: string;
|
||||||
|
policy_id: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
connection_type: "websocket" | "http";
|
||||||
|
status?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Add WebSocket-connected instances
|
||||||
|
const wsInstances = connectedInstances.get(teamKey);
|
||||||
|
if (wsInstances) {
|
||||||
|
for (const info of wsInstances.values()) {
|
||||||
|
results.push({
|
||||||
|
instance_id: info.instanceId,
|
||||||
|
policy_id: info.policyId,
|
||||||
|
agent_name: null, // WebSocket connections don't have agent_name yet
|
||||||
|
connected_at: info.connectedAt.toISOString(),
|
||||||
|
last_heartbeat: info.lastHeartbeat.toISOString(),
|
||||||
|
connection_type: "websocket",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add HTTP-only instances
|
||||||
|
const httpInsts = httpInstances.get(teamKey);
|
||||||
|
if (httpInsts) {
|
||||||
|
for (const info of httpInsts.values()) {
|
||||||
|
results.push({
|
||||||
|
instance_id: info.instanceId,
|
||||||
|
policy_id: info.policyId,
|
||||||
|
agent_name: info.agentName,
|
||||||
|
connected_at: info.firstSeen.toISOString(),
|
||||||
|
last_heartbeat: info.lastHeartbeat.toISOString(),
|
||||||
|
connection_type: "http",
|
||||||
|
status: info.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total connected SDK count across all teams (WebSocket + HTTP)
|
||||||
|
*/
|
||||||
|
getTotalConnectedCount(): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const instances of connectedInstances.values()) {
|
||||||
|
total += instances.size;
|
||||||
|
}
|
||||||
|
for (const instances of httpInstances.values()) {
|
||||||
|
total += instances.size;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: Emitter is returned instead of stored globally
|
||||||
|
// Use app.locals.controlEmitter to access in routes
|
||||||
|
|
||||||
|
console.log("[Aden Control WS] WebSocket namespace initialized at /v1/control/ws");
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default initAdenControlSockets;
|
||||||
|
export { setUserDbService, registerHttpAgent };
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* LLMEventBatcher - Batches LLM events for efficient WebSocket delivery
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Per-team in-memory buffers
|
||||||
|
* - 5-second flush interval (configurable)
|
||||||
|
* - Buffer size cap with graceful degradation (drop oldest)
|
||||||
|
* - Payload optimization (only essential fields)
|
||||||
|
* - Periodic cleanup for idle teams
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FLUSH_REASONS = {
|
||||||
|
TIMER: 1,
|
||||||
|
BUFFER_FULL: 2,
|
||||||
|
MANUAL: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type FlushReason = typeof FLUSH_REASONS[keyof typeof FLUSH_REASONS];
|
||||||
|
|
||||||
|
interface TsdbEvent {
|
||||||
|
timestamp?: Date | string;
|
||||||
|
trace_id?: string;
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
agent?: string;
|
||||||
|
cost_total?: number;
|
||||||
|
latency_ms?: number;
|
||||||
|
usage?: {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
};
|
||||||
|
usage_input_tokens?: number;
|
||||||
|
usage_output_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventSummary {
|
||||||
|
timestamp: string | undefined;
|
||||||
|
trace_id: string | undefined;
|
||||||
|
model: string;
|
||||||
|
provider: string | null;
|
||||||
|
agent: string | null;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost: number;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamBuffer {
|
||||||
|
teamId: string;
|
||||||
|
events: EventSummary[];
|
||||||
|
flushTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
lastFlush: Date;
|
||||||
|
droppedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchPayload {
|
||||||
|
type: string;
|
||||||
|
teamId: string;
|
||||||
|
events: EventSummary[];
|
||||||
|
meta: {
|
||||||
|
batchSize: number;
|
||||||
|
droppedCount: number;
|
||||||
|
windowStart: string | undefined;
|
||||||
|
windowEnd: string | undefined;
|
||||||
|
flushReason: FlushReason;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emitter {
|
||||||
|
to: (room: string) => { emit: (event: string, payload: BatchPayload) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatcherOptions {
|
||||||
|
flushIntervalMs?: number;
|
||||||
|
maxBufferSize?: number;
|
||||||
|
maxEventsPerFlush?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LLMEventBatcher {
|
||||||
|
private flushIntervalMs: number;
|
||||||
|
private maxBufferSize: number;
|
||||||
|
private maxEventsPerFlush: number;
|
||||||
|
private teamBuffers: Map<string, TeamBuffer>;
|
||||||
|
private emitter: Emitter | null;
|
||||||
|
private totalEventsBuffered: number;
|
||||||
|
private totalBatchesSent: number;
|
||||||
|
private totalEventsDropped: number;
|
||||||
|
private _cleanupInterval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(options: BatcherOptions = {}) {
|
||||||
|
// Configuration
|
||||||
|
this.flushIntervalMs = options.flushIntervalMs || 5000; // 5 seconds
|
||||||
|
this.maxBufferSize = options.maxBufferSize || 500; // Max events per team buffer
|
||||||
|
this.maxEventsPerFlush = options.maxEventsPerFlush || 100; // Max events per batch
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.teamBuffers = new Map(); // teamId -> TeamBuffer
|
||||||
|
this.emitter = null; // Set by setEmitter()
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
this.totalEventsBuffered = 0;
|
||||||
|
this.totalBatchesSent = 0;
|
||||||
|
this.totalEventsDropped = 0;
|
||||||
|
|
||||||
|
// Start periodic cleanup
|
||||||
|
this._cleanupInterval = setInterval(() => {
|
||||||
|
this.cleanup();
|
||||||
|
}, 300000); // Every 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Socket.IO emitter for broadcasting
|
||||||
|
* Called during control_sockets initialization
|
||||||
|
* @param {Object} controlEmitter - Socket.IO namespace emitter
|
||||||
|
*/
|
||||||
|
setEmitter(controlEmitter: Emitter): void {
|
||||||
|
this.emitter = controlEmitter;
|
||||||
|
console.log("[LLMEventBatcher] Emitter configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add events to the buffer for a team
|
||||||
|
* Called from control_service.js after TSDB insert
|
||||||
|
* @param {string|number} teamId - Team identifier
|
||||||
|
* @param {Array} tsdbEvents - Array of TSDB events
|
||||||
|
*/
|
||||||
|
add(teamId: string | number, tsdbEvents: TsdbEvent[]): void {
|
||||||
|
if (!tsdbEvents || tsdbEvents.length === 0) return;
|
||||||
|
|
||||||
|
const teamIdStr = String(teamId);
|
||||||
|
|
||||||
|
// Transform to lightweight summaries
|
||||||
|
const summaries = tsdbEvents.map((e) => this._transformToSummary(e));
|
||||||
|
|
||||||
|
// Get or create buffer
|
||||||
|
let buffer = this.teamBuffers.get(teamIdStr);
|
||||||
|
if (!buffer) {
|
||||||
|
buffer = this._createBuffer(teamIdStr);
|
||||||
|
this.teamBuffers.set(teamIdStr, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add events with overflow handling
|
||||||
|
this._addToBuffer(buffer, summaries);
|
||||||
|
|
||||||
|
// Start/reset flush timer if not already running
|
||||||
|
this._scheduleFlush(teamIdStr, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform full TSDB event to lightweight summary
|
||||||
|
* Only includes fields needed for dashboard display
|
||||||
|
* @param {Object} event - Full TSDB event
|
||||||
|
* @returns {Object} Lightweight event summary
|
||||||
|
*/
|
||||||
|
private _transformToSummary(event: TsdbEvent): EventSummary {
|
||||||
|
// Handle both nested usage object (from transformMetricToTsdbEvent)
|
||||||
|
// and flat fields (from TSDB query results)
|
||||||
|
const inputTokens = event.usage?.input_tokens ?? event.usage_input_tokens ?? 0;
|
||||||
|
const outputTokens = event.usage?.output_tokens ?? event.usage_output_tokens ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||||
|
trace_id: event.trace_id,
|
||||||
|
model: event.model || "",
|
||||||
|
provider: event.provider || null,
|
||||||
|
agent: event.agent || null,
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cost: event.cost_total || 0,
|
||||||
|
latency_ms: event.latency_ms || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add events to buffer with overflow handling
|
||||||
|
* @param {Object} buffer - Team buffer
|
||||||
|
* @param {Array} summaries - Event summaries to add
|
||||||
|
*/
|
||||||
|
private _addToBuffer(buffer: TeamBuffer, summaries: EventSummary[]): void {
|
||||||
|
for (const summary of summaries) {
|
||||||
|
if (buffer.events.length >= this.maxBufferSize) {
|
||||||
|
// Drop oldest event
|
||||||
|
buffer.events.shift();
|
||||||
|
buffer.droppedCount++;
|
||||||
|
this.totalEventsDropped++;
|
||||||
|
}
|
||||||
|
buffer.events.push(summary);
|
||||||
|
this.totalEventsBuffered++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force flush if buffer is full
|
||||||
|
if (buffer.events.length >= this.maxBufferSize) {
|
||||||
|
this._flush(buffer.teamId, FLUSH_REASONS.BUFFER_FULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule flush timer for a team
|
||||||
|
* @param {string} teamId - Team identifier
|
||||||
|
* @param {Object} buffer - Team buffer
|
||||||
|
*/
|
||||||
|
private _scheduleFlush(teamId: string, buffer: TeamBuffer): void {
|
||||||
|
// Don't reschedule if timer already running
|
||||||
|
if (buffer.flushTimer) return;
|
||||||
|
|
||||||
|
buffer.flushTimer = setTimeout(() => {
|
||||||
|
this._flush(teamId, FLUSH_REASONS.TIMER);
|
||||||
|
}, this.flushIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush buffered events to WebSocket
|
||||||
|
* @param {string} teamId - Team identifier
|
||||||
|
* @param {number} flushReason - Reason for flush
|
||||||
|
*/
|
||||||
|
private _flush(teamId: string, flushReason: FlushReason): void {
|
||||||
|
const buffer = this.teamBuffers.get(teamId);
|
||||||
|
if (!buffer || buffer.events.length === 0) return;
|
||||||
|
|
||||||
|
// Clear timer
|
||||||
|
if (buffer.flushTimer) {
|
||||||
|
clearTimeout(buffer.flushTimer);
|
||||||
|
buffer.flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract batch (up to maxEventsPerFlush)
|
||||||
|
const batch = buffer.events.splice(0, this.maxEventsPerFlush);
|
||||||
|
const droppedCount = buffer.droppedCount;
|
||||||
|
buffer.droppedCount = 0;
|
||||||
|
buffer.lastFlush = new Date();
|
||||||
|
|
||||||
|
// Build payload
|
||||||
|
const payload: BatchPayload = {
|
||||||
|
type: "llm-events-batch",
|
||||||
|
teamId: teamId,
|
||||||
|
events: batch,
|
||||||
|
meta: {
|
||||||
|
batchSize: batch.length,
|
||||||
|
droppedCount: droppedCount,
|
||||||
|
windowStart: batch[0]?.timestamp,
|
||||||
|
windowEnd: batch[batch.length - 1]?.timestamp,
|
||||||
|
flushReason: flushReason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit to team room
|
||||||
|
if (this.emitter) {
|
||||||
|
const room = `team:${teamId}:llm-events`;
|
||||||
|
this.emitter.to(room).emit("message", payload);
|
||||||
|
this.totalBatchesSent++;
|
||||||
|
|
||||||
|
if (batch.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[LLMEventBatcher] Flushed ${batch.length} events to ${room} ` +
|
||||||
|
`(dropped: ${droppedCount}, reason: ${flushReason})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next flush if buffer still has events
|
||||||
|
if (buffer.events.length > 0) {
|
||||||
|
this._scheduleFlush(teamId, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new buffer for a team
|
||||||
|
* @param {string} teamId - Team identifier
|
||||||
|
* @returns {Object} New team buffer
|
||||||
|
*/
|
||||||
|
private _createBuffer(teamId: string): TeamBuffer {
|
||||||
|
return {
|
||||||
|
teamId: teamId,
|
||||||
|
events: [],
|
||||||
|
flushTimer: null,
|
||||||
|
lastFlush: new Date(),
|
||||||
|
droppedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually flush all buffers (useful for shutdown)
|
||||||
|
*/
|
||||||
|
flushAll(): void {
|
||||||
|
for (const [teamId] of this.teamBuffers) {
|
||||||
|
this._flush(teamId, FLUSH_REASONS.MANUAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metrics for monitoring
|
||||||
|
* @returns {Object} Batcher metrics
|
||||||
|
*/
|
||||||
|
getMetrics(): { activeTeams: number; totalBuffered: number; totalEventsBuffered: number; totalBatchesSent: number; totalEventsDropped: number } {
|
||||||
|
const activeTeams = this.teamBuffers.size;
|
||||||
|
const totalBuffered = Array.from(this.teamBuffers.values()).reduce(
|
||||||
|
(sum, b) => sum + b.events.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTeams,
|
||||||
|
totalBuffered,
|
||||||
|
totalEventsBuffered: this.totalEventsBuffered,
|
||||||
|
totalBatchesSent: this.totalBatchesSent,
|
||||||
|
totalEventsDropped: this.totalEventsDropped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup buffers for teams with no recent activity
|
||||||
|
* Prevents memory leaks from inactive teams
|
||||||
|
* @param {number} maxIdleMs - Max idle time before cleanup (default: 5 minutes)
|
||||||
|
*/
|
||||||
|
cleanup(maxIdleMs = 300000): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleaned = 0;
|
||||||
|
|
||||||
|
for (const [teamId, buffer] of this.teamBuffers.entries()) {
|
||||||
|
if (buffer.events.length === 0 && now - buffer.lastFlush.getTime() > maxIdleMs) {
|
||||||
|
if (buffer.flushTimer) {
|
||||||
|
clearTimeout(buffer.flushTimer);
|
||||||
|
}
|
||||||
|
this.teamBuffers.delete(teamId);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned > 0) {
|
||||||
|
console.log(`[LLMEventBatcher] Cleaned up ${cleaned} idle team buffers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the batcher (cleanup intervals and flush remaining)
|
||||||
|
*/
|
||||||
|
shutdown(): void {
|
||||||
|
if (this._cleanupInterval) {
|
||||||
|
clearInterval(this._cleanupInterval);
|
||||||
|
}
|
||||||
|
this.flushAll();
|
||||||
|
console.log("[LLMEventBatcher] Shutdown complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
const llmEventBatcher = new LLMEventBatcher();
|
||||||
|
|
||||||
|
export default llmEventBatcher;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import config from "../../config";
|
||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
|
||||||
|
declare const _ACHO_MG_DB: undefined | { db: (name: string) => unknown };
|
||||||
|
|
||||||
|
let client: MongoClient | null = null;
|
||||||
|
|
||||||
|
const getMongoClient = async (): Promise<MongoClient> => {
|
||||||
|
if (client) return client;
|
||||||
|
if (!config.mongodb.url) {
|
||||||
|
throw new Error("Missing MONGODB_URL in environment");
|
||||||
|
}
|
||||||
|
client = new MongoClient(config.mongodb.url);
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMongoDb = async (dbName = config.mongodb.dbName): Promise<unknown> => {
|
||||||
|
if (typeof _ACHO_MG_DB !== "undefined" && _ACHO_MG_DB && typeof _ACHO_MG_DB.db === "function") {
|
||||||
|
return _ACHO_MG_DB.db(dbName);
|
||||||
|
}
|
||||||
|
const c = await getMongoClient();
|
||||||
|
return c.db(dbName);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getMongoDb };
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Quickstart Document Generation Service
|
||||||
|
* Template-based SDK quickstart documentation generator
|
||||||
|
*
|
||||||
|
* Structure:
|
||||||
|
* - docs/aden-sdk-documents/config/*.json - Configuration for vendors, languages, frameworks
|
||||||
|
* - docs/aden-sdk-documents/templates/{language}/*.md - Complete template files
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Base paths
|
||||||
|
const DOCS_BASE = path.join(__dirname, "../../../docs/aden-sdk-documents");
|
||||||
|
const CONFIG_PATH = path.join(DOCS_BASE, "config");
|
||||||
|
const TEMPLATES_PATH = path.join(DOCS_BASE, "templates");
|
||||||
|
|
||||||
|
interface VendorConfig {
|
||||||
|
name: string;
|
||||||
|
envVarComment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageConfig {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FrameworkConfig {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
templateFile: string;
|
||||||
|
pythonSupport: boolean;
|
||||||
|
typescriptSupport: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigCache {
|
||||||
|
vendors: Record<string, VendorConfig>;
|
||||||
|
languages: Record<string, LanguageConfig>;
|
||||||
|
frameworks: Record<string, FrameworkConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for configs and templates
|
||||||
|
let configCache: ConfigCache | null = null;
|
||||||
|
let templateCache: Record<string, string> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all configuration files
|
||||||
|
*/
|
||||||
|
function loadConfigs(): ConfigCache {
|
||||||
|
if (configCache) return configCache;
|
||||||
|
|
||||||
|
configCache = {
|
||||||
|
vendors: JSON.parse(
|
||||||
|
fs.readFileSync(path.join(CONFIG_PATH, "llm-vendors.json"), "utf-8")
|
||||||
|
),
|
||||||
|
languages: JSON.parse(
|
||||||
|
fs.readFileSync(path.join(CONFIG_PATH, "sdk-languages.json"), "utf-8")
|
||||||
|
),
|
||||||
|
frameworks: JSON.parse(
|
||||||
|
fs.readFileSync(path.join(CONFIG_PATH, "agent-frameworks.json"), "utf-8")
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a template file
|
||||||
|
*/
|
||||||
|
function loadTemplate(language: string, templateName: string): string | null {
|
||||||
|
const cacheKey = `${language}/${templateName}`;
|
||||||
|
if (templateCache[cacheKey]) return templateCache[cacheKey];
|
||||||
|
|
||||||
|
const templatePath = path.join(
|
||||||
|
TEMPLATES_PATH,
|
||||||
|
language,
|
||||||
|
`${templateName}.md`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(templatePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
templateCache[cacheKey] = fs.readFileSync(templatePath, "utf-8");
|
||||||
|
return templateCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear caches (useful for development/testing)
|
||||||
|
*/
|
||||||
|
function clearCaches(): void {
|
||||||
|
configCache = null;
|
||||||
|
templateCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace variables in template: {{variableName}}
|
||||||
|
*/
|
||||||
|
function replaceVariables(
|
||||||
|
template: string,
|
||||||
|
variables: Record<string, string>
|
||||||
|
): string {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||||
|
return variables[key] !== undefined ? variables[key] : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateQuickstartParams {
|
||||||
|
llmVendor?: string;
|
||||||
|
sdkLanguage?: string;
|
||||||
|
agentFramework: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate quickstart document based on parameters
|
||||||
|
*/
|
||||||
|
function generateQuickstart({
|
||||||
|
llmVendor = "openai",
|
||||||
|
sdkLanguage = "python",
|
||||||
|
agentFramework,
|
||||||
|
apiKey,
|
||||||
|
}: GenerateQuickstartParams): string {
|
||||||
|
const config = loadConfigs();
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (!config.vendors[llmVendor]) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid LLM vendor: ${llmVendor}. Valid options: ${Object.keys(
|
||||||
|
config.vendors
|
||||||
|
).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!config.languages[sdkLanguage]) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid SDK language: ${sdkLanguage}. Valid options: ${Object.keys(
|
||||||
|
config.languages
|
||||||
|
).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!config.frameworks[agentFramework]) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid agent framework: ${agentFramework}. Valid options: ${Object.keys(
|
||||||
|
config.frameworks
|
||||||
|
).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("API key is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = config.vendors[llmVendor];
|
||||||
|
const framework = config.frameworks[agentFramework];
|
||||||
|
|
||||||
|
// Check language support
|
||||||
|
if (sdkLanguage === "python" && !framework.pythonSupport) {
|
||||||
|
throw new Error(`${framework.name} does not support Python`);
|
||||||
|
}
|
||||||
|
if (sdkLanguage !== "python" && !framework.typescriptSupport) {
|
||||||
|
throw new Error(`${framework.name} does not support ${sdkLanguage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load template
|
||||||
|
const template = loadTemplate(sdkLanguage, framework.templateFile);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(
|
||||||
|
`Template not found: ${sdkLanguage}/${framework.templateFile}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build variables
|
||||||
|
const variables: Record<string, string> = {
|
||||||
|
apiKey,
|
||||||
|
serverUrl: process.env.HIVE_HOST || "https://hive.adenhq.com",
|
||||||
|
envVarComment: vendor.envVarComment || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace variables and return
|
||||||
|
return replaceVariables(template, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickstartOptions {
|
||||||
|
llmVendors: Array<{ id: string; name: string }>;
|
||||||
|
sdkLanguages: Array<{ id: string; name: string }>;
|
||||||
|
agentFrameworks: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
pythonSupport: boolean;
|
||||||
|
typescriptSupport: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available options for quickstart generation
|
||||||
|
*/
|
||||||
|
function getQuickstartOptions(): QuickstartOptions {
|
||||||
|
const config = loadConfigs();
|
||||||
|
|
||||||
|
return {
|
||||||
|
llmVendors: Object.entries(config.vendors).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
})),
|
||||||
|
sdkLanguages: Object.entries(config.languages).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
})),
|
||||||
|
agentFrameworks: Object.entries(config.frameworks).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
pythonSupport: value.pythonSupport,
|
||||||
|
typescriptSupport: value.typescriptSupport,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload configs (useful after updating config files)
|
||||||
|
*/
|
||||||
|
function reloadConfigs(): ConfigCache {
|
||||||
|
clearCaches();
|
||||||
|
return loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { generateQuickstart, getQuickstartOptions, reloadConfigs, clearCaches };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Initialize TimescaleDB extension
|
||||||
|
-- This must run BEFORE schema.sql to enable hypertables and continuous aggregates
|
||||||
|
|
||||||
|
-- Create TimescaleDB extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
|
||||||
|
-- Log successful initialization
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'TimescaleDB extension initialized successfully';
|
||||||
|
END$$;
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
/**
|
||||||
|
* TSDB Analytics Service
|
||||||
|
* Computes windowed aggregations from llm_events for dashboard analytics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PoolClient } from 'pg';
|
||||||
|
import pricingService from './pricing_service';
|
||||||
|
|
||||||
|
const BUCKETS = [
|
||||||
|
{ label: '0-1s', min: 0, max: 1000 },
|
||||||
|
{ label: '1-2s', min: 1000, max: 2000 },
|
||||||
|
{ label: '2-5s', min: 2000, max: 5000 },
|
||||||
|
{ label: '5-10s', min: 5000, max: 10000 },
|
||||||
|
{ label: '10-20s', min: 10000, max: 20000 },
|
||||||
|
{ label: '20s+', min: 20000, max: null as number | null },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface WindowDef {
|
||||||
|
label: string;
|
||||||
|
start: Date | null;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyRow {
|
||||||
|
bucket: string;
|
||||||
|
requests: number;
|
||||||
|
cost_total: number;
|
||||||
|
tokens: {
|
||||||
|
total: number;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cached: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LatencyRow {
|
||||||
|
bucket: string;
|
||||||
|
count: number;
|
||||||
|
avg_ms: number | null;
|
||||||
|
p50_ms: number | null;
|
||||||
|
p95_ms: number | null;
|
||||||
|
p99_ms: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelCostRow {
|
||||||
|
model: string;
|
||||||
|
cost_total: number;
|
||||||
|
cached_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentCostRow {
|
||||||
|
agent: string;
|
||||||
|
requests: number;
|
||||||
|
cost_total: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
avg_latency_ms: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNumber = (val: unknown, fallback = 0): number => {
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentile = (values: number[], pct: number): number | null => {
|
||||||
|
if (!values.length) return null;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
if (sorted.length === 1) return sorted[0];
|
||||||
|
const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor(pct * (sorted.length - 1))));
|
||||||
|
return sorted[idx];
|
||||||
|
};
|
||||||
|
|
||||||
|
const startOfWeekUtc = (d: Date): Date => {
|
||||||
|
const day = d.getUTCDay();
|
||||||
|
const diff = (day + 6) % 7;
|
||||||
|
const monday = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0));
|
||||||
|
monday.setUTCDate(monday.getUTCDate() - diff);
|
||||||
|
return monday;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startOfMonthUtc = (d: Date): Date =>
|
||||||
|
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||||
|
|
||||||
|
const startOfDayUtc = (d: Date): Date =>
|
||||||
|
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0));
|
||||||
|
|
||||||
|
export const parseAnalyticsWindow = (label: string): WindowDef => {
|
||||||
|
const now = new Date();
|
||||||
|
switch ((label || '').toLowerCase()) {
|
||||||
|
case 'all_time':
|
||||||
|
case 'all-time':
|
||||||
|
case 'alltime':
|
||||||
|
return { label: 'all_time', start: null, end: now };
|
||||||
|
case 'today': {
|
||||||
|
const start = startOfDayUtc(now);
|
||||||
|
return { label: 'today', start, end: now };
|
||||||
|
}
|
||||||
|
case 'last_2_weeks':
|
||||||
|
case 'last-2-weeks':
|
||||||
|
case 'last2weeks': {
|
||||||
|
const start = new Date(now.getTime() - 14 * 24 * 3600 * 1000);
|
||||||
|
return { label: 'last_2_weeks', start, end: now };
|
||||||
|
}
|
||||||
|
case 'this_week': {
|
||||||
|
const start = startOfWeekUtc(now);
|
||||||
|
return { label: 'this_week', start, end: now };
|
||||||
|
}
|
||||||
|
case 'this_month':
|
||||||
|
default: {
|
||||||
|
const start = startOfMonthUtc(now);
|
||||||
|
return { label: 'this_month', start, end: now };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bucketLatency = (latMs: number, buckets: typeof BUCKETS): string | null => {
|
||||||
|
if (latMs === null || latMs === undefined) return null;
|
||||||
|
for (const b of buckets) {
|
||||||
|
if (b.max === null) {
|
||||||
|
if (latMs >= b.min) return b.label;
|
||||||
|
} else if (latMs >= b.min && latMs < b.max) {
|
||||||
|
return b.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLatencyDistribution = (rows: { bucket: string; count: number }[]) => {
|
||||||
|
const counts = new Map(rows.map((r) => [r.bucket, r.count]));
|
||||||
|
const total = rows.reduce((acc, r) => acc + (r.count || 0), 0);
|
||||||
|
return BUCKETS.map((b) => {
|
||||||
|
const count = counts.get(b.label) || 0;
|
||||||
|
return {
|
||||||
|
bucket: b.label,
|
||||||
|
count,
|
||||||
|
share: total ? count / total : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bucketLabel = (date: Date, resolution: string): string => {
|
||||||
|
if (resolution === 'hour') {
|
||||||
|
const h = new Date(
|
||||||
|
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), 0, 0, 0)
|
||||||
|
);
|
||||||
|
return h.toISOString().slice(0, 13) + ':00:00Z';
|
||||||
|
}
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDailyCA = async ({
|
||||||
|
client,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}: {
|
||||||
|
client: PoolClient;
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
}): Promise<DailyRow[]> => {
|
||||||
|
const params: (Date | null)[] = [];
|
||||||
|
const conds: string[] = [];
|
||||||
|
if (start) {
|
||||||
|
params.push(start);
|
||||||
|
conds.push(`bucket >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
params.push(end);
|
||||||
|
conds.push(`bucket < $${params.length}`);
|
||||||
|
}
|
||||||
|
const sql = `
|
||||||
|
SELECT bucket, requests, cost_total, input_tokens, output_tokens, total_tokens, cached_tokens
|
||||||
|
FROM llm_events_daily_ca
|
||||||
|
${conds.length ? `WHERE ${conds.join(' AND ')}` : ''}
|
||||||
|
ORDER BY bucket ASC
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
bucket: r.bucket instanceof Date ? r.bucket.toISOString().slice(0, 10) : r.bucket,
|
||||||
|
requests: Number(r.requests) || 0,
|
||||||
|
cost_total: toNumber(r.cost_total, 0),
|
||||||
|
tokens: {
|
||||||
|
total: toNumber(r.total_tokens, 0),
|
||||||
|
input: toNumber(r.input_tokens, 0),
|
||||||
|
output: toNumber(r.output_tokens, 0),
|
||||||
|
cached: toNumber(r.cached_tokens, 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTodayFromBaseTable = async ({
|
||||||
|
client,
|
||||||
|
todayStart,
|
||||||
|
end,
|
||||||
|
}: {
|
||||||
|
client: PoolClient;
|
||||||
|
todayStart: Date;
|
||||||
|
end: Date;
|
||||||
|
}): Promise<DailyRow | null> => {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
$1::date as bucket,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(cost_total), 0) as cost_total,
|
||||||
|
COALESCE(SUM(usage_input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(usage_output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(COALESCE(usage_total_tokens, usage_input_tokens + usage_output_tokens)), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(usage_cached_tokens), 0) as cached_tokens
|
||||||
|
FROM llm_events
|
||||||
|
WHERE "timestamp" >= $1 AND "timestamp" <= $2
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, [todayStart, end]);
|
||||||
|
if (!rows.length || rows[0].requests === 0 || rows[0].requests === '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const r = rows[0];
|
||||||
|
return {
|
||||||
|
bucket: todayStart.toISOString().slice(0, 10),
|
||||||
|
requests: Number(r.requests) || 0,
|
||||||
|
cost_total: toNumber(r.cost_total, 0),
|
||||||
|
tokens: {
|
||||||
|
total: toNumber(r.total_tokens, 0),
|
||||||
|
input: toNumber(r.input_tokens, 0),
|
||||||
|
output: toNumber(r.output_tokens, 0),
|
||||||
|
cached: toNumber(r.cached_tokens, 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLatencyDaily = async ({
|
||||||
|
client,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}: {
|
||||||
|
client: PoolClient;
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
}): Promise<LatencyRow[]> => {
|
||||||
|
const params: (string | Date)[] = ['1 day'];
|
||||||
|
const conds = ['latency_ms IS NOT NULL'];
|
||||||
|
if (start) {
|
||||||
|
params.push(start);
|
||||||
|
conds.push(`"timestamp" >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
params.push(end);
|
||||||
|
conds.push(`"timestamp" < $${params.length}`);
|
||||||
|
}
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
time_bucket($1::interval, "timestamp") AS bucket,
|
||||||
|
COUNT(latency_ms) AS count,
|
||||||
|
AVG(latency_ms) AS avg_ms,
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY latency_ms) AS p50_ms,
|
||||||
|
percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_ms,
|
||||||
|
percentile_cont(0.99) WITHIN GROUP (ORDER BY latency_ms) AS p99_ms
|
||||||
|
FROM llm_events
|
||||||
|
WHERE ${conds.join(' AND ')}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 ASC
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
bucket: r.bucket instanceof Date ? r.bucket.toISOString().slice(0, 10) : r.bucket,
|
||||||
|
count: Number(r.count) || 0,
|
||||||
|
avg_ms: r.avg_ms === null ? null : Number(r.avg_ms),
|
||||||
|
p50_ms: r.p50_ms === null ? null : Number(r.p50_ms),
|
||||||
|
p95_ms: r.p95_ms === null ? null : Number(r.p95_ms),
|
||||||
|
p99_ms: r.p99_ms === null ? null : Number(r.p99_ms),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLatencyDistributionDaily = async ({
|
||||||
|
client,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}: {
|
||||||
|
client: PoolClient;
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
}): Promise<{ bucket: string; count: number }[]> => {
|
||||||
|
const params: Date[] = [];
|
||||||
|
const conds = ['latency_ms IS NOT NULL'];
|
||||||
|
if (start) {
|
||||||
|
params.push(start);
|
||||||
|
conds.push(`"timestamp" >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
params.push(end);
|
||||||
|
conds.push(`"timestamp" < $${params.length}`);
|
||||||
|
}
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN latency_ms < 1000 THEN '0-1s'
|
||||||
|
WHEN latency_ms < 2000 THEN '1-2s'
|
||||||
|
WHEN latency_ms < 5000 THEN '2-5s'
|
||||||
|
WHEN latency_ms < 10000 THEN '5-10s'
|
||||||
|
WHEN latency_ms < 20000 THEN '10-20s'
|
||||||
|
ELSE '20s+'
|
||||||
|
END AS bucket,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM llm_events
|
||||||
|
WHERE ${conds.join(' AND ')}
|
||||||
|
GROUP BY 1
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
return rows.map((r: any) => ({ bucket: r.bucket, count: Number(r.count) || 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchModelCost = async ({
|
||||||
|
client,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}: {
|
||||||
|
client: PoolClient;
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
}): Promise<ModelCostRow[]> => {
|
||||||
|
const params: Date[] = [];
|
||||||
|
const conds: string[] = [];
|
||||||
|
if (start) {
|
||||||
|
params.push(start);
|
||||||
|
conds.push(`"timestamp" >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
params.push(end);
|
||||||
|
conds.push(`"timestamp" < $${params.length}`);
|
||||||
|
}
|
||||||
|
const sql = `
|
||||||
|
SELECT model,
|
||||||
|
SUM(cost_total) AS cost_total,
|
||||||
|
SUM(usage_cached_tokens) AS cached_tokens
|
||||||
|
FROM llm_events
|
||||||
|
${conds.length ? `WHERE ${conds.join(' AND ')}` : ''}
|
||||||
|
GROUP BY model
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
return rows
|
||||||
|
.filter((r: any) => r.model)
|
||||||
|
.map((r: any) => ({
|
||||||
|
model: r.model,
|
||||||
|
cost_total: toNumber(r.cost_total, 0),
|
||||||
|
cached_tokens: toNumber(r.cached_tokens, 0),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAgentCost = async ({
|
||||||
|
client,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}: {
|
||||||
|
client: PoolClient;
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
}): Promise<AgentCostRow[]> => {
|
||||||
|
const params: Date[] = [];
|
||||||
|
const conds: string[] = [];
|
||||||
|
if (start) {
|
||||||
|
params.push(start);
|
||||||
|
conds.push(`"timestamp" >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
params.push(end);
|
||||||
|
conds.push(`"timestamp" < $${params.length}`);
|
||||||
|
}
|
||||||
|
const sql = `
|
||||||
|
SELECT agent,
|
||||||
|
COUNT(*) AS requests,
|
||||||
|
SUM(cost_total) AS cost_total,
|
||||||
|
SUM(usage_input_tokens) AS input_tokens,
|
||||||
|
SUM(usage_output_tokens) AS output_tokens,
|
||||||
|
AVG(latency_ms) AS avg_latency_ms
|
||||||
|
FROM llm_events
|
||||||
|
${conds.length ? `WHERE ${conds.join(' AND ')}` : ''}
|
||||||
|
GROUP BY agent
|
||||||
|
`;
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
return rows
|
||||||
|
.filter((r: any) => r.agent)
|
||||||
|
.map((r: any) => ({
|
||||||
|
agent: r.agent,
|
||||||
|
requests: Number(r.requests) || 0,
|
||||||
|
cost_total: toNumber(r.cost_total, 0),
|
||||||
|
input_tokens: toNumber(r.input_tokens, 0),
|
||||||
|
output_tokens: toNumber(r.output_tokens, 0),
|
||||||
|
avg_latency_ms: r.avg_latency_ms === null ? null : Number(r.avg_latency_ms),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAnalytics = async ({
|
||||||
|
windowLabel,
|
||||||
|
client,
|
||||||
|
resolution = 'day',
|
||||||
|
}: {
|
||||||
|
windowLabel: string;
|
||||||
|
client: PoolClient;
|
||||||
|
resolution?: 'day' | 'hour';
|
||||||
|
}) => {
|
||||||
|
const windowDef = parseAnalyticsWindow(windowLabel);
|
||||||
|
|
||||||
|
if (resolution === 'day') {
|
||||||
|
try {
|
||||||
|
const now = windowDef.end || new Date();
|
||||||
|
const todayMidnight = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const caRows = await fetchDailyCA({ client, start: windowDef.start, end: todayMidnight });
|
||||||
|
|
||||||
|
let todayData: DailyRow | null = null;
|
||||||
|
if (now >= todayMidnight) {
|
||||||
|
try {
|
||||||
|
todayData = await fetchTodayFromBaseTable({ client, todayStart: todayMidnight, end: now });
|
||||||
|
} catch {
|
||||||
|
// Ignore errors fetching today's data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRows = [...(caRows || [])];
|
||||||
|
if (todayData) {
|
||||||
|
const todayBucket = todayData.bucket;
|
||||||
|
const existingIdx = allRows.findIndex((r) => r.bucket === todayBucket);
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
allRows[existingIdx] = todayData;
|
||||||
|
} else {
|
||||||
|
allRows.push(todayData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allRows && allRows.length) {
|
||||||
|
const total_cost = allRows.reduce((acc, r) => acc + (r.cost_total || 0), 0);
|
||||||
|
const total_requests = allRows.reduce((acc, r) => acc + (r.requests || 0), 0);
|
||||||
|
const total_tokens = allRows.reduce((acc, r) => acc + (r.tokens.total || 0), 0);
|
||||||
|
|
||||||
|
const bucket_cost = allRows.map((r) => ({ bucket: r.bucket, cost_total: r.cost_total }));
|
||||||
|
const bucket_requests = allRows.map((r) => ({ bucket: r.bucket, requests: r.requests }));
|
||||||
|
const bucket_tokens = allRows.map((r) => ({
|
||||||
|
bucket: r.bucket,
|
||||||
|
total_tokens: r.tokens.total,
|
||||||
|
input_tokens: r.tokens.input,
|
||||||
|
output_tokens: r.tokens.output,
|
||||||
|
cached_tokens: r.tokens.cached,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const latencyBuckets = await fetchLatencyDaily({
|
||||||
|
client,
|
||||||
|
start: windowDef.start,
|
||||||
|
end: windowDef.end,
|
||||||
|
});
|
||||||
|
const latencyDistributionRows = await fetchLatencyDistributionDaily({
|
||||||
|
client,
|
||||||
|
start: windowDef.start,
|
||||||
|
end: windowDef.end,
|
||||||
|
});
|
||||||
|
const latency_distribution = buildLatencyDistribution(latencyDistributionRows);
|
||||||
|
const latency_total = latencyDistributionRows.reduce((acc, r) => acc + (r.count || 0), 0);
|
||||||
|
const avg_latency_ms =
|
||||||
|
latencyBuckets.reduce(
|
||||||
|
(acc, r) => acc + (r.avg_ms !== null ? r.avg_ms * (r.count || 0) : 0),
|
||||||
|
0
|
||||||
|
) / (latency_total || 1);
|
||||||
|
|
||||||
|
const modelRows = await fetchModelCost({ client, start: windowDef.start, end: windowDef.end });
|
||||||
|
const models = modelRows
|
||||||
|
.sort((a, b) => (b.cost_total || 0) - (a.cost_total || 0))
|
||||||
|
.map((r) => ({
|
||||||
|
model: r.model,
|
||||||
|
cost_total: r.cost_total,
|
||||||
|
share: total_cost ? r.cost_total / total_cost : null,
|
||||||
|
}));
|
||||||
|
const cache_savings = modelRows.reduce((acc, r) => {
|
||||||
|
const pricing = pricingService.getModelPricingSync(r.model || '');
|
||||||
|
return acc + (r.cached_tokens / 1_000_000) * pricing.input;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const agentRows = await fetchAgentCost({ client, start: windowDef.start, end: windowDef.end });
|
||||||
|
const agents = agentRows
|
||||||
|
.sort((a, b) => (b.cost_total || 0) - (a.cost_total || 0))
|
||||||
|
.map((r) => ({
|
||||||
|
agent: r.agent,
|
||||||
|
requests: r.requests,
|
||||||
|
cost_total: r.cost_total,
|
||||||
|
share: total_cost ? r.cost_total / total_cost : null,
|
||||||
|
avg_latency_ms: r.avg_latency_ms,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: {
|
||||||
|
label: windowDef.label,
|
||||||
|
start: windowDef.start ? windowDef.start.toISOString() : null,
|
||||||
|
end: windowDef.end ? windowDef.end.toISOString() : null,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
total_cost,
|
||||||
|
total_requests,
|
||||||
|
total_tokens,
|
||||||
|
avg_latency_ms: Number.isFinite(avg_latency_ms) ? avg_latency_ms : null,
|
||||||
|
cache_savings,
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
resolution: 'day',
|
||||||
|
daily: {
|
||||||
|
cost: bucket_cost,
|
||||||
|
requests: bucket_requests,
|
||||||
|
tokens: bucket_tokens,
|
||||||
|
latency_percentiles: latencyBuckets,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cost_by_model: {
|
||||||
|
total_cost,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
cost_by_agent: {
|
||||||
|
total_cost,
|
||||||
|
agents,
|
||||||
|
},
|
||||||
|
latency_distribution: {
|
||||||
|
total: latency_total,
|
||||||
|
buckets: latency_distribution,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Fall through to base-table path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scan base table directly
|
||||||
|
const params: Date[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (windowDef.start) {
|
||||||
|
params.push(windowDef.start);
|
||||||
|
conditions.push(`"timestamp" >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (windowDef.end) {
|
||||||
|
params.push(windowDef.end);
|
||||||
|
conditions.push(`"timestamp" < $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
"timestamp",
|
||||||
|
model,
|
||||||
|
agent,
|
||||||
|
latency_ms,
|
||||||
|
cost_total,
|
||||||
|
usage_input_tokens,
|
||||||
|
usage_output_tokens,
|
||||||
|
usage_total_tokens,
|
||||||
|
usage_cached_tokens
|
||||||
|
FROM llm_events
|
||||||
|
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
|
||||||
|
ORDER BY "timestamp" ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
|
||||||
|
const bucketCost = new Map<string, number>();
|
||||||
|
const bucketRequests = new Map<string, number>();
|
||||||
|
const bucketTokens = new Map<string, { total: number; input: number; output: number; cached: number }>();
|
||||||
|
const bucketLatencies = new Map<string, number[]>();
|
||||||
|
const modelCost = new Map<string, number>();
|
||||||
|
const agentStats = new Map<string, { cost: number; requests: number; latencies: number[] }>();
|
||||||
|
const latencyBucketCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalRequests = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
let totalLatency = 0;
|
||||||
|
let latencyCount = 0;
|
||||||
|
let cacheSavings = 0;
|
||||||
|
|
||||||
|
rows.forEach((r: any) => {
|
||||||
|
const ts = r.timestamp instanceof Date ? r.timestamp : new Date(r.timestamp);
|
||||||
|
if (!ts || Number.isNaN(ts.getTime())) return;
|
||||||
|
const bucket = bucketLabel(ts, resolution);
|
||||||
|
|
||||||
|
const cost = toNumber(r.cost_total, 0);
|
||||||
|
const inTok = toNumber(r.usage_input_tokens, 0);
|
||||||
|
const outTok = toNumber(r.usage_output_tokens, 0);
|
||||||
|
const totalTokRaw = toNumber(r.usage_total_tokens, inTok + outTok);
|
||||||
|
const cachedTok = toNumber(r.usage_cached_tokens, 0);
|
||||||
|
const lat = r.latency_ms === null || r.latency_ms === undefined ? null : Number(r.latency_ms);
|
||||||
|
|
||||||
|
totalRequests += 1;
|
||||||
|
totalCost += cost;
|
||||||
|
totalTokens += totalTokRaw;
|
||||||
|
if (lat !== null && !Number.isNaN(lat)) {
|
||||||
|
totalLatency += lat;
|
||||||
|
latencyCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketCost.set(bucket, (bucketCost.get(bucket) || 0) + cost);
|
||||||
|
bucketRequests.set(bucket, (bucketRequests.get(bucket) || 0) + 1);
|
||||||
|
const tok = bucketTokens.get(bucket) || { total: 0, input: 0, output: 0, cached: 0 };
|
||||||
|
tok.total += totalTokRaw;
|
||||||
|
tok.input += inTok;
|
||||||
|
tok.output += outTok;
|
||||||
|
tok.cached += cachedTok;
|
||||||
|
bucketTokens.set(bucket, tok);
|
||||||
|
|
||||||
|
if (lat !== null && !Number.isNaN(lat)) {
|
||||||
|
const arr = bucketLatencies.get(bucket) || [];
|
||||||
|
arr.push(lat);
|
||||||
|
bucketLatencies.set(bucket, arr);
|
||||||
|
|
||||||
|
const latBucket = bucketLatency(lat, BUCKETS);
|
||||||
|
if (latBucket) latencyBucketCounts.set(latBucket, (latencyBucketCounts.get(latBucket) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.model) {
|
||||||
|
modelCost.set(r.model, (modelCost.get(r.model) || 0) + cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.agent) {
|
||||||
|
const stats = agentStats.get(r.agent) || { cost: 0, requests: 0, latencies: [] };
|
||||||
|
stats.cost += cost;
|
||||||
|
stats.requests += 1;
|
||||||
|
if (lat !== null && !Number.isNaN(lat)) {
|
||||||
|
stats.latencies.push(lat);
|
||||||
|
}
|
||||||
|
agentStats.set(r.agent, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedTok > 0) {
|
||||||
|
const pricing = pricingService.getModelPricingSync(r.model || '');
|
||||||
|
cacheSavings += (cachedTok / 1_000_000) * pricing.input;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedBuckets = Array.from(
|
||||||
|
new Set([
|
||||||
|
...bucketCost.keys(),
|
||||||
|
...bucketRequests.keys(),
|
||||||
|
...bucketTokens.keys(),
|
||||||
|
...bucketLatencies.keys(),
|
||||||
|
])
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
const bucket_cost = sortedBuckets.map((key) => ({ bucket: key, cost_total: bucketCost.get(key) || 0 }));
|
||||||
|
const bucket_requests = sortedBuckets.map((key) => ({
|
||||||
|
bucket: key,
|
||||||
|
requests: bucketRequests.get(key) || 0,
|
||||||
|
}));
|
||||||
|
const bucket_tokens = sortedBuckets.map((key) => {
|
||||||
|
const tok = bucketTokens.get(key) || { total: 0, input: 0, output: 0, cached: 0 };
|
||||||
|
return {
|
||||||
|
bucket: key,
|
||||||
|
total_tokens: tok.total,
|
||||||
|
input_tokens: tok.input,
|
||||||
|
output_tokens: tok.output,
|
||||||
|
cached_tokens: tok.cached,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const bucket_latency_percentiles = sortedBuckets.map((key) => {
|
||||||
|
const lats = bucketLatencies.get(key) || [];
|
||||||
|
return {
|
||||||
|
bucket: key,
|
||||||
|
count: lats.length,
|
||||||
|
avg_ms: lats.length ? lats.reduce((a, b) => a + b, 0) / lats.length : null,
|
||||||
|
p50_ms: percentile(lats, 0.5),
|
||||||
|
p95_ms: percentile(lats, 0.95),
|
||||||
|
p99_ms: percentile(lats, 0.99),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const latency_total = Array.from(latencyBucketCounts.values()).reduce((a, b) => a + b, 0);
|
||||||
|
const latency_distribution = BUCKETS.map((b) => {
|
||||||
|
const count = latencyBucketCounts.get(b.label) || 0;
|
||||||
|
return {
|
||||||
|
bucket: b.label,
|
||||||
|
count,
|
||||||
|
share: latency_total ? count / latency_total : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = Array.from(modelCost.entries())
|
||||||
|
.sort((a, b) => (b[1] || 0) - (a[1] || 0))
|
||||||
|
.map(([model, cost]) => ({
|
||||||
|
model,
|
||||||
|
cost_total: cost,
|
||||||
|
share: totalCost ? cost / totalCost : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const agents = Array.from(agentStats.entries())
|
||||||
|
.sort((a, b) => (b[1].cost || 0) - (a[1].cost || 0))
|
||||||
|
.map(([agent, stats]) => ({
|
||||||
|
agent,
|
||||||
|
requests: stats.requests,
|
||||||
|
cost_total: stats.cost,
|
||||||
|
share: totalCost ? stats.cost / totalCost : null,
|
||||||
|
avg_latency_ms: stats.latencies.length
|
||||||
|
? stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: {
|
||||||
|
label: windowDef.label,
|
||||||
|
start: windowDef.start ? windowDef.start.toISOString() : null,
|
||||||
|
end: windowDef.end ? windowDef.end.toISOString() : null,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
total_cost: totalCost,
|
||||||
|
total_requests: totalRequests,
|
||||||
|
total_tokens: totalTokens,
|
||||||
|
avg_latency_ms: latencyCount ? totalLatency / latencyCount : null,
|
||||||
|
cache_savings: cacheSavings,
|
||||||
|
},
|
||||||
|
timeline:
|
||||||
|
resolution === 'hour'
|
||||||
|
? {
|
||||||
|
resolution: 'hour',
|
||||||
|
hourly: {
|
||||||
|
cost: bucket_cost,
|
||||||
|
requests: bucket_requests,
|
||||||
|
tokens: bucket_tokens,
|
||||||
|
latency_percentiles: bucket_latency_percentiles,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
resolution: 'day',
|
||||||
|
daily: {
|
||||||
|
cost: bucket_cost,
|
||||||
|
requests: bucket_requests,
|
||||||
|
tokens: bucket_tokens,
|
||||||
|
latency_percentiles: bucket_latency_percentiles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cost_by_model: {
|
||||||
|
total_cost: totalCost,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
cost_by_agent: {
|
||||||
|
total_cost: totalCost,
|
||||||
|
agents,
|
||||||
|
},
|
||||||
|
latency_distribution: {
|
||||||
|
total: latency_total,
|
||||||
|
buckets: latency_distribution,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
buildAnalytics,
|
||||||
|
parseAnalyticsWindow,
|
||||||
|
};
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
/**
|
||||||
|
* LLM Pricing Service
|
||||||
|
*
|
||||||
|
* Centralized pricing table for calculating costs by provider and model.
|
||||||
|
* Prices are stored in MongoDB and cached in memory for performance.
|
||||||
|
* Prices are in USD per 1M tokens (industry standard).
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - OpenAI: https://openai.com/pricing
|
||||||
|
* - Anthropic: https://www.anthropic.com/pricing
|
||||||
|
* - Google: https://ai.google.dev/pricing
|
||||||
|
* - AWS Bedrock: https://aws.amazon.com/bedrock/pricing/
|
||||||
|
*/
|
||||||
|
|
||||||
|
// In-memory cache for pricing data
|
||||||
|
let pricingCache = new Map<string, PricingEntry>();
|
||||||
|
let aliasCacheMap = new Map<string, string>(); // model alias -> canonical model
|
||||||
|
let cacheLoadedAt: number | null = null;
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
interface PricingEntry {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cached_input: number;
|
||||||
|
aliases: string[];
|
||||||
|
effective_date?: Date;
|
||||||
|
updated_at?: Date;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricingTableEntry {
|
||||||
|
provider: string;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cached_input: number;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback pricing for unknown models (conservative estimate)
|
||||||
|
const DEFAULT_PRICING = { input: 1.00, output: 3.00, cached_input: 0.25 };
|
||||||
|
|
||||||
|
// Default pricing table for seeding - USD per 1M tokens
|
||||||
|
// Updated: 2025-01-01
|
||||||
|
const DEFAULT_PRICING_TABLE: Record<string, PricingTableEntry> = {
|
||||||
|
// OpenAI Models
|
||||||
|
"gpt-4o": { provider: "openai", input: 2.50, output: 10.00, cached_input: 1.25, aliases: ["gpt-4o-2024-11-20", "gpt-4o-2024-08-06"] },
|
||||||
|
"gpt-4o-2024-05-13": { provider: "openai", input: 5.00, output: 15.00, cached_input: 2.50, aliases: [] },
|
||||||
|
"gpt-4o-mini": { provider: "openai", input: 0.15, output: 0.60, cached_input: 0.075, aliases: ["gpt-4o-mini-2024-07-18"] },
|
||||||
|
"gpt-4-turbo": { provider: "openai", input: 10.00, output: 30.00, cached_input: 5.00, aliases: ["gpt-4-turbo-2024-04-09", "gpt-4-turbo-preview"] },
|
||||||
|
"gpt-4": { provider: "openai", input: 30.00, output: 60.00, cached_input: 15.00, aliases: ["gpt-4-0613"] },
|
||||||
|
"gpt-3.5-turbo": { provider: "openai", input: 0.50, output: 1.50, cached_input: 0.25, aliases: ["gpt-3.5-turbo-0125"] },
|
||||||
|
"o1": { provider: "openai", input: 15.00, output: 60.00, cached_input: 7.50, aliases: ["o1-2024-12-17", "o1-preview"] },
|
||||||
|
"o1-mini": { provider: "openai", input: 3.00, output: 12.00, cached_input: 1.50, aliases: ["o1-mini-2024-09-12"] },
|
||||||
|
"o3-mini": { provider: "openai", input: 1.10, output: 4.40, cached_input: 0.55, aliases: [] },
|
||||||
|
|
||||||
|
// Anthropic Models
|
||||||
|
"claude-3-5-sonnet-20241022": { provider: "anthropic", input: 3.00, output: 15.00, cached_input: 0.30, aliases: ["claude-3-5-sonnet-20240620", "claude-3-5-sonnet-latest"] },
|
||||||
|
"claude-sonnet-4-20250514": { provider: "anthropic", input: 3.00, output: 15.00, cached_input: 0.30, aliases: ["claude-sonnet-4-5-20250929"] },
|
||||||
|
"claude-3-5-haiku-20241022": { provider: "anthropic", input: 0.80, output: 4.00, cached_input: 0.08, aliases: ["claude-3-5-haiku-latest"] },
|
||||||
|
"claude-3-opus-20240229": { provider: "anthropic", input: 15.00, output: 75.00, cached_input: 1.50, aliases: ["claude-3-opus-latest"] },
|
||||||
|
"claude-3-sonnet-20240229": { provider: "anthropic", input: 3.00, output: 15.00, cached_input: 0.30, aliases: [] },
|
||||||
|
"claude-3-haiku-20240307": { provider: "anthropic", input: 0.25, output: 1.25, cached_input: 0.025, aliases: [] },
|
||||||
|
"claude-opus-4-5-20251101": { provider: "anthropic", input: 15.00, output: 75.00, cached_input: 1.50, aliases: ["claude-opus-4-20250514"] },
|
||||||
|
|
||||||
|
// Google Models
|
||||||
|
"gemini-2.0-flash": { provider: "google", input: 0.10, output: 0.40, cached_input: 0.025, aliases: ["gemini-2.0-flash-exp"] },
|
||||||
|
"gemini-1.5-flash": { provider: "google", input: 0.075, output: 0.30, cached_input: 0.01875, aliases: ["gemini-1.5-flash-latest"] },
|
||||||
|
"gemini-1.5-flash-8b": { provider: "google", input: 0.0375, output: 0.15, cached_input: 0.01, aliases: [] },
|
||||||
|
"gemini-1.5-pro": { provider: "google", input: 1.25, output: 5.00, cached_input: 0.3125, aliases: ["gemini-1.5-pro-latest"] },
|
||||||
|
"gemini-1.0-pro": { provider: "google", input: 0.50, output: 1.50, cached_input: 0.125, aliases: ["gemini-pro"] },
|
||||||
|
"gemini-exp-1206": { provider: "google", input: 0.00, output: 0.00, cached_input: 0.00, aliases: [] },
|
||||||
|
|
||||||
|
// AWS Bedrock - Claude (cross-region inference)
|
||||||
|
"anthropic.claude-3-5-sonnet-20241022-v2:0": { provider: "bedrock", input: 3.00, output: 15.00, cached_input: 0.30, aliases: [] },
|
||||||
|
"anthropic.claude-3-5-haiku-20241022-v1:0": { provider: "bedrock", input: 0.80, output: 4.00, cached_input: 0.08, aliases: [] },
|
||||||
|
"anthropic.claude-3-opus-20240229-v1:0": { provider: "bedrock", input: 15.00, output: 75.00, cached_input: 1.50, aliases: [] },
|
||||||
|
"anthropic.claude-3-sonnet-20240229-v1:0": { provider: "bedrock", input: 3.00, output: 15.00, cached_input: 0.30, aliases: [] },
|
||||||
|
"anthropic.claude-3-haiku-20240307-v1:0": { provider: "bedrock", input: 0.25, output: 1.25, cached_input: 0.025, aliases: [] },
|
||||||
|
|
||||||
|
// AWS Bedrock - Amazon Models
|
||||||
|
"amazon.nova-pro-v1:0": { provider: "bedrock", input: 0.80, output: 3.20, cached_input: 0.20, aliases: [] },
|
||||||
|
"amazon.nova-lite-v1:0": { provider: "bedrock", input: 0.06, output: 0.24, cached_input: 0.015, aliases: [] },
|
||||||
|
"amazon.nova-micro-v1:0": { provider: "bedrock", input: 0.035, output: 0.14, cached_input: 0.00875, aliases: [] },
|
||||||
|
"amazon.titan-text-express-v1": { provider: "bedrock", input: 0.20, output: 0.60, cached_input: 0.05, aliases: [] },
|
||||||
|
"amazon.titan-text-lite-v1": { provider: "bedrock", input: 0.15, output: 0.20, cached_input: 0.0375, aliases: [] },
|
||||||
|
|
||||||
|
// Mistral Models
|
||||||
|
"mistral-large-latest": { provider: "mistral", input: 2.00, output: 6.00, cached_input: 0.50, aliases: ["mistral-large-2411"] },
|
||||||
|
"mistral-medium-latest": { provider: "mistral", input: 2.70, output: 8.10, cached_input: 0.675, aliases: [] },
|
||||||
|
"mistral-small-latest": { provider: "mistral", input: 0.20, output: 0.60, cached_input: 0.05, aliases: ["mistral-small-2409"] },
|
||||||
|
"codestral-latest": { provider: "mistral", input: 0.30, output: 0.90, cached_input: 0.075, aliases: [] },
|
||||||
|
"pixtral-large-latest": { provider: "mistral", input: 2.00, output: 6.00, cached_input: 0.50, aliases: [] },
|
||||||
|
"ministral-8b-latest": { provider: "mistral", input: 0.10, output: 0.10, cached_input: 0.025, aliases: [] },
|
||||||
|
"ministral-3b-latest": { provider: "mistral", input: 0.04, output: 0.04, cached_input: 0.01, aliases: [] },
|
||||||
|
|
||||||
|
// Cohere Models
|
||||||
|
"command-r-plus": { provider: "cohere", input: 2.50, output: 10.00, cached_input: 0.625, aliases: [] },
|
||||||
|
"command-r": { provider: "cohere", input: 0.15, output: 0.60, cached_input: 0.0375, aliases: [] },
|
||||||
|
"command": { provider: "cohere", input: 1.00, output: 2.00, cached_input: 0.25, aliases: [] },
|
||||||
|
"command-light": { provider: "cohere", input: 0.30, output: 0.60, cached_input: 0.075, aliases: [] },
|
||||||
|
|
||||||
|
// DeepSeek Models
|
||||||
|
"deepseek-chat": { provider: "deepseek", input: 0.14, output: 0.28, cached_input: 0.014, aliases: [] },
|
||||||
|
"deepseek-reasoner": { provider: "deepseek", input: 0.55, output: 2.19, cached_input: 0.055, aliases: [] },
|
||||||
|
|
||||||
|
// Groq Models (inference pricing, not training)
|
||||||
|
"llama-3.3-70b-versatile": { provider: "groq", input: 0.59, output: 0.79, cached_input: 0.15, aliases: [] },
|
||||||
|
"llama-3.1-70b-versatile": { provider: "groq", input: 0.59, output: 0.79, cached_input: 0.15, aliases: [] },
|
||||||
|
"llama-3.1-8b-instant": { provider: "groq", input: 0.05, output: 0.08, cached_input: 0.0125, aliases: [] },
|
||||||
|
"llama-3.2-90b-vision-preview": { provider: "groq", input: 0.90, output: 0.90, cached_input: 0.225, aliases: [] },
|
||||||
|
"mixtral-8x7b-32768": { provider: "groq", input: 0.24, output: 0.24, cached_input: 0.06, aliases: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const _ACHO_MG_DB: { db: (name: string) => { collection: (name: string) => unknown } };
|
||||||
|
declare const _ACHO_MDB_CONFIG: { ERP_DBNAME: string };
|
||||||
|
declare const _ACHO_MDB_COLLECTIONS: { ADEN_LLM_PRICING: string };
|
||||||
|
|
||||||
|
interface MongoCollection {
|
||||||
|
find: (query: Record<string, unknown>) => { toArray: () => Promise<unknown[]>; sort: (sort: Record<string, number>) => { toArray: () => Promise<unknown[]> } };
|
||||||
|
findOne: (query: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
findOneAndUpdate: (query: Record<string, unknown>, update: Record<string, unknown>, options: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
deleteOne: (query: Record<string, unknown>) => Promise<{ deletedCount: number }>;
|
||||||
|
insertOne: (doc: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
updateOne: (query: Record<string, unknown>, update: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the MongoDB collection for pricing
|
||||||
|
* @returns {Collection} MongoDB collection
|
||||||
|
*/
|
||||||
|
function getPricingCollection(): MongoCollection {
|
||||||
|
const db = _ACHO_MG_DB.db(_ACHO_MDB_CONFIG.ERP_DBNAME);
|
||||||
|
return db.collection(_ACHO_MDB_COLLECTIONS.ADEN_LLM_PRICING) as MongoCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cache is still valid
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isCacheValid(): boolean {
|
||||||
|
if (!cacheLoadedAt || pricingCache.size === 0) return false;
|
||||||
|
return Date.now() - cacheLoadedAt < CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbPricingDoc {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
input_per_1m: number;
|
||||||
|
output_per_1m: number;
|
||||||
|
cached_input_per_1m: number;
|
||||||
|
aliases?: string[];
|
||||||
|
effective_date?: Date;
|
||||||
|
updated_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load pricing from MongoDB into memory cache
|
||||||
|
* @param {boolean} force - Force reload even if cache is valid
|
||||||
|
* @returns {Promise<Map>} Pricing cache
|
||||||
|
*/
|
||||||
|
async function loadPricingFromDb(force = false): Promise<Map<string, PricingEntry>> {
|
||||||
|
if (!force && isCacheValid()) {
|
||||||
|
return pricingCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collection = getPricingCollection();
|
||||||
|
const docs = await collection.find({}).toArray() as DbPricingDoc[];
|
||||||
|
|
||||||
|
if (docs.length === 0) {
|
||||||
|
console.log("[pricing_service] No pricing in DB, using defaults");
|
||||||
|
loadFromDefaults();
|
||||||
|
return pricingCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear and rebuild cache
|
||||||
|
pricingCache.clear();
|
||||||
|
aliasCacheMap.clear();
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
const pricing: PricingEntry = {
|
||||||
|
model: doc.model,
|
||||||
|
provider: doc.provider,
|
||||||
|
input: doc.input_per_1m,
|
||||||
|
output: doc.output_per_1m,
|
||||||
|
cached_input: doc.cached_input_per_1m,
|
||||||
|
aliases: doc.aliases || [],
|
||||||
|
effective_date: doc.effective_date,
|
||||||
|
updated_at: doc.updated_at,
|
||||||
|
};
|
||||||
|
pricingCache.set(doc.model.toLowerCase(), pricing);
|
||||||
|
|
||||||
|
// Build alias map
|
||||||
|
for (const alias of pricing.aliases) {
|
||||||
|
aliasCacheMap.set(alias.toLowerCase(), doc.model.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheLoadedAt = Date.now();
|
||||||
|
console.log(`[pricing_service] Loaded ${pricingCache.size} pricing entries from DB`);
|
||||||
|
return pricingCache;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[pricing_service] Error loading from DB, using defaults:", (err as Error).message);
|
||||||
|
loadFromDefaults();
|
||||||
|
return pricingCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load pricing from hardcoded defaults into cache
|
||||||
|
*/
|
||||||
|
function loadFromDefaults(): void {
|
||||||
|
pricingCache.clear();
|
||||||
|
aliasCacheMap.clear();
|
||||||
|
|
||||||
|
for (const [model, data] of Object.entries(DEFAULT_PRICING_TABLE)) {
|
||||||
|
const pricing: PricingEntry = {
|
||||||
|
model,
|
||||||
|
provider: data.provider,
|
||||||
|
input: data.input,
|
||||||
|
output: data.output,
|
||||||
|
cached_input: data.cached_input,
|
||||||
|
aliases: data.aliases || [],
|
||||||
|
source: "default",
|
||||||
|
};
|
||||||
|
pricingCache.set(model.toLowerCase(), pricing);
|
||||||
|
|
||||||
|
// Build alias map
|
||||||
|
for (const alias of data.aliases || []) {
|
||||||
|
aliasCacheMap.set(alias.toLowerCase(), model.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheLoadedAt = Date.now();
|
||||||
|
console.log(`[pricing_service] Loaded ${pricingCache.size} pricing entries from defaults`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cache to force reload on next access
|
||||||
|
*/
|
||||||
|
function invalidateCache(): void {
|
||||||
|
cacheLoadedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve model name to canonical form using aliases
|
||||||
|
* @param {string} model - Model name (possibly an alias)
|
||||||
|
* @returns {string} Canonical model name
|
||||||
|
*/
|
||||||
|
function resolveAlias(model: string): string | null {
|
||||||
|
if (!model) return null;
|
||||||
|
const lower = model.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Check if it's a direct match
|
||||||
|
if (pricingCache.has(lower)) {
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check alias map
|
||||||
|
if (aliasCacheMap.has(lower)) {
|
||||||
|
return aliasCacheMap.get(lower)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try partial matching for model families
|
||||||
|
for (const [key, pricing] of pricingCache.entries()) {
|
||||||
|
// Check if input starts with a known model prefix
|
||||||
|
if (lower.startsWith(key) || key.startsWith(lower)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
// Check aliases
|
||||||
|
for (const alias of pricing.aliases || []) {
|
||||||
|
if (lower.startsWith(alias.toLowerCase()) || alias.toLowerCase().startsWith(lower)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelPricingResult {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cached_input: number;
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pricing for a model
|
||||||
|
* @param {string} model - Model name
|
||||||
|
* @param {string} provider - Provider name (optional, for disambiguation)
|
||||||
|
* @returns {Promise<Object>} Pricing { input, output, cached_input } in USD per 1M tokens
|
||||||
|
*/
|
||||||
|
async function getModelPricing(model: string, provider: string | null = null): Promise<ModelPricingResult> {
|
||||||
|
await loadPricingFromDb();
|
||||||
|
|
||||||
|
const resolved = resolveAlias(model);
|
||||||
|
|
||||||
|
// Try exact match
|
||||||
|
if (resolved && pricingCache.has(resolved)) {
|
||||||
|
const pricing = pricingCache.get(resolved)!;
|
||||||
|
return {
|
||||||
|
input: pricing.input,
|
||||||
|
output: pricing.output,
|
||||||
|
cached_input: pricing.cached_input,
|
||||||
|
model: pricing.model,
|
||||||
|
provider: pricing.provider,
|
||||||
|
source: "db",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try provider-prefixed lookup for Bedrock
|
||||||
|
if (provider === "bedrock" || provider === "aws") {
|
||||||
|
for (const [key, pricing] of pricingCache.entries()) {
|
||||||
|
if (key.includes(resolved || "") || (resolved || "").includes(key.split(".").pop()?.split("-")[0] || "")) {
|
||||||
|
return {
|
||||||
|
input: pricing.input,
|
||||||
|
output: pricing.output,
|
||||||
|
cached_input: pricing.cached_input,
|
||||||
|
model: pricing.model,
|
||||||
|
provider: pricing.provider,
|
||||||
|
source: "bedrock_match",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default pricing
|
||||||
|
console.log(`[pricing_service] Unknown model: ${model}, using default pricing`);
|
||||||
|
return {
|
||||||
|
...DEFAULT_PRICING,
|
||||||
|
model: model,
|
||||||
|
provider: provider || "unknown",
|
||||||
|
source: "default",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model pricing synchronously (uses cached data)
|
||||||
|
* @param {string} model - Model name
|
||||||
|
* @returns {Object} Pricing { input, output, cached_input } in USD per 1M tokens
|
||||||
|
*/
|
||||||
|
function getModelPricingSync(model: string): ModelPricingResult {
|
||||||
|
const resolved = resolveAlias(model);
|
||||||
|
|
||||||
|
if (resolved && pricingCache.has(resolved)) {
|
||||||
|
const cached = pricingCache.get(resolved)!;
|
||||||
|
return {
|
||||||
|
input: cached.input,
|
||||||
|
output: cached.output,
|
||||||
|
cached_input: cached.cached_input,
|
||||||
|
model: cached.model,
|
||||||
|
provider: cached.provider,
|
||||||
|
source: "db",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...DEFAULT_PRICING, model, provider: "unknown", source: "default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CostCalculationParams {
|
||||||
|
model: string;
|
||||||
|
provider?: string;
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
cached_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CostResult {
|
||||||
|
total: number;
|
||||||
|
input_cost: number;
|
||||||
|
output_cost: number;
|
||||||
|
cached_cost: number;
|
||||||
|
pricing: {
|
||||||
|
model: string;
|
||||||
|
source: string;
|
||||||
|
input_per_1m: number;
|
||||||
|
output_per_1m: number;
|
||||||
|
cached_per_1m: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cost for a request (synchronous version using cached data)
|
||||||
|
* @param {Object} params - Request parameters
|
||||||
|
* @returns {Object} Cost breakdown { total, input_cost, output_cost, cached_cost, pricing }
|
||||||
|
*/
|
||||||
|
function calculateCostSync({ model, provider, input_tokens = 0, output_tokens = 0, cached_tokens = 0 }: CostCalculationParams): CostResult {
|
||||||
|
const resolved = resolveAlias(model);
|
||||||
|
let pricing: { input: number; output: number; cached_input: number; model: string; source: string };
|
||||||
|
|
||||||
|
if (resolved && pricingCache.has(resolved)) {
|
||||||
|
const cached = pricingCache.get(resolved)!;
|
||||||
|
pricing = {
|
||||||
|
input: cached.input,
|
||||||
|
output: cached.output,
|
||||||
|
cached_input: cached.cached_input,
|
||||||
|
model: cached.model,
|
||||||
|
source: "db",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pricing = { ...DEFAULT_PRICING, model, source: "default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-cached input tokens
|
||||||
|
const nonCachedInput = Math.max(0, input_tokens - cached_tokens);
|
||||||
|
|
||||||
|
// Calculate costs (pricing is per 1M tokens)
|
||||||
|
const inputCost = (nonCachedInput / 1_000_000) * pricing.input;
|
||||||
|
const outputCost = (output_tokens / 1_000_000) * pricing.output;
|
||||||
|
const cachedCost = (cached_tokens / 1_000_000) * pricing.cached_input;
|
||||||
|
|
||||||
|
const total = inputCost + outputCost + cachedCost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
input_cost: inputCost,
|
||||||
|
output_cost: outputCost,
|
||||||
|
cached_cost: cachedCost,
|
||||||
|
pricing: {
|
||||||
|
model: pricing.model,
|
||||||
|
source: pricing.source,
|
||||||
|
input_per_1m: pricing.input,
|
||||||
|
output_per_1m: pricing.output,
|
||||||
|
cached_per_1m: pricing.cached_input,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cost for a request (async version)
|
||||||
|
* @param {Object} params - Request parameters
|
||||||
|
* @returns {Promise<Object>} Cost breakdown { total, input_cost, output_cost, cached_cost, pricing }
|
||||||
|
*/
|
||||||
|
async function calculateCost(params: CostCalculationParams): Promise<CostResult> {
|
||||||
|
await loadPricingFromDb();
|
||||||
|
return calculateCostSync(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpsertPricingInput {
|
||||||
|
provider?: string;
|
||||||
|
input_per_1m?: number;
|
||||||
|
input?: number;
|
||||||
|
output_per_1m?: number;
|
||||||
|
output?: number;
|
||||||
|
cached_input_per_1m?: number;
|
||||||
|
cached_input?: number;
|
||||||
|
aliases?: string[];
|
||||||
|
effective_date?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert pricing for a model
|
||||||
|
* @param {string} model - Model identifier
|
||||||
|
* @param {Object} pricing - Pricing data
|
||||||
|
* @param {string} userId - User making the change
|
||||||
|
* @returns {Promise<Object>} Updated document
|
||||||
|
*/
|
||||||
|
async function upsertPricing(model: string, pricing: UpsertPricingInput, userId: string | null = null): Promise<unknown> {
|
||||||
|
const collection = getPricingCollection();
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
model: model,
|
||||||
|
provider: pricing.provider,
|
||||||
|
input_per_1m: pricing.input_per_1m ?? pricing.input,
|
||||||
|
output_per_1m: pricing.output_per_1m ?? pricing.output,
|
||||||
|
cached_input_per_1m: pricing.cached_input_per_1m ?? pricing.cached_input,
|
||||||
|
aliases: pricing.aliases || [],
|
||||||
|
effective_date: pricing.effective_date || new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
updated_by: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.findOneAndUpdate(
|
||||||
|
{ model: model },
|
||||||
|
{ $set: doc },
|
||||||
|
{ upsert: true, returnDocument: "after" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate cache to force reload
|
||||||
|
invalidateCache();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete pricing for a model
|
||||||
|
* @param {string} model - Model identifier
|
||||||
|
* @returns {Promise<boolean>} True if deleted
|
||||||
|
*/
|
||||||
|
async function deletePricing(model: string): Promise<boolean> {
|
||||||
|
const collection = getPricingCollection();
|
||||||
|
const result = await collection.deleteOne({ model: model });
|
||||||
|
|
||||||
|
// Invalidate cache to force reload
|
||||||
|
invalidateCache();
|
||||||
|
|
||||||
|
return result.deletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedResult {
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: { model: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed default pricing to MongoDB
|
||||||
|
* @param {string} userId - User making the change
|
||||||
|
* @param {boolean} overwrite - If true, overwrite existing entries
|
||||||
|
* @returns {Promise<Object>} Seed results
|
||||||
|
*/
|
||||||
|
async function seedDefaultPricing(userId: string | null = null, overwrite = false): Promise<SeedResult> {
|
||||||
|
const collection = getPricingCollection();
|
||||||
|
const results: SeedResult = { inserted: 0, updated: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
|
for (const [model, data] of Object.entries(DEFAULT_PRICING_TABLE)) {
|
||||||
|
try {
|
||||||
|
const existing = await collection.findOne({ model });
|
||||||
|
|
||||||
|
if (existing && !overwrite) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
model,
|
||||||
|
provider: data.provider,
|
||||||
|
input_per_1m: data.input,
|
||||||
|
output_per_1m: data.output,
|
||||||
|
cached_input_per_1m: data.cached_input,
|
||||||
|
aliases: data.aliases || [],
|
||||||
|
effective_date: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
updated_by: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await collection.updateOne({ model }, { $set: doc });
|
||||||
|
results.updated++;
|
||||||
|
} else {
|
||||||
|
await collection.insertOne(doc);
|
||||||
|
results.inserted++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
results.errors.push({ model, error: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache to force reload
|
||||||
|
invalidateCache();
|
||||||
|
|
||||||
|
console.log(`[pricing_service] Seeded pricing: ${results.inserted} inserted, ${results.updated} updated, ${results.skipped} skipped`);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AllPricingResult {
|
||||||
|
[key: string]: {
|
||||||
|
provider: string;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cached_input: number;
|
||||||
|
aliases: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available pricing data
|
||||||
|
* @returns {Promise<Object>} Full pricing table
|
||||||
|
*/
|
||||||
|
async function getAllPricing(): Promise<AllPricingResult> {
|
||||||
|
await loadPricingFromDb();
|
||||||
|
|
||||||
|
const result: AllPricingResult = {};
|
||||||
|
for (const [key, pricing] of pricingCache.entries()) {
|
||||||
|
result[pricing.model] = {
|
||||||
|
provider: pricing.provider,
|
||||||
|
input: pricing.input,
|
||||||
|
output: pricing.output,
|
||||||
|
cached_input: pricing.cached_input,
|
||||||
|
aliases: pricing.aliases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricingByProviderResult {
|
||||||
|
[provider: string]: {
|
||||||
|
[model: string]: {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cached_input: number;
|
||||||
|
aliases: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pricing summary grouped by provider
|
||||||
|
* @returns {Promise<Object>} Pricing by provider
|
||||||
|
*/
|
||||||
|
async function getPricingByProvider(): Promise<PricingByProviderResult> {
|
||||||
|
await loadPricingFromDb();
|
||||||
|
|
||||||
|
const byProvider: PricingByProviderResult = {};
|
||||||
|
|
||||||
|
for (const [, pricing] of pricingCache.entries()) {
|
||||||
|
const provider = pricing.provider || "other";
|
||||||
|
if (!byProvider[provider]) {
|
||||||
|
byProvider[provider] = {};
|
||||||
|
}
|
||||||
|
byProvider[provider][pricing.model] = {
|
||||||
|
input: pricing.input,
|
||||||
|
output: pricing.output,
|
||||||
|
cached_input: pricing.cached_input,
|
||||||
|
aliases: pricing.aliases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return byProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DegradationModel {
|
||||||
|
model: string;
|
||||||
|
label: string;
|
||||||
|
input_cost: number;
|
||||||
|
output_cost: number;
|
||||||
|
avg_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DegradationTargetsResult {
|
||||||
|
providers: string[];
|
||||||
|
models: { [provider: string]: DegradationModel[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get degradation target models grouped by provider
|
||||||
|
* Returns models sorted by cost (cheapest first) for budget control "degrade" mode
|
||||||
|
* @returns {Promise<Object>} { providers: [...], models: { provider: [...] } }
|
||||||
|
*/
|
||||||
|
async function getDegradationTargets(): Promise<DegradationTargetsResult> {
|
||||||
|
await loadPricingFromDb();
|
||||||
|
|
||||||
|
const byProvider: { [provider: string]: DegradationModel[] } = {};
|
||||||
|
|
||||||
|
for (const [, pricing] of pricingCache.entries()) {
|
||||||
|
const provider = pricing.provider || "other";
|
||||||
|
if (!byProvider[provider]) {
|
||||||
|
byProvider[provider] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average cost per 1M tokens (input + output) / 2
|
||||||
|
const avgCost = (pricing.input + pricing.output) / 2;
|
||||||
|
|
||||||
|
byProvider[provider].push({
|
||||||
|
model: pricing.model,
|
||||||
|
label: pricing.model,
|
||||||
|
input_cost: pricing.input,
|
||||||
|
output_cost: pricing.output,
|
||||||
|
avg_cost: avgCost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort models within each provider by avg_cost (cheapest first)
|
||||||
|
for (const provider of Object.keys(byProvider)) {
|
||||||
|
byProvider[provider].sort((a, b) => a.avg_cost - b.avg_cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sorted list of providers
|
||||||
|
const providers = Object.keys(byProvider).sort();
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers,
|
||||||
|
models: byProvider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pricing directly from DB (bypasses cache)
|
||||||
|
* @param {string} model - Model identifier
|
||||||
|
* @returns {Promise<Object|null>} Pricing document or null
|
||||||
|
*/
|
||||||
|
async function getPricingFromDb(model: string): Promise<unknown> {
|
||||||
|
const collection = getPricingCollection();
|
||||||
|
return collection.findOne({ model });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all pricing from DB (bypasses cache)
|
||||||
|
* @returns {Promise<Array>} All pricing documents
|
||||||
|
*/
|
||||||
|
async function listAllPricingFromDb(): Promise<unknown[]> {
|
||||||
|
const collection = getPricingCollection();
|
||||||
|
return collection.find({}).sort({ provider: 1, model: 1 }).toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize pricing service - call on server startup
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function initialize(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await loadPricingFromDb(true);
|
||||||
|
console.log("[pricing_service] Initialized successfully");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[pricing_service] Failed to initialize, using defaults:", (err as Error).message);
|
||||||
|
loadFromDefaults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Core functions
|
||||||
|
getModelPricing,
|
||||||
|
getModelPricingSync,
|
||||||
|
calculateCost,
|
||||||
|
calculateCostSync,
|
||||||
|
|
||||||
|
// CRUD operations
|
||||||
|
upsertPricing,
|
||||||
|
deletePricing,
|
||||||
|
seedDefaultPricing,
|
||||||
|
|
||||||
|
// Query functions
|
||||||
|
getAllPricing,
|
||||||
|
getPricingByProvider,
|
||||||
|
getDegradationTargets,
|
||||||
|
getPricingFromDb,
|
||||||
|
listAllPricingFromDb,
|
||||||
|
|
||||||
|
// Cache management
|
||||||
|
loadPricingFromDb,
|
||||||
|
invalidateCache,
|
||||||
|
initialize,
|
||||||
|
|
||||||
|
// Constants (for reference/testing)
|
||||||
|
DEFAULT_PRICING,
|
||||||
|
DEFAULT_PRICING_TABLE,
|
||||||
|
};
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
-- TSDB schema for team-scoped hypertable (Timescale)
|
||||||
|
-- Architecture: Hot (metrics) / Warm (content refs) / Cold (content store)
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Enable TimescaleDB extension (required for hypertables and continuous aggregates)
|
||||||
|
-- This is safe to run multiple times - CREATE EXTENSION IF NOT EXISTS is idempotent
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- HOT TABLE: llm_events (metrics only - fast time-series queries)
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_events (
|
||||||
|
"timestamp" timestamptz NOT NULL,
|
||||||
|
ingest_date date,
|
||||||
|
team_id text NOT NULL,
|
||||||
|
user_id text,
|
||||||
|
trace_id text NOT NULL,
|
||||||
|
span_id text,
|
||||||
|
parent_span_id text,
|
||||||
|
request_id text,
|
||||||
|
provider text,
|
||||||
|
call_sequence integer NOT NULL,
|
||||||
|
model text,
|
||||||
|
stream boolean DEFAULT false,
|
||||||
|
agent text,
|
||||||
|
agent_name text,
|
||||||
|
agent_stack jsonb,
|
||||||
|
call_site jsonb,
|
||||||
|
metadata jsonb,
|
||||||
|
latency_ms double precision,
|
||||||
|
usage_input_tokens double precision,
|
||||||
|
usage_output_tokens double precision,
|
||||||
|
usage_total_tokens double precision,
|
||||||
|
usage_cached_tokens double precision,
|
||||||
|
usage_reasoning_tokens double precision,
|
||||||
|
usage_accepted_prediction_tokens double precision,
|
||||||
|
usage_rejected_prediction_tokens double precision,
|
||||||
|
cost_total numeric,
|
||||||
|
-- Content flags (lightweight references instead of full content)
|
||||||
|
has_content boolean DEFAULT false,
|
||||||
|
finish_reason text,
|
||||||
|
tool_call_count integer DEFAULT 0,
|
||||||
|
-- Deprecated: content_capture jsonb (migrated to warm storage)
|
||||||
|
content_capture jsonb,
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
CONSTRAINT llm_events_pk PRIMARY KEY ("timestamp", trace_id, call_sequence)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- WARM TABLE: llm_event_content (content references per event)
|
||||||
|
-- Links events to deduplicated content in the cold store
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_event_content (
|
||||||
|
id bigserial,
|
||||||
|
"timestamp" timestamptz NOT NULL,
|
||||||
|
trace_id text NOT NULL,
|
||||||
|
call_sequence integer NOT NULL,
|
||||||
|
team_id text NOT NULL,
|
||||||
|
-- Content type: 'system_prompt', 'messages', 'response', 'tools', 'params'
|
||||||
|
content_type text NOT NULL,
|
||||||
|
-- Reference to cold storage (content-addressable)
|
||||||
|
content_hash text NOT NULL,
|
||||||
|
-- Quick access metadata (no need to fetch from cold store)
|
||||||
|
byte_size integer NOT NULL DEFAULT 0,
|
||||||
|
message_count integer, -- For messages type
|
||||||
|
truncated_preview text, -- First 200 chars for quick preview
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
CONSTRAINT llm_event_content_pk PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for joining back to events
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_event_content_event
|
||||||
|
ON llm_event_content (trace_id, call_sequence, "timestamp");
|
||||||
|
|
||||||
|
-- Index for content type queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_event_content_type
|
||||||
|
ON llm_event_content (team_id, content_type, "timestamp" DESC);
|
||||||
|
|
||||||
|
-- Index for content hash lookups (finding which events use a content)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_event_content_hash
|
||||||
|
ON llm_event_content (content_hash);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- COLD TABLE: llm_content_store (deduplicated content storage)
|
||||||
|
-- Content-addressable storage with SHA-256 hashes
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_content_store (
|
||||||
|
content_hash text NOT NULL,
|
||||||
|
team_id text NOT NULL,
|
||||||
|
content text NOT NULL,
|
||||||
|
byte_size integer NOT NULL,
|
||||||
|
ref_count integer DEFAULT 1, -- Number of events referencing this content
|
||||||
|
first_seen_at timestamptz DEFAULT now(),
|
||||||
|
last_seen_at timestamptz DEFAULT now(),
|
||||||
|
CONSTRAINT llm_content_store_pk PRIMARY KEY (content_hash, team_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for cleanup queries (find orphaned content)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_content_store_refs
|
||||||
|
ON llm_content_store (team_id, ref_count, last_seen_at);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- MIGRATION: Add new columns to existing llm_events tables
|
||||||
|
-- =============================================================================
|
||||||
|
ALTER TABLE llm_events ADD COLUMN IF NOT EXISTS has_content boolean DEFAULT false;
|
||||||
|
ALTER TABLE llm_events ADD COLUMN IF NOT EXISTS finish_reason text;
|
||||||
|
ALTER TABLE llm_events ADD COLUMN IF NOT EXISTS tool_call_count integer DEFAULT 0;
|
||||||
|
|
||||||
|
-- Ensure primary key includes timestamp if table already existed without it
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON c.conrelid = t.oid
|
||||||
|
WHERE t.relname = 'llm_events'
|
||||||
|
AND c.contype = 'p'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM unnest(c.conkey) WITH ORDINALITY AS ck(attnum, ord)
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ck.attnum
|
||||||
|
WHERE a.attname = 'timestamp'
|
||||||
|
)
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE llm_events DROP CONSTRAINT IF EXISTS llm_events_pk;
|
||||||
|
ALTER TABLE llm_events ADD CONSTRAINT llm_events_pk PRIMARY KEY ("timestamp", trace_id, call_sequence);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Promote to hypertable when Timescale is available
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||||
|
PERFORM public.create_hypertable('llm_events', 'timestamp', if_not_exists => TRUE);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Ensure metadata column exists for flexible fields
|
||||||
|
ALTER TABLE llm_events
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata jsonb;
|
||||||
|
|
||||||
|
-- Ensure content_capture column exists (for Layer 0 content capture)
|
||||||
|
ALTER TABLE llm_events
|
||||||
|
ADD COLUMN IF NOT EXISTS content_capture jsonb;
|
||||||
|
|
||||||
|
-- Helpful indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_ts ON llm_events ("timestamp" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_team_ts ON llm_events (team_id, "timestamp" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_model ON llm_events (model);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_agent ON llm_events (agent);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_trace ON llm_events (trace_id);
|
||||||
|
|
||||||
|
-- Continuous aggregate: daily rollup for analytics-wide
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS llm_events_daily_ca
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 day', "timestamp") AS bucket,
|
||||||
|
COUNT(*) AS requests,
|
||||||
|
SUM(cost_total) AS cost_total,
|
||||||
|
SUM(usage_input_tokens) AS input_tokens,
|
||||||
|
SUM(usage_output_tokens) AS output_tokens,
|
||||||
|
SUM(COALESCE(usage_total_tokens, COALESCE(usage_input_tokens, 0) + COALESCE(usage_output_tokens, 0))) AS total_tokens,
|
||||||
|
SUM(usage_cached_tokens) AS cached_tokens
|
||||||
|
FROM llm_events
|
||||||
|
GROUP BY 1
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
-- Initial refresh to populate the CA immediately
|
||||||
|
CALL refresh_continuous_aggregate('llm_events_daily_ca', NULL, NOW());
|
||||||
|
END IF;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN others THEN NULL; -- Ignore errors if CA already exists or refresh fails
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Index on CA for fast range scans
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'llm_events_daily_ca') THEN
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_daily_ca_bucket ON llm_events_daily_ca (bucket DESC);
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
NULL;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Continuous aggregate: daily rollup by model for fast model-grouped queries
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS llm_events_daily_by_model_ca
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 day', "timestamp") AS bucket,
|
||||||
|
model,
|
||||||
|
provider,
|
||||||
|
COUNT(*) AS requests,
|
||||||
|
SUM(cost_total) AS cost_total,
|
||||||
|
SUM(usage_input_tokens) AS input_tokens,
|
||||||
|
SUM(usage_output_tokens) AS output_tokens,
|
||||||
|
SUM(COALESCE(usage_total_tokens, COALESCE(usage_input_tokens, 0) + COALESCE(usage_output_tokens, 0))) AS total_tokens,
|
||||||
|
SUM(usage_cached_tokens) AS cached_tokens,
|
||||||
|
AVG(latency_ms) AS avg_latency_ms
|
||||||
|
FROM llm_events
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
-- Initial refresh to populate the CA immediately
|
||||||
|
CALL refresh_continuous_aggregate('llm_events_daily_by_model_ca', NULL, NOW());
|
||||||
|
END IF;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN others THEN NULL; -- Ignore errors if CA already exists or refresh fails
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Index on model CA for fast range scans
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'llm_events_daily_by_model_ca') THEN
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_daily_by_model_ca_bucket ON llm_events_daily_by_model_ca (bucket DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_daily_by_model_ca_model ON llm_events_daily_by_model_ca (model);
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
NULL;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Continuous aggregate: daily rollup by agent for fast agent-grouped queries
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS llm_events_daily_by_agent_ca
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 day', "timestamp") AS bucket,
|
||||||
|
agent,
|
||||||
|
COUNT(*) AS requests,
|
||||||
|
SUM(cost_total) AS cost_total,
|
||||||
|
SUM(usage_input_tokens) AS input_tokens,
|
||||||
|
SUM(usage_output_tokens) AS output_tokens,
|
||||||
|
SUM(COALESCE(usage_total_tokens, COALESCE(usage_input_tokens, 0) + COALESCE(usage_output_tokens, 0))) AS total_tokens,
|
||||||
|
SUM(usage_cached_tokens) AS cached_tokens,
|
||||||
|
AVG(latency_ms) AS avg_latency_ms
|
||||||
|
FROM llm_events
|
||||||
|
GROUP BY 1, 2
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
-- Initial refresh to populate the CA immediately
|
||||||
|
CALL refresh_continuous_aggregate('llm_events_daily_by_agent_ca', NULL, NOW());
|
||||||
|
END IF;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN others THEN NULL; -- Ignore errors if CA already exists or refresh fails
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Index on agent CA for fast range scans
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'llm_events_daily_by_agent_ca') THEN
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_daily_by_agent_ca_bucket ON llm_events_daily_by_agent_ca (bucket DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_events_daily_by_agent_ca_agent ON llm_events_daily_by_agent_ca (agent);
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
NULL;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Refresh policies: keep recent buckets fresh
|
||||||
|
-- Note: Using timescaledb_information.jobs (not the deprecated policy_refresh_continuous_aggregate view)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM timescaledb_information.continuous_aggregates
|
||||||
|
WHERE view_name = 'llm_events_daily_ca'
|
||||||
|
AND view_schema = current_schema()
|
||||||
|
)
|
||||||
|
THEN
|
||||||
|
-- Add refresh policy if none exists for this CA
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.jobs
|
||||||
|
WHERE proc_name = 'policy_refresh_continuous_aggregate'
|
||||||
|
AND hypertable_schema = current_schema()
|
||||||
|
AND hypertable_name = 'llm_events_daily_ca'
|
||||||
|
) THEN
|
||||||
|
PERFORM add_continuous_aggregate_policy(
|
||||||
|
'llm_events_daily_ca',
|
||||||
|
start_offset => interval '30 days',
|
||||||
|
end_offset => interval '1 hour',
|
||||||
|
schedule_interval => interval '15 minutes'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN undefined_table THEN NULL;
|
||||||
|
WHEN undefined_function THEN NULL;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Refresh policies for llm_events_daily_by_model_ca
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM timescaledb_information.continuous_aggregates
|
||||||
|
WHERE view_name = 'llm_events_daily_by_model_ca'
|
||||||
|
AND view_schema = current_schema()
|
||||||
|
)
|
||||||
|
THEN
|
||||||
|
-- Add refresh policy if none exists for this CA
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.jobs
|
||||||
|
WHERE proc_name = 'policy_refresh_continuous_aggregate'
|
||||||
|
AND hypertable_schema = current_schema()
|
||||||
|
AND hypertable_name = 'llm_events_daily_by_model_ca'
|
||||||
|
) THEN
|
||||||
|
PERFORM add_continuous_aggregate_policy(
|
||||||
|
'llm_events_daily_by_model_ca',
|
||||||
|
start_offset => interval '30 days',
|
||||||
|
end_offset => interval '1 hour',
|
||||||
|
schedule_interval => interval '15 minutes'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN undefined_table THEN NULL;
|
||||||
|
WHEN undefined_function THEN NULL;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- Refresh policies for llm_events_daily_by_agent_ca
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM timescaledb_information.continuous_aggregates
|
||||||
|
WHERE view_name = 'llm_events_daily_by_agent_ca'
|
||||||
|
AND view_schema = current_schema()
|
||||||
|
)
|
||||||
|
THEN
|
||||||
|
-- Add refresh policy if none exists for this CA
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.jobs
|
||||||
|
WHERE proc_name = 'policy_refresh_continuous_aggregate'
|
||||||
|
AND hypertable_schema = current_schema()
|
||||||
|
AND hypertable_name = 'llm_events_daily_by_agent_ca'
|
||||||
|
) THEN
|
||||||
|
PERFORM add_continuous_aggregate_policy(
|
||||||
|
'llm_events_daily_by_agent_ca',
|
||||||
|
start_offset => interval '30 days',
|
||||||
|
end_offset => interval '1 hour',
|
||||||
|
schedule_interval => interval '15 minutes'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN undefined_table THEN NULL;
|
||||||
|
WHEN undefined_function THEN NULL;
|
||||||
|
END$$;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Pool, PoolConfig, PoolClient } from "pg";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
// Cache pools per team schema
|
||||||
|
const poolCache = new Map<string, Pool>();
|
||||||
|
|
||||||
|
interface TokenPayload {
|
||||||
|
team_id?: string;
|
||||||
|
team?: string;
|
||||||
|
teamId?: string;
|
||||||
|
current_team_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sub?: string;
|
||||||
|
user?: string;
|
||||||
|
userId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedToken {
|
||||||
|
team_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
token: string;
|
||||||
|
payload: TokenPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JWT to extract team_id and user_id.
|
||||||
|
* - Supports Authorization header formats: "Bearer <token>" or "jwt <token>" or raw token.
|
||||||
|
* - team_id: payload.team_id || payload.team || payload.teamId
|
||||||
|
* - user_id: payload.user_id || payload.sub || payload.user || payload.userId
|
||||||
|
*/
|
||||||
|
const parseToken = (authHeader: string | undefined): ParsedToken | null => {
|
||||||
|
if (!authHeader) return null;
|
||||||
|
const parts = authHeader.trim().split(" ");
|
||||||
|
const token = parts.length === 2 ? parts[1] : parts[0];
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
// Token is already verified by passport middleware; decode only to extract team/user fields.
|
||||||
|
const payload = jwt.decode(token) as TokenPayload | null;
|
||||||
|
if (!payload || typeof payload !== "object") return null;
|
||||||
|
|
||||||
|
const team_id = payload.team_id || payload.team || payload.teamId || payload.current_team_id;
|
||||||
|
const user_id = payload.user_id || payload.sub || payload.user || payload.userId || null;
|
||||||
|
if (!team_id) return null;
|
||||||
|
|
||||||
|
return { team_id, user_id: user_id as string | null, token, payload };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSchemaName = (team_id: string | number): string => {
|
||||||
|
return `team_${team_id}`.replace(/[^a-zA-Z0-9_]/g, "_");
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const _GLOBAL_CONST: { ACHO_PG_CONFIG?: { USER: string; HOST: string; DATABASE: string; PASSWORD: string; PORT: number } };
|
||||||
|
|
||||||
|
const basePoolConfig = (): Partial<PoolConfig> => {
|
||||||
|
const connStr = (process.env.TSDB_PG_URL || "").replace(/\s+/g, "");
|
||||||
|
if (connStr) {
|
||||||
|
// Only enable SSL for non-local connections or when explicitly requested
|
||||||
|
const isLocal = connStr.includes("localhost") || connStr.includes("127.0.0.1") || connStr.includes("timescaledb");
|
||||||
|
const sslRequested = connStr.includes("sslmode=require") || process.env.TSDB_SSL === "true";
|
||||||
|
const ssl = !isLocal || sslRequested ? { rejectUnauthorized: false } : false;
|
||||||
|
return { connectionString: connStr, ssl };
|
||||||
|
}
|
||||||
|
if (typeof _GLOBAL_CONST !== "undefined" && _GLOBAL_CONST.ACHO_PG_CONFIG) {
|
||||||
|
const cfg = _GLOBAL_CONST.ACHO_PG_CONFIG;
|
||||||
|
return {
|
||||||
|
user: cfg.USER,
|
||||||
|
host: cfg.HOST,
|
||||||
|
database: cfg.DATABASE,
|
||||||
|
password: cfg.PASSWORD,
|
||||||
|
port: cfg.PORT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTeamPool = async (team_id: string | number, overrideConfig?: Partial<PoolConfig>): Promise<Pool> => {
|
||||||
|
const schema = buildSchemaName(team_id);
|
||||||
|
if (poolCache.has(schema)) return poolCache.get(schema)!;
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
...basePoolConfig(),
|
||||||
|
...(overrideConfig || {}),
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pool-level errors to prevent unhandled rejections
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
console.error(`[team_context] Pool error for schema ${schema}:`, err.message);
|
||||||
|
// Remove from cache to force fresh pool on next request
|
||||||
|
poolCache.delete(schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure schema exists and set search_path per connection
|
||||||
|
pool.on("connect", (client: PoolClient) => {
|
||||||
|
// Fire-and-forget with error handling - don't await in event handler
|
||||||
|
client.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`)
|
||||||
|
.then(() => client.query(`SET search_path TO ${schema}, public`))
|
||||||
|
.catch((err: Error) => {
|
||||||
|
console.error(`[team_context] Schema setup error for ${schema}:`, err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
poolCache.set(schema, pool);
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseToken,
|
||||||
|
buildSchemaName,
|
||||||
|
getTeamPool,
|
||||||
|
};
|
||||||
@@ -0,0 +1,955 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { Pool, PoolClient } from "pg";
|
||||||
|
import pricingService from "./pricing_service";
|
||||||
|
|
||||||
|
let _tsdbPool: Pool | undefined;
|
||||||
|
let _schemaReadyPromise: Promise<void> | null;
|
||||||
|
const _schemaReadyByName = new Map<string, Promise<void>>(); // Per-schema initialization tracking
|
||||||
|
const SCHEMA_SQL = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8");
|
||||||
|
|
||||||
|
const safeParseJson = (val: unknown): unknown => {
|
||||||
|
if (val === null || val === undefined) return null;
|
||||||
|
if (typeof val === "object") return val;
|
||||||
|
if (typeof val === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(val);
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asObject = (val: unknown, fallback: Record<string, unknown> = {}): Record<string, unknown> => {
|
||||||
|
const parsed = safeParseJson(val);
|
||||||
|
if (parsed && !Array.isArray(parsed) && typeof parsed === "object") return parsed as Record<string, unknown>;
|
||||||
|
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asArray = (val: unknown, fallback: unknown[] = []): unknown[] => {
|
||||||
|
if (Array.isArray(val)) return val;
|
||||||
|
const parsed = safeParseJson(val);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
if (typeof val === "string") {
|
||||||
|
const trimmed = val.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||||
|
const inner = trimmed.slice(1, -1).trim();
|
||||||
|
if (!inner) return [];
|
||||||
|
return inner
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim().replace(/^"+|"+$/g, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [val];
|
||||||
|
}
|
||||||
|
if (val !== null && val !== undefined && typeof val !== "object" && typeof val !== "function") {
|
||||||
|
return [val];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMetadata = (raw: Record<string, unknown>): Record<string, unknown> | null => {
|
||||||
|
const base = asObject(raw.metadata ?? raw.meta ?? raw.properties ?? raw.extra, {});
|
||||||
|
const tags = asArray(raw.tags ?? raw.labels, []) as string[];
|
||||||
|
if (tags && tags.length) {
|
||||||
|
base.tags = tags;
|
||||||
|
}
|
||||||
|
const sessionId = raw.session_id ?? raw.sessionId;
|
||||||
|
if (sessionId !== undefined && sessionId !== null && base.session_id === undefined) {
|
||||||
|
base.session_id = sessionId;
|
||||||
|
}
|
||||||
|
const environment = raw.environment ?? raw.env;
|
||||||
|
if (environment && base.environment === undefined) {
|
||||||
|
base.environment = environment;
|
||||||
|
}
|
||||||
|
return Object.keys(base).length ? base : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UsageData {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
cached_tokens?: number;
|
||||||
|
reasoning_tokens?: number;
|
||||||
|
accepted_prediction_tokens?: number;
|
||||||
|
rejected_prediction_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcCost = (model: string, usage: UsageData = {}): number => {
|
||||||
|
const inputTokens = Number.isFinite(Number(usage.input_tokens)) ? Number(usage.input_tokens) : 0;
|
||||||
|
const outputTokens = Number.isFinite(Number(usage.output_tokens)) ? Number(usage.output_tokens) : 0;
|
||||||
|
const cachedTokens = Number.isFinite(Number(usage.cached_tokens)) ? Number(usage.cached_tokens) : 0;
|
||||||
|
|
||||||
|
const result = pricingService.calculateCostSync({
|
||||||
|
model: model || "",
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cached_tokens: cachedTokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.total;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Content Storage Types and Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ContentCapture {
|
||||||
|
system_prompt?: string;
|
||||||
|
messages?: unknown[];
|
||||||
|
tools?: unknown[];
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
response_content?: string;
|
||||||
|
finish_reason?: string;
|
||||||
|
choice_count?: number;
|
||||||
|
has_images?: boolean;
|
||||||
|
image_urls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentReference {
|
||||||
|
content_type: string;
|
||||||
|
content_hash: string;
|
||||||
|
byte_size: number;
|
||||||
|
message_count?: number;
|
||||||
|
truncated_preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentToStore {
|
||||||
|
content_hash: string;
|
||||||
|
content: string;
|
||||||
|
byte_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SHA-256 hash of content for content-addressable storage
|
||||||
|
*/
|
||||||
|
const hashContent = (content: string): string => {
|
||||||
|
return crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a truncated preview of content (first 200 chars)
|
||||||
|
*/
|
||||||
|
const createPreview = (content: string, maxLength: number = 200): string => {
|
||||||
|
if (!content || content.length <= maxLength) return content || "";
|
||||||
|
return content.slice(0, maxLength) + "...";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content from ContentCapture and prepare for storage
|
||||||
|
* Returns content references for warm table and content items for cold table
|
||||||
|
*/
|
||||||
|
const extractContent = (
|
||||||
|
contentCapture: ContentCapture | null | undefined
|
||||||
|
): { refs: ContentReference[]; items: ContentToStore[] } => {
|
||||||
|
if (!contentCapture) {
|
||||||
|
return { refs: [], items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs: ContentReference[] = [];
|
||||||
|
const items: ContentToStore[] = [];
|
||||||
|
const seenHashes = new Set<string>();
|
||||||
|
|
||||||
|
// Helper to process a content field
|
||||||
|
const processContent = (
|
||||||
|
type: string,
|
||||||
|
value: unknown,
|
||||||
|
messageCount?: number
|
||||||
|
): void => {
|
||||||
|
if (value === null || value === undefined) return;
|
||||||
|
|
||||||
|
const contentStr = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
if (!contentStr || contentStr === "null" || contentStr === "{}") return;
|
||||||
|
|
||||||
|
const hash = hashContent(contentStr);
|
||||||
|
const byteSize = Buffer.byteLength(contentStr, "utf8");
|
||||||
|
|
||||||
|
refs.push({
|
||||||
|
content_type: type,
|
||||||
|
content_hash: hash,
|
||||||
|
byte_size: byteSize,
|
||||||
|
message_count: messageCount,
|
||||||
|
truncated_preview: createPreview(contentStr),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only store content once per hash (deduplication within batch)
|
||||||
|
if (!seenHashes.has(hash)) {
|
||||||
|
seenHashes.add(hash);
|
||||||
|
items.push({
|
||||||
|
content_hash: hash,
|
||||||
|
content: contentStr,
|
||||||
|
byte_size: byteSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract each content type
|
||||||
|
if (contentCapture.system_prompt) {
|
||||||
|
processContent("system_prompt", contentCapture.system_prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentCapture.messages && Array.isArray(contentCapture.messages) && contentCapture.messages.length > 0) {
|
||||||
|
processContent("messages", contentCapture.messages, contentCapture.messages.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentCapture.response_content) {
|
||||||
|
processContent("response", contentCapture.response_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentCapture.tools && Array.isArray(contentCapture.tools) && contentCapture.tools.length > 0) {
|
||||||
|
processContent("tools", contentCapture.tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only store params if they have meaningful values (not all nulls)
|
||||||
|
if (contentCapture.params) {
|
||||||
|
const hasValues = Object.values(contentCapture.params).some(
|
||||||
|
(v) => v !== null && v !== undefined
|
||||||
|
);
|
||||||
|
if (hasValues) {
|
||||||
|
processContent("params", contentCapture.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { refs, items };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseDate = (val: unknown): Date | null => {
|
||||||
|
if (!val) return null;
|
||||||
|
const d = new Date(val as string | number | Date);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const numberOrNull = (val: unknown): number | null => {
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RawEvent {
|
||||||
|
timestamp?: unknown;
|
||||||
|
team_id?: unknown;
|
||||||
|
traceId?: string;
|
||||||
|
trace_id?: string;
|
||||||
|
spanId?: string;
|
||||||
|
span_id?: string;
|
||||||
|
parent_span_id?: string;
|
||||||
|
callSequence?: number;
|
||||||
|
call_sequence?: number;
|
||||||
|
requestId?: string;
|
||||||
|
request_id?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
stream?: boolean;
|
||||||
|
agent?: string;
|
||||||
|
agent_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
latency_ms?: number;
|
||||||
|
usage?: UsageData;
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
cached_tokens?: number;
|
||||||
|
reasoning_tokens?: number;
|
||||||
|
accepted_prediction_tokens?: number;
|
||||||
|
rejected_prediction_tokens?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
tags?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
session_id?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
environment?: string;
|
||||||
|
env?: string;
|
||||||
|
agentStack?: string[];
|
||||||
|
agent_stack?: string[];
|
||||||
|
callSite?: Record<string, unknown>;
|
||||||
|
call_site?: Record<string, unknown>;
|
||||||
|
call_site_file?: string;
|
||||||
|
call_site_line?: number;
|
||||||
|
call_site_column?: number;
|
||||||
|
call_site_function?: string;
|
||||||
|
call_stack?: string[];
|
||||||
|
content_capture?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedEvent {
|
||||||
|
timestamp: Date;
|
||||||
|
ingest_date: string;
|
||||||
|
team_id: string;
|
||||||
|
trace_id: string;
|
||||||
|
span_id: string | null;
|
||||||
|
parent_span_id: string | null;
|
||||||
|
request_id: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
call_sequence: number;
|
||||||
|
model: string;
|
||||||
|
stream: boolean;
|
||||||
|
agent: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
user_id: string | null;
|
||||||
|
latency_ms: number | null;
|
||||||
|
usage_input_tokens: number | null;
|
||||||
|
usage_output_tokens: number | null;
|
||||||
|
usage_total_tokens: number | null;
|
||||||
|
usage_cached_tokens: number | null;
|
||||||
|
usage_reasoning_tokens: number | null;
|
||||||
|
usage_accepted_prediction_tokens: number | null;
|
||||||
|
usage_rejected_prediction_tokens: number | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
call_site: Record<string, unknown>;
|
||||||
|
agent_stack: string[];
|
||||||
|
cost_total: number;
|
||||||
|
// Hot table fields (lightweight content indicators)
|
||||||
|
has_content: boolean;
|
||||||
|
finish_reason: string | null;
|
||||||
|
tool_call_count: number;
|
||||||
|
// Content data for warm/cold storage (extracted separately)
|
||||||
|
content_refs: ContentReference[];
|
||||||
|
content_items: ContentToStore[];
|
||||||
|
// Deprecated: kept for backward compatibility during migration
|
||||||
|
content_capture: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeEvent = (raw: RawEvent): NormalizedEvent | null => {
|
||||||
|
const ts = raw.timestamp;
|
||||||
|
const teamId = raw.team_id;
|
||||||
|
const parsedTs = parseDate(ts);
|
||||||
|
if (!parsedTs) return null;
|
||||||
|
|
||||||
|
const traceId = raw.traceId || raw.trace_id;
|
||||||
|
const spanId = raw.spanId || raw.span_id;
|
||||||
|
const parentSpanId = raw.parent_span_id || null;
|
||||||
|
const callSeqRaw = raw.callSequence ?? raw.call_sequence;
|
||||||
|
if (
|
||||||
|
traceId === undefined ||
|
||||||
|
callSeqRaw === undefined ||
|
||||||
|
callSeqRaw === null ||
|
||||||
|
teamId === undefined ||
|
||||||
|
teamId === null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const callSeq = Number(callSeqRaw);
|
||||||
|
if (!Number.isInteger(callSeq)) return null;
|
||||||
|
|
||||||
|
const usage: UsageData = raw.usage || {
|
||||||
|
input_tokens: raw.input_tokens,
|
||||||
|
output_tokens: raw.output_tokens,
|
||||||
|
total_tokens: raw.total_tokens,
|
||||||
|
cached_tokens: raw.cached_tokens,
|
||||||
|
reasoning_tokens: raw.reasoning_tokens,
|
||||||
|
accepted_prediction_tokens: raw.accepted_prediction_tokens,
|
||||||
|
rejected_prediction_tokens: raw.rejected_prediction_tokens,
|
||||||
|
};
|
||||||
|
// Extract agent - metadata.agent takes precedence over top-level agent
|
||||||
|
const metadata = asObject(raw.metadata, {});
|
||||||
|
const effectiveAgent = (metadata.agent as string) || raw.agent || null;
|
||||||
|
|
||||||
|
let agentStack = asArray(raw.agentStack ?? raw.agent_stack, []) as string[];
|
||||||
|
if (effectiveAgent) {
|
||||||
|
const agentVal = String(effectiveAgent);
|
||||||
|
if (!agentStack.includes(agentVal)) {
|
||||||
|
agentStack = [agentVal, ...agentStack];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const callSite =
|
||||||
|
raw.callSite ||
|
||||||
|
asObject(raw.call_site, {
|
||||||
|
file: raw.call_site_file,
|
||||||
|
line: raw.call_site_line,
|
||||||
|
column: raw.call_site_column,
|
||||||
|
function: raw.call_site_function,
|
||||||
|
stack: asArray(raw.call_stack, []),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract content for warm/cold storage
|
||||||
|
const contentCapture = raw.content_capture as ContentCapture | undefined;
|
||||||
|
const { refs: contentRefs, items: contentItems } = extractContent(contentCapture);
|
||||||
|
|
||||||
|
// Extract lightweight content indicators for hot table
|
||||||
|
const hasContent = contentRefs.length > 0;
|
||||||
|
const finishReason = contentCapture?.finish_reason || null;
|
||||||
|
|
||||||
|
// Count tool calls from messages or tool_calls field
|
||||||
|
let toolCallCount = 0;
|
||||||
|
if (contentCapture?.messages && Array.isArray(contentCapture.messages)) {
|
||||||
|
for (const msg of contentCapture.messages) {
|
||||||
|
const msgObj = msg as Record<string, unknown>;
|
||||||
|
if (msgObj.tool_calls && Array.isArray(msgObj.tool_calls)) {
|
||||||
|
toolCallCount += msgObj.tool_calls.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: parsedTs,
|
||||||
|
ingest_date: parsedTs.toISOString().slice(0, 10),
|
||||||
|
team_id: String(teamId),
|
||||||
|
trace_id: String(traceId),
|
||||||
|
span_id: spanId || null,
|
||||||
|
parent_span_id: parentSpanId,
|
||||||
|
request_id: raw.requestId || raw.request_id || null,
|
||||||
|
provider: raw.provider || null,
|
||||||
|
call_sequence: callSeq,
|
||||||
|
model: raw.model || "",
|
||||||
|
stream: Boolean(raw.stream),
|
||||||
|
agent: agentStack[0] || null,
|
||||||
|
agent_name: raw.agent_name || null,
|
||||||
|
user_id: raw.user_id || null,
|
||||||
|
latency_ms: numberOrNull(raw.latency_ms),
|
||||||
|
usage_input_tokens: numberOrNull(usage.input_tokens),
|
||||||
|
usage_output_tokens: numberOrNull(usage.output_tokens),
|
||||||
|
usage_total_tokens: numberOrNull(usage.total_tokens),
|
||||||
|
usage_cached_tokens: numberOrNull(usage.cached_tokens),
|
||||||
|
usage_reasoning_tokens: numberOrNull(usage.reasoning_tokens),
|
||||||
|
usage_accepted_prediction_tokens: numberOrNull(usage.accepted_prediction_tokens),
|
||||||
|
usage_rejected_prediction_tokens: numberOrNull(usage.rejected_prediction_tokens),
|
||||||
|
metadata: buildMetadata(raw as Record<string, unknown>),
|
||||||
|
call_site: callSite as Record<string, unknown>,
|
||||||
|
agent_stack: agentStack,
|
||||||
|
cost_total: calcCost(raw.model || "", usage),
|
||||||
|
// Hot table content indicators
|
||||||
|
has_content: hasContent,
|
||||||
|
finish_reason: finishReason,
|
||||||
|
tool_call_count: toolCallCount,
|
||||||
|
// Content for warm/cold storage
|
||||||
|
content_refs: contentRefs,
|
||||||
|
content_items: contentItems,
|
||||||
|
// Deprecated: kept for backward compatibility
|
||||||
|
content_capture: raw.content_capture || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupeEvents = (events: NormalizedEvent[]): NormalizedEvent[] => {
|
||||||
|
const deduped = new Map<string, NormalizedEvent>();
|
||||||
|
events.forEach((ev) => {
|
||||||
|
const key = `${ev.trace_id}||${ev.call_sequence}`;
|
||||||
|
const existing = deduped.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
deduped.set(key, ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.timestamp && ev.timestamp && ev.timestamp > existing.timestamp) {
|
||||||
|
deduped.set(key, ev);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(deduped.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEvents = (rawEvents: RawEvent[] = []): NormalizedEvent[] => {
|
||||||
|
const normalized: NormalizedEvent[] = [];
|
||||||
|
rawEvents.forEach((ev) => {
|
||||||
|
const n = normalizeEvent(ev);
|
||||||
|
if (n) normalized.push(n);
|
||||||
|
});
|
||||||
|
return dedupeEvents(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTsdbPool = (): Pool => {
|
||||||
|
if (_tsdbPool) return _tsdbPool;
|
||||||
|
const connStr = (process.env.TSDB_PG_URL || "").replace(/\s+/g, "");
|
||||||
|
if (connStr) {
|
||||||
|
_tsdbPool = new Pool({
|
||||||
|
connectionString: connStr,
|
||||||
|
ssl: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
return _tsdbPool;
|
||||||
|
}
|
||||||
|
if ((global as unknown as Record<string, unknown>)._ACHO_PG_POOL) {
|
||||||
|
_tsdbPool = (global as unknown as Record<string, unknown>)._ACHO_PG_POOL as Pool;
|
||||||
|
return _tsdbPool;
|
||||||
|
}
|
||||||
|
throw new Error("TSDB pool not available. Set TSDB_PG_URL or initialize _ACHO_PG_POOL.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureSchema = async (client?: PoolClient): Promise<void> => {
|
||||||
|
if (client) {
|
||||||
|
// Get current schema name for per-schema caching
|
||||||
|
const schemaResult = await client.query("SELECT current_schema()");
|
||||||
|
const schemaName = schemaResult.rows[0]?.current_schema || "public";
|
||||||
|
|
||||||
|
// Check if this schema is already initialized
|
||||||
|
if (_schemaReadyByName.has(schemaName)) {
|
||||||
|
return _schemaReadyByName.get(schemaName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and cache the initialization promise
|
||||||
|
const initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
await client.query(SCHEMA_SQL);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Handle race condition - if object already exists, it's fine
|
||||||
|
const pgError = err as { code?: string };
|
||||||
|
if (pgError.code === "23505" || pgError.code === "42P07") {
|
||||||
|
// 23505 = unique_violation, 42P07 = duplicate_table
|
||||||
|
console.log(`[tsdb] Schema ${schemaName} already initialized (concurrent request)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
_schemaReadyByName.set(schemaName, initPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initPromise;
|
||||||
|
} catch (err) {
|
||||||
|
_schemaReadyByName.delete(schemaName);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_schemaReadyPromise) return _schemaReadyPromise;
|
||||||
|
|
||||||
|
const pool = getTsdbPool();
|
||||||
|
_schemaReadyPromise = (async () => {
|
||||||
|
const executor = await pool.connect();
|
||||||
|
try {
|
||||||
|
await executor.query(SCHEMA_SQL);
|
||||||
|
} finally {
|
||||||
|
executor.release();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _schemaReadyPromise;
|
||||||
|
} catch (err) {
|
||||||
|
_schemaReadyPromise = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UpsertResult {
|
||||||
|
rowsWritten: number;
|
||||||
|
normalized: number;
|
||||||
|
received?: number;
|
||||||
|
contentStored?: number;
|
||||||
|
contentDeduplicated?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store content in cold storage (llm_content_store) with deduplication
|
||||||
|
* Uses ON CONFLICT to increment ref_count for existing content
|
||||||
|
*/
|
||||||
|
const storeContentCold = async (
|
||||||
|
executor: PoolClient,
|
||||||
|
teamId: string,
|
||||||
|
items: ContentToStore[]
|
||||||
|
): Promise<{ stored: number; deduplicated: number }> => {
|
||||||
|
if (!items.length) return { stored: 0, deduplicated: 0 };
|
||||||
|
|
||||||
|
// Batch upsert content items
|
||||||
|
const cols = ["content_hash", "team_id", "content", "byte_size", "ref_count", "first_seen_at", "last_seen_at"];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
items.forEach((item, idx) => {
|
||||||
|
const base = idx * cols.length;
|
||||||
|
placeholders.push(`(${cols.map((__, i) => `$${base + i + 1}`).join(", ")})`);
|
||||||
|
values.push(item.content_hash, teamId, item.content, item.byte_size, 1, now, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO llm_content_store (${cols.join(", ")})
|
||||||
|
VALUES ${placeholders.join(", ")}
|
||||||
|
ON CONFLICT (content_hash, team_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
ref_count = llm_content_store.ref_count + 1,
|
||||||
|
last_seen_at = EXCLUDED.last_seen_at
|
||||||
|
RETURNING (xmax = 0) AS inserted
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await executor.query(sql, values);
|
||||||
|
const inserted = result.rows.filter((r: { inserted: boolean }) => r.inserted).length;
|
||||||
|
const deduplicated = items.length - inserted;
|
||||||
|
|
||||||
|
return { stored: inserted, deduplicated };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store content references in warm storage (llm_event_content)
|
||||||
|
*/
|
||||||
|
const storeContentWarm = async (
|
||||||
|
executor: PoolClient,
|
||||||
|
events: NormalizedEvent[]
|
||||||
|
): Promise<number> => {
|
||||||
|
// Collect all content references from all events
|
||||||
|
const allRefs: Array<{
|
||||||
|
timestamp: Date;
|
||||||
|
trace_id: string;
|
||||||
|
call_sequence: number;
|
||||||
|
team_id: string;
|
||||||
|
ref: ContentReference;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
for (const ref of ev.content_refs) {
|
||||||
|
allRefs.push({
|
||||||
|
timestamp: ev.timestamp,
|
||||||
|
trace_id: ev.trace_id,
|
||||||
|
call_sequence: ev.call_sequence,
|
||||||
|
team_id: ev.team_id,
|
||||||
|
ref,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allRefs.length) return 0;
|
||||||
|
|
||||||
|
const cols = [
|
||||||
|
'"timestamp"',
|
||||||
|
"trace_id",
|
||||||
|
"call_sequence",
|
||||||
|
"team_id",
|
||||||
|
"content_type",
|
||||||
|
"content_hash",
|
||||||
|
"byte_size",
|
||||||
|
"message_count",
|
||||||
|
"truncated_preview",
|
||||||
|
];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
|
||||||
|
allRefs.forEach((item, idx) => {
|
||||||
|
const base = idx * cols.length;
|
||||||
|
placeholders.push(`(${cols.map((__, i) => `$${base + i + 1}`).join(", ")})`);
|
||||||
|
values.push(
|
||||||
|
item.timestamp,
|
||||||
|
item.trace_id,
|
||||||
|
item.call_sequence,
|
||||||
|
item.team_id,
|
||||||
|
item.ref.content_type,
|
||||||
|
item.ref.content_hash,
|
||||||
|
item.ref.byte_size,
|
||||||
|
item.ref.message_count || null,
|
||||||
|
item.ref.truncated_preview || null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO llm_event_content (${cols.join(", ")})
|
||||||
|
VALUES ${placeholders.join(", ")}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await executor.query(sql, values);
|
||||||
|
return allRefs.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertEvents = async (rawEvents: RawEvent[] = [], client?: PoolClient): Promise<UpsertResult> => {
|
||||||
|
const events = normalizeEvents(rawEvents);
|
||||||
|
if (!events.length) {
|
||||||
|
return { rowsWritten: 0, normalized: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot table columns (metrics only, no full content_capture)
|
||||||
|
const cols = [
|
||||||
|
'"timestamp"',
|
||||||
|
"ingest_date",
|
||||||
|
"team_id",
|
||||||
|
"user_id",
|
||||||
|
"trace_id",
|
||||||
|
"span_id",
|
||||||
|
"parent_span_id",
|
||||||
|
"request_id",
|
||||||
|
"provider",
|
||||||
|
"call_sequence",
|
||||||
|
"model",
|
||||||
|
"stream",
|
||||||
|
"agent",
|
||||||
|
"agent_name",
|
||||||
|
"latency_ms",
|
||||||
|
"usage_input_tokens",
|
||||||
|
"usage_output_tokens",
|
||||||
|
"usage_total_tokens",
|
||||||
|
"usage_cached_tokens",
|
||||||
|
"usage_reasoning_tokens",
|
||||||
|
"usage_accepted_prediction_tokens",
|
||||||
|
"usage_rejected_prediction_tokens",
|
||||||
|
"call_site",
|
||||||
|
"metadata",
|
||||||
|
"agent_stack",
|
||||||
|
"cost_total",
|
||||||
|
// New lightweight content fields
|
||||||
|
"has_content",
|
||||||
|
"finish_reason",
|
||||||
|
"tool_call_count",
|
||||||
|
// Deprecated: kept for backward compatibility during migration
|
||||||
|
"content_capture",
|
||||||
|
];
|
||||||
|
|
||||||
|
const values: unknown[] = [];
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
events.forEach((ev, idx) => {
|
||||||
|
const base = idx * cols.length;
|
||||||
|
placeholders.push(`(${cols.map((__, i) => `$${base + i + 1}`).join(", ")})`);
|
||||||
|
values.push(
|
||||||
|
ev.timestamp,
|
||||||
|
ev.ingest_date,
|
||||||
|
ev.team_id,
|
||||||
|
ev.user_id,
|
||||||
|
ev.trace_id,
|
||||||
|
ev.span_id,
|
||||||
|
ev.parent_span_id,
|
||||||
|
ev.request_id,
|
||||||
|
ev.provider,
|
||||||
|
ev.call_sequence,
|
||||||
|
ev.model,
|
||||||
|
ev.stream,
|
||||||
|
ev.agent,
|
||||||
|
ev.agent_name,
|
||||||
|
ev.latency_ms,
|
||||||
|
ev.usage_input_tokens,
|
||||||
|
ev.usage_output_tokens,
|
||||||
|
ev.usage_total_tokens,
|
||||||
|
ev.usage_cached_tokens,
|
||||||
|
ev.usage_reasoning_tokens,
|
||||||
|
ev.usage_accepted_prediction_tokens,
|
||||||
|
ev.usage_rejected_prediction_tokens,
|
||||||
|
JSON.stringify(ev.call_site || {}),
|
||||||
|
ev.metadata ? JSON.stringify(ev.metadata) : null,
|
||||||
|
JSON.stringify(ev.agent_stack || []),
|
||||||
|
ev.cost_total,
|
||||||
|
// New fields
|
||||||
|
ev.has_content,
|
||||||
|
ev.finish_reason,
|
||||||
|
ev.tool_call_count,
|
||||||
|
// Deprecated: store null for new events, keep for backward compat
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO llm_events (${cols.join(", ")})
|
||||||
|
VALUES ${placeholders.join(", ")}
|
||||||
|
ON CONFLICT ("timestamp", trace_id, call_sequence)
|
||||||
|
DO UPDATE SET
|
||||||
|
"timestamp" = EXCLUDED."timestamp",
|
||||||
|
ingest_date = EXCLUDED.ingest_date,
|
||||||
|
team_id = EXCLUDED.team_id,
|
||||||
|
user_id = EXCLUDED.user_id,
|
||||||
|
trace_id = EXCLUDED.trace_id,
|
||||||
|
span_id = EXCLUDED.span_id,
|
||||||
|
parent_span_id = EXCLUDED.parent_span_id,
|
||||||
|
request_id = EXCLUDED.request_id,
|
||||||
|
provider = EXCLUDED.provider,
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
stream = EXCLUDED.stream,
|
||||||
|
agent = EXCLUDED.agent,
|
||||||
|
agent_name = EXCLUDED.agent_name,
|
||||||
|
latency_ms = EXCLUDED.latency_ms,
|
||||||
|
usage_input_tokens = EXCLUDED.usage_input_tokens,
|
||||||
|
usage_output_tokens = EXCLUDED.usage_output_tokens,
|
||||||
|
usage_total_tokens = EXCLUDED.usage_total_tokens,
|
||||||
|
usage_cached_tokens = EXCLUDED.usage_cached_tokens,
|
||||||
|
usage_reasoning_tokens = EXCLUDED.usage_reasoning_tokens,
|
||||||
|
usage_accepted_prediction_tokens = EXCLUDED.usage_accepted_prediction_tokens,
|
||||||
|
usage_rejected_prediction_tokens = EXCLUDED.usage_rejected_prediction_tokens,
|
||||||
|
call_site = EXCLUDED.call_site,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
agent_stack = EXCLUDED.agent_stack,
|
||||||
|
cost_total = EXCLUDED.cost_total,
|
||||||
|
has_content = EXCLUDED.has_content,
|
||||||
|
finish_reason = EXCLUDED.finish_reason,
|
||||||
|
tool_call_count = EXCLUDED.tool_call_count
|
||||||
|
WHERE EXCLUDED."timestamp" >= llm_events."timestamp"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const pool = client ? null : getTsdbPool();
|
||||||
|
const executor = client || (await pool!.connect());
|
||||||
|
|
||||||
|
let contentStored = 0;
|
||||||
|
let contentDeduplicated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Insert into hot table (llm_events)
|
||||||
|
await executor.query(sql, values);
|
||||||
|
|
||||||
|
// 2. Collect all content items for cold storage (deduplicated across events)
|
||||||
|
const allContentItems: ContentToStore[] = [];
|
||||||
|
const seenHashes = new Set<string>();
|
||||||
|
const teamId = events[0]?.team_id;
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
for (const item of ev.content_items) {
|
||||||
|
if (!seenHashes.has(item.content_hash)) {
|
||||||
|
seenHashes.add(item.content_hash);
|
||||||
|
allContentItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Store content in cold storage (llm_content_store)
|
||||||
|
if (allContentItems.length > 0 && teamId) {
|
||||||
|
const coldResult = await storeContentCold(executor, teamId, allContentItems);
|
||||||
|
contentStored = coldResult.stored;
|
||||||
|
contentDeduplicated = coldResult.deduplicated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store content references in warm storage (llm_event_content)
|
||||||
|
await storeContentWarm(executor, events);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
if (!client && executor && 'release' in executor) {
|
||||||
|
(executor as PoolClient).release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowsWritten: events.length,
|
||||||
|
normalized: events.length,
|
||||||
|
received: rawEvents.length,
|
||||||
|
contentStored,
|
||||||
|
contentDeduplicated,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve content from cold storage by hash
|
||||||
|
*/
|
||||||
|
const getContentByHash = async (
|
||||||
|
teamId: string,
|
||||||
|
contentHash: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const pool = client ? null : getTsdbPool();
|
||||||
|
const executor = client || (await pool!.connect());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executor.query(
|
||||||
|
`SELECT content FROM llm_content_store WHERE content_hash = $1 AND team_id = $2`,
|
||||||
|
[contentHash, teamId]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.content || null;
|
||||||
|
} finally {
|
||||||
|
if (!client && executor && "release" in executor) {
|
||||||
|
(executor as PoolClient).release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all content references for an event
|
||||||
|
*/
|
||||||
|
const getEventContent = async (
|
||||||
|
teamId: string,
|
||||||
|
traceId: string,
|
||||||
|
callSequence: number,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<Array<ContentReference & { content?: string }>> => {
|
||||||
|
const pool = client ? null : getTsdbPool();
|
||||||
|
const executor = client || (await pool!.connect());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get content references from warm storage
|
||||||
|
const refsResult = await executor.query(
|
||||||
|
`SELECT content_type, content_hash, byte_size, message_count, truncated_preview
|
||||||
|
FROM llm_event_content
|
||||||
|
WHERE team_id = $1 AND trace_id = $2 AND call_sequence = $3`,
|
||||||
|
[teamId, traceId, callSequence]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refs = refsResult.rows as ContentReference[];
|
||||||
|
|
||||||
|
// Optionally fetch full content from cold storage
|
||||||
|
const results: Array<ContentReference & { content?: string }> = [];
|
||||||
|
for (const ref of refs) {
|
||||||
|
const content = await getContentByHash(teamId, ref.content_hash, executor);
|
||||||
|
results.push({ ...ref, content: content || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
if (!client && executor && "release" in executor) {
|
||||||
|
(executor as PoolClient).release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DistinctAgentRecord {
|
||||||
|
agent: string;
|
||||||
|
agent_name: string | null;
|
||||||
|
first_seen: Date;
|
||||||
|
last_seen: Date;
|
||||||
|
total_requests: number;
|
||||||
|
total_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all distinct agents from events for a team
|
||||||
|
* Returns agent identifiers with their first/last seen timestamps and usage stats
|
||||||
|
*/
|
||||||
|
const getDistinctAgents = async (
|
||||||
|
teamId: string,
|
||||||
|
options: {
|
||||||
|
since?: Date;
|
||||||
|
limit?: number;
|
||||||
|
} = {},
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<DistinctAgentRecord[]> => {
|
||||||
|
const pool = client ? null : getTsdbPool();
|
||||||
|
const executor = client || (await pool!.connect());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { since, limit = 100 } = options;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
agent,
|
||||||
|
MAX(agent_name) as agent_name,
|
||||||
|
MIN("timestamp") as first_seen,
|
||||||
|
MAX("timestamp") as last_seen,
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(cost_total), 0) as total_cost
|
||||||
|
FROM llm_events
|
||||||
|
WHERE team_id = $1
|
||||||
|
AND agent IS NOT NULL
|
||||||
|
AND agent != ''
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: unknown[] = [teamId];
|
||||||
|
|
||||||
|
if (since) {
|
||||||
|
sql += ` AND "timestamp" >= $${params.length + 1}`;
|
||||||
|
params.push(since);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
GROUP BY agent
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT $${params.length + 1}
|
||||||
|
`;
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const result = await executor.query(sql, params);
|
||||||
|
|
||||||
|
return result.rows.map((row: Record<string, unknown>) => ({
|
||||||
|
agent: row.agent as string,
|
||||||
|
agent_name: row.agent_name as string | null,
|
||||||
|
first_seen: new Date(row.first_seen as string),
|
||||||
|
last_seen: new Date(row.last_seen as string),
|
||||||
|
total_requests: Number(row.total_requests),
|
||||||
|
total_cost: Number(row.total_cost),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (!client && executor && "release" in executor) {
|
||||||
|
(executor as PoolClient).release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeEvent,
|
||||||
|
normalizeEvents,
|
||||||
|
ensureSchema,
|
||||||
|
upsertEvents,
|
||||||
|
getTsdbPool,
|
||||||
|
getContentByHash,
|
||||||
|
getEventContent,
|
||||||
|
getDistinctAgents,
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
-- User Authentication Schema for PostgreSQL (Local Development)
|
||||||
|
-- This schema mirrors the MySQL user tables for local development
|
||||||
|
-- Run this on your local PostgreSQL/TimescaleDB instance
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- USERS TABLE: Core user accounts
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255),
|
||||||
|
name VARCHAR(255),
|
||||||
|
firstname VARCHAR(255),
|
||||||
|
lastname VARCHAR(255),
|
||||||
|
-- JWT authentication (TEXT for long JWT tokens)
|
||||||
|
token TEXT UNIQUE,
|
||||||
|
salt TEXT,
|
||||||
|
-- Team association
|
||||||
|
current_team_id INTEGER,
|
||||||
|
-- Account status
|
||||||
|
status VARCHAR(50) DEFAULT 'active',
|
||||||
|
email_verified BOOLEAN DEFAULT false,
|
||||||
|
-- Metadata
|
||||||
|
avatar_url TEXT,
|
||||||
|
preferences JSONB DEFAULT '{}',
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_token ON users (token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_team ON users (current_team_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- DEVELOPERS TABLE: API tokens for programmatic access
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS developers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
label VARCHAR(255),
|
||||||
|
-- System tokens are managed by the platform, not users
|
||||||
|
"system" BOOLEAN DEFAULT false,
|
||||||
|
-- Permissions and scope
|
||||||
|
scopes JSONB DEFAULT '[]',
|
||||||
|
-- Rate limiting
|
||||||
|
rate_limit INTEGER DEFAULT 1000,
|
||||||
|
-- Timestamps
|
||||||
|
create_time BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
-- Status
|
||||||
|
revoked BOOLEAN DEFAULT false,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for token lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_developers_token ON developers (token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_developers_user ON developers (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_developers_team ON developers (team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_developers_user_team ON developers (user_id, team_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- TEAMS TABLE: Team/Organization accounts
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE,
|
||||||
|
-- Billing and subscription
|
||||||
|
plan VARCHAR(50) DEFAULT 'free',
|
||||||
|
billing_email VARCHAR(255),
|
||||||
|
-- Settings
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- TEAM_MEMBERS TABLE: User-Team associations
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(50) DEFAULT 'member',
|
||||||
|
-- Timestamps
|
||||||
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, team_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members (team_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SEED DATA: Default development user and team
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Create a default team
|
||||||
|
INSERT INTO teams (id, name, slug, plan)
|
||||||
|
VALUES (1, 'Development Team', 'dev-team', 'enterprise')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Create a default development user
|
||||||
|
-- Email: dev@honeycomb.local
|
||||||
|
-- Password: honeycomb123
|
||||||
|
INSERT INTO users (id, email, password, name, firstname, lastname, token, salt, current_team_id, status, email_verified)
|
||||||
|
VALUES (
|
||||||
|
1,
|
||||||
|
'dev@honeycomb.local',
|
||||||
|
'$2b$10$BgXnS6Cg7HwimTzBtsnh0.j8s8.ypWFooW9A.7YbNIC4e94HIFxYu',
|
||||||
|
'Development User',
|
||||||
|
'Dev',
|
||||||
|
'User',
|
||||||
|
'dev-token-12345',
|
||||||
|
'dev-salt-secret-key',
|
||||||
|
1,
|
||||||
|
'active',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Create a default API token for the development user
|
||||||
|
INSERT INTO developers (id, user_id, team_id, token, label, "system")
|
||||||
|
VALUES (
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
'hive_dev_token_abc123xyz',
|
||||||
|
'Development API Token',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Add user to team
|
||||||
|
INSERT INTO team_members (user_id, team_id, role)
|
||||||
|
VALUES (1, 1, 'admin')
|
||||||
|
ON CONFLICT (user_id, team_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Reset sequences to avoid conflicts
|
||||||
|
SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1));
|
||||||
|
SELECT setval('teams_id_seq', COALESCE((SELECT MAX(id) FROM teams), 1));
|
||||||
|
SELECT setval('developers_id_seq', COALESCE((SELECT MAX(id) FROM developers), 1));
|
||||||
|
SELECT setval('team_members_id_seq', COALESCE((SELECT MAX(id) FROM team_members), 1));
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Control Socket Initialization
|
||||||
|
*
|
||||||
|
* Wrapper for initializing control plane WebSockets with proper dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import { Emitter } from '@socket.io/redis-emitter';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import type { Server as HttpServer } from 'http';
|
||||||
|
|
||||||
|
import initAdenControlSockets, { setUserDbService } from '../services/control/control_sockets';
|
||||||
|
|
||||||
|
interface ControlEmitter {
|
||||||
|
emitPolicyUpdate: (teamId: string | number, policyId: string | null, policy: unknown) => void;
|
||||||
|
emitCommand: (teamId: string | number, command: { action: string; [key: string]: unknown }) => void;
|
||||||
|
emitAlert: (teamId: string | number, policyId: string | null, alert: unknown) => void;
|
||||||
|
emitToInstance: (teamId: string | number, instanceId: string, message: unknown) => boolean;
|
||||||
|
getConnectedCount: (teamId: string | number) => number;
|
||||||
|
getConnectedInstances: (teamId: string | number) => Array<{
|
||||||
|
instance_id: string;
|
||||||
|
policy_id: string | null;
|
||||||
|
connected_at: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
}>;
|
||||||
|
getTotalConnectedCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockEmitter {
|
||||||
|
of: () => {
|
||||||
|
to: () => { emit: () => void };
|
||||||
|
emit: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WebSockets for the control plane
|
||||||
|
* @param server - HTTP server instance
|
||||||
|
* @returns Promise<{io: Server, controlEmitter: Object}>
|
||||||
|
*/
|
||||||
|
async function initializeSockets(server: HttpServer): Promise<{ io: Server; controlEmitter: ControlEmitter }> {
|
||||||
|
// Create Socket.IO server
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let controlEmitter: ControlEmitter;
|
||||||
|
|
||||||
|
// Try to setup Redis adapter for scaling
|
||||||
|
if (process.env.REDIS_URL) {
|
||||||
|
try {
|
||||||
|
const pubClient = new Redis(process.env.REDIS_URL);
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
new Promise<void>((resolve) => pubClient.on('connect', resolve)),
|
||||||
|
new Promise<void>((resolve) => subClient.on('connect', resolve)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
io.adapter(createAdapter(pubClient, subClient));
|
||||||
|
|
||||||
|
// Create Redis emitter for cross-instance communication
|
||||||
|
const redisEmitter = new Emitter(pubClient);
|
||||||
|
controlEmitter = initAdenControlSockets(io, redisEmitter as unknown as { of: (namespace: string) => { to: (room: string) => { emit: (event: string, payload: unknown) => void }; emit: (event: string, payload: unknown) => void } });
|
||||||
|
|
||||||
|
console.log('[Sockets] Redis adapter connected');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Sockets] Redis connection failed, using local adapter:', (err as Error).message);
|
||||||
|
// Create a mock emitter for local development
|
||||||
|
const mockEmitter: MockEmitter = {
|
||||||
|
of: () => ({
|
||||||
|
to: () => ({ emit: () => {} }),
|
||||||
|
emit: () => {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
controlEmitter = initAdenControlSockets(io, mockEmitter as unknown as { of: (namespace: string) => { to: (room: string) => { emit: (event: string, payload: unknown) => void }; emit: (event: string, payload: unknown) => void } });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[Sockets] No REDIS_URL configured, using local adapter');
|
||||||
|
// Create a mock emitter for local development
|
||||||
|
const mockEmitter: MockEmitter = {
|
||||||
|
of: () => ({
|
||||||
|
to: () => ({ emit: () => {} }),
|
||||||
|
emit: () => {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
controlEmitter = initAdenControlSockets(io, mockEmitter as unknown as { of: (namespace: string) => { to: (room: string) => { emit: (event: string, payload: unknown) => void }; emit: (event: string, payload: unknown) => void } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { io, controlEmitter };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initializeSockets, setUserDbService };
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
declare module '@acho-inc/administration' {
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
|
export interface MySQLPoolConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
user?: string;
|
||||||
|
password?: string;
|
||||||
|
database?: string;
|
||||||
|
ssl?: {
|
||||||
|
ca?: string | Buffer;
|
||||||
|
key?: string | Buffer;
|
||||||
|
cert?: string | Buffer;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDbServiceConfig {
|
||||||
|
/** MySQL connection pool (for production) */
|
||||||
|
mysqlPool?: any;
|
||||||
|
/** PostgreSQL connection pool (for local development) */
|
||||||
|
pgPool?: Pool;
|
||||||
|
/** Database type: 'mysql' or 'postgres' */
|
||||||
|
dbType?: 'mysql' | 'postgres';
|
||||||
|
/** Redis client for caching (optional) */
|
||||||
|
redisClient?: any;
|
||||||
|
/** Table name mapping */
|
||||||
|
tables: {
|
||||||
|
USER: string;
|
||||||
|
DEVELOPERS?: string;
|
||||||
|
};
|
||||||
|
/** Service account salt lookup function (optional) */
|
||||||
|
findServiceAccountSalt?: (token: string) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevTokenObject {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
team_id: number;
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
system?: boolean;
|
||||||
|
create_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
name?: string;
|
||||||
|
current_team_id?: number;
|
||||||
|
created_at?: Date | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResult {
|
||||||
|
token: string;
|
||||||
|
salt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginOptions {
|
||||||
|
jwtSecret: string;
|
||||||
|
expiresIn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterOptions extends LoginOptions {
|
||||||
|
defaultTeamId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name?: string;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResult {
|
||||||
|
id: number;
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
current_team_id?: number;
|
||||||
|
created_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDbService {
|
||||||
|
findSaltByToken: (token: string) => Promise<string | null>;
|
||||||
|
findById: (id: number) => Promise<any>;
|
||||||
|
findByToken: (token: string) => Promise<any>;
|
||||||
|
findByEmail: (email: string) => Promise<any>;
|
||||||
|
getLatestUserDevToken: (user: { id: number; current_team_id: number }) => Promise<DevTokenObject | null>;
|
||||||
|
// Auth methods
|
||||||
|
verifyPassword: (password: string, hash: string) => Promise<boolean>;
|
||||||
|
hashPassword: (password: string) => Promise<string>;
|
||||||
|
generateToken: (user: any, options: LoginOptions) => Promise<TokenResult>;
|
||||||
|
updateUserToken: (userId: number, token: string, salt: string) => Promise<void>;
|
||||||
|
login: (email: string, password: string, options: LoginOptions) => Promise<LoginResult>;
|
||||||
|
register: (userData: UserData, options: RegisterOptions) => Promise<RegisterResult>;
|
||||||
|
dbType?: 'mysql' | 'postgres';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassportStrategyConfig {
|
||||||
|
findSaltByToken: (token: string) => Promise<string | null>;
|
||||||
|
jwtSecret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth: {
|
||||||
|
createPassportStrategy: (config: PassportStrategyConfig) => Strategy;
|
||||||
|
verifyToken: (token: string, secret: string) => Promise<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const database: {
|
||||||
|
createMySQLPool: (config: MySQLPoolConfig) => any;
|
||||||
|
createPGPool: (connectionString: string) => Pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const models: {
|
||||||
|
createUserDbService: (config: UserDbServiceConfig) => UserDbService;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"useUnknownInCatchVariables": false,
|
||||||
|
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Frontend Environment Variables
|
||||||
|
# Copy this file to .env and update values as needed
|
||||||
|
# Or run `npm run generate:env` from the root to generate from config.yaml
|
||||||
|
|
||||||
|
# Hive API URL (handles all backend endpoints: auth, user, IAM, agent control)
|
||||||
|
VITE_API_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# Application settings
|
||||||
|
VITE_APP_NAME=Beeline
|
||||||
|
VITE_APP_ENV=development
|
||||||
|
|
||||||
|
# Google OAuth (optional)
|
||||||
|
VITE_GOOGLE_OAUTH_ID=your-google-oauth-client-id
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build argument for API URL (Vite needs this at build time)
|
||||||
|
ARG VITE_API_URL=http://localhost:4000
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy built assets from builder
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Development Dockerfile with hot reload
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start development server with hot reload
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/styles/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Beeline - AI agent observability and control" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||||
|
<title>Beeline</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied expired no-cache no-store private auth;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# Handle SPA routing - serve index.html for all routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://hive:4000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "honeycomb",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.71.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"react-vega": "^8.0.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vega": "^6.2.0",
|
||||||
|
"vega-embed": "^7.1.0",
|
||||||
|
"vega-lite": "^6.4.1",
|
||||||
|
"zod": "^4.3.5",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.8",
|
||||||
|
"vitest": "^1.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<polygon points="50,5 90,27.5 90,72.5 50,95 10,72.5 10,27.5" fill="#f59e0b" stroke="#d97706" stroke-width="3"/>
|
||||||
|
<polygon points="50,20 75,35 75,65 50,80 25,65 25,35" fill="#fbbf24"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 257 B |
@@ -0,0 +1,40 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AgentControlLayout } from './components/agent-control/AgentControlLayout';
|
||||||
|
import { DataPanel } from './components/agent-control/DataPanel';
|
||||||
|
import { AnalyticsPanel } from './components/agent-control/AnalyticsPanel';
|
||||||
|
import { CostControls } from './components/agent-control/CostControls';
|
||||||
|
import { WorkersPanel } from './components/agent-control/WorkersPanel';
|
||||||
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
|
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/:org/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/:org/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route path="/" element={<Navigate to="/agents" replace />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AgentControlLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="agents" element={<WorkersPanel />} />
|
||||||
|
<Route path="data" element={<DataPanel />} />
|
||||||
|
<Route path="analytics" element={<AnalyticsPanel />} />
|
||||||
|
<Route path="cost-control" element={<CostControls />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="40" height="40" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M9.63113 26.0105H16.1867C24.6745 26.0105 24.753 25.5286 24.753 18.5305L24.753 8.2883C24.753 3.6327 22.3321 0.18757 16.1867 0.18757L1.14916 0.18757L1.14916 1.2126C1.14916 3.2948 2.83715 4.9828 4.91939 4.9828L16.1867 4.9828C18.8869 4.9828 19.9577 6.426 19.9577 8.3814V10.1505L9.67768 10.1505C3.87039 10.1505 1.05012 11.0776 0.174184 15.6845C-0.0578263 16.9047 -0.0453967 18.1658 0.13663 19.3945C0.876011 24.3853 3.15632 26.0105 9.63113 26.0105ZM19.9577 14.0146V17.8632C19.9577 20.3928 19.8181 21.2618 16.8075 21.2618L9.03913 21.2618C5.53646 21.2618 4.46256 20.1546 4.4695 17.886C4.46982 17.7825 4.47015 17.6767 4.47015 17.5684C4.47015 17.4181 4.46915 17.2728 4.46818 17.1325C4.45343 14.9984 5.8521 14.0146 9.50698 14.0146L19.9577 14.0146Z"
|
||||||
|
fill="#263A99" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 918 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="120" height="36" viewBox="0 0 595 178" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_19225_30)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M530.365 42.7814H483.872C470.988 42.7814 462.655 54.1519 462.655 69.2512V177.893H470.25C480.274 177.893 488.407 169.744 488.407 159.678V76.1564C488.407 68.8522 489.376 67.6247 496.433 67.6247H530.365C557.332 67.6247 569.001 78.8264 569.001 99.0509V159.678C569.001 169.729 577.134 177.893 587.159 177.893H595V99.0509C595 63.4969 573.86 42.7814 530.365 42.7814ZM333.784 105.557V85.4093C333.784 72.1821 334.507 67.6247 350.22 67.6247H396.39C414.67 67.6247 414.655 73.425 414.624 85.2866C414.624 85.8236 414.624 86.376 414.624 86.9438C414.624 87.7264 414.624 88.4936 414.64 89.2302C414.716 100.401 414.747 105.542 395.683 105.542H333.784V105.557ZM395.021 42.7814H353.464C321.392 42.7814 308.754 57.5585 308.754 81.9107V135.495C308.754 159.847 321.392 177.877 353.464 177.877H431.951V172.522C431.951 161.627 423.142 152.788 412.272 152.788H353.464C339.365 152.788 333.784 145.239 333.784 135.004V125.751H394.775C431.229 125.751 438.27 122.099 438.27 87.0972C438.27 50.9141 430.26 42.7814 395.021 42.7814ZM233.587 152.804C254.236 152.804 259.017 143.551 259.017 121.623V67.64H206.451C184.342 67.64 179.238 77.7062 179.238 107.107C179.238 134.881 184.342 152.819 206.451 152.819H233.587V152.804ZM259.032 19.7334C259.032 8.83862 267.842 0 278.712 0H285.031V129.342C285.031 166.369 278.22 177.893 247.609 177.893H202.407C161.665 177.893 153.239 161.811 153.239 107.092C153.239 53.9985 164.909 42.7814 205.652 42.7814H259.032V19.7334Z"
|
||||||
|
fill="#263A99" />
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M104.163 115.24V135.372C104.163 148.599 103.44 153.157 87.7275 153.157H47.1845C28.904 153.157 23.2923 147.356 23.3384 135.495C23.3384 134.958 23.3384 134.405 23.3384 133.837C23.3384 133.055 23.3384 132.288 23.323 131.551C23.2461 120.38 30.5491 115.24 49.6291 115.24H104.163ZM50.2748 178H84.4835C128.778 178 129.193 175.483 129.193 138.871V85.2711C129.193 60.9188 116.555 42.8887 84.4835 42.8887H5.99576V48.244C5.99576 59.1388 14.8055 67.9775 25.6753 67.9775H84.4835C98.5821 67.9775 104.163 75.5271 104.163 85.7621V95.015H50.5054C20.202 95.015 5.47302 99.864 0.906743 123.971C-0.307856 130.354 -0.246358 136.952 0.706872 143.382C4.56592 169.499 16.4659 178 50.2748 178Z"
|
||||||
|
fill="#263A99" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_19225_30">
|
||||||
|
<rect width="595" height="178" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user