container lifecycle management
This commit is contained in:
9
gateway/.dockerignore
Normal file
9
gateway/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
39
gateway/.env.example
Normal file
39
gateway/.env.example
Normal file
@@ -0,0 +1,39 @@
|
||||
# Server configuration
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
LOG_LEVEL=info
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dexorder
|
||||
|
||||
# LLM Provider API Keys (configure at least one)
|
||||
# Anthropic Claude
|
||||
ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
|
||||
# OpenAI GPT
|
||||
OPENAI_API_KEY=sk-xxxxx
|
||||
|
||||
# Google Gemini
|
||||
GOOGLE_API_KEY=xxxxx
|
||||
|
||||
# OpenRouter (access to 300+ models with one key)
|
||||
OPENROUTER_API_KEY=sk-or-xxxxx
|
||||
|
||||
# Default model (if user has no preference)
|
||||
DEFAULT_MODEL_PROVIDER=anthropic
|
||||
DEFAULT_MODEL=claude-3-5-sonnet-20241022
|
||||
|
||||
# Telegram (optional)
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Kubernetes configuration
|
||||
KUBERNETES_NAMESPACE=dexorder-agents
|
||||
KUBERNETES_IN_CLUSTER=false
|
||||
KUBERNETES_CONTEXT=minikube
|
||||
AGENT_IMAGE=ghcr.io/dexorder/agent:latest
|
||||
SIDECAR_IMAGE=ghcr.io/dexorder/lifecycle-sidecar:latest
|
||||
AGENT_STORAGE_CLASS=standard
|
||||
|
||||
# Redis (for session management - future)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
6
gateway/.gitignore
vendored
Normal file
6
gateway/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
313
gateway/ARCHITECTURE.md
Normal file
313
gateway/ARCHITECTURE.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Gateway Architecture: LangChain.js + LangGraph
|
||||
|
||||
## Why LangChain.js (Not Vercel AI SDK or Direct Anthropic SDK)?
|
||||
|
||||
### The Decision
|
||||
|
||||
After evaluating Vercel AI SDK and LangChain.js, we chose **LangChain.js + LangGraph** for these reasons:
|
||||
|
||||
1. **Multi-model support**: 300+ models via OpenRouter, plus direct integrations
|
||||
2. **Complex workflows**: LangGraph for stateful trading analysis pipelines
|
||||
3. **No vendor lock-in**: Switch between Anthropic, OpenAI, Google with one line
|
||||
4. **Streaming**: Same as Vercel AI SDK (`.stream()` method)
|
||||
5. **Tool calling**: Unified across all providers
|
||||
6. **Trading-specific**: State management, conditional branching, human-in-the-loop
|
||||
|
||||
**We don't need Vercel AI SDK because:**
|
||||
- ❌ We use Vue (not React) - don't need React hooks
|
||||
- ❌ We have Node.js servers (not edge) - don't need edge runtime
|
||||
- ✅ **DO need** complex workflows (strategy analysis, backtesting, approvals)
|
||||
- ✅ **DO need** stateful execution (resume from failures)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
### Layer 1: Model Abstraction (`src/llm/`)
|
||||
|
||||
**Provider Factory** (`provider.ts`)
|
||||
```typescript
|
||||
const factory = new LLMProviderFactory(config, logger);
|
||||
|
||||
// Create any model
|
||||
const claude = factory.createModel({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
});
|
||||
|
||||
const gpt4 = factory.createModel({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
```
|
||||
|
||||
**Model Router** (`router.ts`)
|
||||
```typescript
|
||||
const router = new ModelRouter(factory, logger);
|
||||
|
||||
// Intelligently route based on:
|
||||
// - User license (free → Gemini Flash, pro → GPT-4, enterprise → Claude)
|
||||
// - Query complexity (simple → cheap, complex → smart)
|
||||
// - User preference (if set in license.preferredModel)
|
||||
// - Cost optimization (always use cheapest)
|
||||
|
||||
const model = await router.route(
|
||||
message.content,
|
||||
userLicense,
|
||||
RoutingStrategy.COMPLEXITY
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Layer 2: Agent Harness (`src/harness/`)
|
||||
|
||||
**Stateless Orchestrator**
|
||||
|
||||
The harness has **ZERO conversation state**. Everything lives in user's MCP container.
|
||||
|
||||
**Flow:**
|
||||
```typescript
|
||||
async handleMessage(message: InboundMessage) {
|
||||
// 1. Fetch context from user's MCP (resources, not tools)
|
||||
const resources = await mcpClient.listResources();
|
||||
const context = await Promise.all([
|
||||
mcpClient.readResource('context://user-profile'), // Trading style
|
||||
mcpClient.readResource('context://conversation-summary'), // RAG summary
|
||||
mcpClient.readResource('context://workspace-state'), // Current chart
|
||||
mcpClient.readResource('context://system-prompt'), // Custom instructions
|
||||
]);
|
||||
|
||||
// 2. Route to appropriate model
|
||||
const model = await modelRouter.route(message, license);
|
||||
|
||||
// 3. Build messages with embedded context
|
||||
const messages = buildLangChainMessages(systemPrompt, context);
|
||||
|
||||
// 4. Call LLM
|
||||
const response = await model.invoke(messages);
|
||||
|
||||
// 5. Save to user's MCP (tool call)
|
||||
await mcpClient.callTool('save_message', { role: 'user', content: message });
|
||||
await mcpClient.callTool('save_message', { role: 'assistant', content: response });
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**Streaming variant:**
|
||||
```typescript
|
||||
async *streamMessage(message: InboundMessage) {
|
||||
const model = await modelRouter.route(message, license);
|
||||
const messages = buildMessages(context, message);
|
||||
|
||||
const stream = await model.stream(messages);
|
||||
|
||||
let fullResponse = '';
|
||||
for await (const chunk of stream) {
|
||||
fullResponse += chunk.content;
|
||||
yield chunk.content; // Stream to WebSocket/Telegram
|
||||
}
|
||||
|
||||
// Save after streaming completes
|
||||
await mcpClient.callTool('save_message', { /* ... */ });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Layer 3: Workflows (`src/workflows/`)
|
||||
|
||||
**LangGraph for Complex Trading Analysis**
|
||||
|
||||
```typescript
|
||||
// Example: Strategy Analysis Pipeline
|
||||
const workflow = new StateGraph(StrategyAnalysisState)
|
||||
.addNode('code_review', async (state) => {
|
||||
const model = new ChatAnthropic({ model: 'claude-3-opus' });
|
||||
const review = await model.invoke(`Review: ${state.strategyCode}`);
|
||||
return { codeReview: review.content };
|
||||
})
|
||||
.addNode('backtest', async (state) => {
|
||||
// Call user's MCP backtest tool
|
||||
const results = await mcpClient.callTool('run_backtest', {
|
||||
strategy: state.strategyCode,
|
||||
ticker: state.ticker,
|
||||
});
|
||||
return { backtestResults: results };
|
||||
})
|
||||
.addNode('risk_assessment', async (state) => {
|
||||
const model = new ChatAnthropic({ model: 'claude-3-5-sonnet' });
|
||||
const assessment = await model.invoke(
|
||||
`Analyze risk: ${JSON.stringify(state.backtestResults)}`
|
||||
);
|
||||
return { riskAssessment: assessment.content };
|
||||
})
|
||||
.addNode('human_approval', async (state) => {
|
||||
// Pause for user review (human-in-the-loop)
|
||||
return { humanApproved: await waitForUserApproval(state) };
|
||||
})
|
||||
.addConditionalEdges('human_approval', (state) => {
|
||||
return state.humanApproved ? 'deploy' : 'reject';
|
||||
})
|
||||
.compile();
|
||||
|
||||
// Execute
|
||||
const result = await workflow.invoke({
|
||||
strategyCode: userCode,
|
||||
ticker: 'BTC/USDT',
|
||||
timeframe: '1h',
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Stateful**: Resume if server crashes mid-analysis
|
||||
- **Conditional**: Route based on results (if Sharpe > 2 → deploy, else → reject)
|
||||
- **Human-in-the-loop**: Pause for user approval
|
||||
- **Multi-step**: Each node can use different models
|
||||
|
||||
---
|
||||
|
||||
## User Context Architecture
|
||||
|
||||
### MCP Resources (Not Tools)
|
||||
|
||||
**User's MCP server exposes resources** (read-only context):
|
||||
|
||||
```
|
||||
context://user-profile → Trading style, preferences
|
||||
context://conversation-summary → RAG-generated summary
|
||||
context://workspace-state → Current chart, positions
|
||||
context://system-prompt → User's custom AI instructions
|
||||
```
|
||||
|
||||
**Gateway fetches and embeds in LLM call:**
|
||||
```typescript
|
||||
const userProfile = await mcpClient.readResource('context://user-profile');
|
||||
const conversationSummary = await mcpClient.readResource('context://conversation-summary');
|
||||
|
||||
// User's MCP server runs RAG search and returns summary
|
||||
// Gateway embeds this in Claude/GPT prompt
|
||||
```
|
||||
|
||||
**Why resources, not tools?**
|
||||
- Resources = context injection (read-only)
|
||||
- Tools = actions (write operations)
|
||||
- Context should be fetched **before** LLM call, not during
|
||||
|
||||
---
|
||||
|
||||
## Model Routing Strategies
|
||||
|
||||
### 1. User Preference
|
||||
```typescript
|
||||
// User's license has preferred model
|
||||
{
|
||||
"preferredModel": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3-5-sonnet-20241022"
|
||||
}
|
||||
}
|
||||
|
||||
// Router uses this if set
|
||||
```
|
||||
|
||||
### 2. Complexity-Based
|
||||
```typescript
|
||||
const isComplex = message.includes('backtest') || message.length > 200;
|
||||
|
||||
if (isComplex) {
|
||||
return { provider: 'anthropic', model: 'claude-3-opus' }; // Smart
|
||||
} else {
|
||||
return { provider: 'openai', model: 'gpt-4o-mini' }; // Fast
|
||||
}
|
||||
```
|
||||
|
||||
### 3. License Tier
|
||||
```typescript
|
||||
switch (license.licenseType) {
|
||||
case 'free':
|
||||
return { provider: 'google', model: 'gemini-2.0-flash-exp' }; // Cheap
|
||||
case 'pro':
|
||||
return { provider: 'openai', model: 'gpt-4o' }; // Balanced
|
||||
case 'enterprise':
|
||||
return { provider: 'anthropic', model: 'claude-3-5-sonnet' }; // Premium
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Cost-Optimized
|
||||
```typescript
|
||||
return { provider: 'google', model: 'gemini-2.0-flash-exp' }; // Always cheapest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use What
|
||||
|
||||
### Simple Chat → Agent Harness
|
||||
```typescript
|
||||
// User: "What's the RSI on BTC?"
|
||||
// → Fast streaming response via harness.streamMessage()
|
||||
```
|
||||
|
||||
### Complex Analysis → LangGraph Workflow
|
||||
```typescript
|
||||
// User: "Analyze this strategy and backtest it"
|
||||
// → Multi-step workflow: code review → backtest → risk → approval
|
||||
```
|
||||
|
||||
### Direct Tool Call → MCP Client
|
||||
```typescript
|
||||
// User: "Get my watchlist"
|
||||
// → Direct MCP tool call, no LLM needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User Message ("Analyze my strategy")
|
||||
↓
|
||||
Gateway → Route to workflow (not harness)
|
||||
↓
|
||||
LangGraph Workflow:
|
||||
├─ Node 1: Code Review (Claude Opus)
|
||||
│ └─ Analyzes strategy code
|
||||
├─ Node 2: Backtest (MCP tool call)
|
||||
│ └─ User's container runs backtest
|
||||
├─ Node 3: Risk Assessment (Claude Sonnet)
|
||||
│ └─ Evaluates results
|
||||
├─ Node 4: Human Approval (pause)
|
||||
│ └─ User reviews in UI
|
||||
└─ Node 5: Recommendation (GPT-4o-mini)
|
||||
└─ Final decision
|
||||
|
||||
Result → Return to user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits Summary
|
||||
|
||||
| Feature | LangChain.js | Vercel AI SDK | Direct Anthropic SDK |
|
||||
|---------|--------------|---------------|----------------------|
|
||||
| Multi-model | ✅ 300+ models | ✅ 100+ models | ❌ Anthropic only |
|
||||
| Streaming | ✅ `.stream()` | ✅ `streamText()` | ✅ `.stream()` |
|
||||
| Tool calling | ✅ Unified | ✅ Unified | ✅ Anthropic format |
|
||||
| Complex workflows | ✅ LangGraph | ❌ Limited | ❌ DIY |
|
||||
| Stateful agents | ✅ LangGraph | ❌ No | ❌ No |
|
||||
| Human-in-the-loop | ✅ LangGraph | ❌ No | ❌ No |
|
||||
| React hooks | ❌ N/A | ✅ `useChat()` | ❌ N/A |
|
||||
| Bundle size | Large (101kb) | Small (30kb) | Medium (60kb) |
|
||||
| **Dexorder needs** | **✅ Perfect fit** | **❌ Missing workflows** | **❌ Vendor lock-in** |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Implement tool calling** in agent harness (bind MCP tools to LangChain)
|
||||
2. **Add state persistence** for LangGraph (PostgreSQL checkpointer)
|
||||
3. **Build more workflows**: market scanner, portfolio optimizer
|
||||
4. **Add monitoring**: Track model usage, costs, latency
|
||||
5. **User container**: Implement Python MCP server with resources
|
||||
40
gateway/Dockerfile
Normal file
40
gateway/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY src ./src
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
212
gateway/README.md
Normal file
212
gateway/README.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Dexorder Gateway
|
||||
|
||||
Multi-channel gateway with agent harness for the Dexorder AI platform.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Platform Gateway │
|
||||
│ (Node.js/Fastify) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Channels │ │
|
||||
│ │ - WebSocket (/ws/chat) │ │
|
||||
│ │ - Telegram Webhook (/webhook/telegram) │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ ↕ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Authenticator │ │
|
||||
│ │ - JWT verification (WebSocket) │ │
|
||||
│ │ - Channel linking (Telegram) │ │
|
||||
│ │ - User license lookup (PostgreSQL) │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ ↕ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Agent Harness (per-session) │ │
|
||||
│ │ - Claude API integration │ │
|
||||
│ │ - MCP client connector │ │
|
||||
│ │ - Conversation state │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ ↕ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ MCP Client │ │
|
||||
│ │ - User container connection │ │
|
||||
│ │ - Tool routing │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌───────────────────────────────┐
|
||||
│ User MCP Server (Python) │
|
||||
│ - Strategies, indicators │
|
||||
│ - Memory, preferences │
|
||||
│ - Backtest sandbox │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic container provisioning**: Creates user agent containers on-demand via Kubernetes
|
||||
- **Multi-channel support**: WebSocket and Telegram webhooks
|
||||
- **Per-channel authentication**: JWT for web, channel linking for chat apps
|
||||
- **User license management**: Feature flags and resource limits from PostgreSQL
|
||||
- **Container lifecycle management**: Auto-shutdown on idle (handled by container sidecar)
|
||||
- **License-based resources**: Different memory/CPU/storage limits per tier
|
||||
- **Multi-model LLM support**: Anthropic Claude, OpenAI GPT, Google Gemini, OpenRouter (300+ models)
|
||||
- **Zero vendor lock-in**: Switch models with one line, powered by LangChain.js
|
||||
- **Intelligent routing**: Auto-select models based on complexity, license tier, or user preference
|
||||
- **Streaming responses**: Real-time chat with WebSocket and Telegram
|
||||
- **Complex workflows**: LangGraph for stateful trading analysis (backtest → risk → approval)
|
||||
- **Agent harness**: Stateless orchestrator (all context lives in user's MCP container)
|
||||
- **MCP resource integration**: User's RAG, conversation history, and preferences
|
||||
|
||||
## Container Management
|
||||
|
||||
When a user authenticates, the gateway:
|
||||
|
||||
1. **Checks for existing container**: Queries Kubernetes for deployment
|
||||
2. **Creates if missing**: Renders YAML template based on license tier
|
||||
3. **Waits for ready**: Polls deployment status until healthy
|
||||
4. **Returns MCP endpoint**: Computed from service name
|
||||
5. **Connects to MCP server**: Proceeds with normal authentication flow
|
||||
|
||||
Container templates by license tier:
|
||||
|
||||
| Tier | Memory | CPU | Storage | Idle Timeout |
|
||||
|------|--------|-----|---------|--------------|
|
||||
| Free | 512Mi | 500m | 1Gi | 15min |
|
||||
| Pro | 2Gi | 2000m | 10Gi | 60min |
|
||||
| Enterprise | 4Gi | 4000m | 50Gi | Never |
|
||||
|
||||
Containers self-manage their lifecycle using the lifecycle sidecar (see `../lifecycle-sidecar/`)
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 22.0.0
|
||||
- PostgreSQL database
|
||||
- At least one LLM provider API key:
|
||||
- Anthropic Claude
|
||||
- OpenAI GPT
|
||||
- Google Gemini
|
||||
- OpenRouter (one key for 300+ models)
|
||||
|
||||
### Development
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Copy environment template:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Configure `.env` (see `.env.example`):
|
||||
```bash
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dexorder
|
||||
|
||||
# Configure at least one provider
|
||||
ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
# OPENAI_API_KEY=sk-xxxxx
|
||||
# GOOGLE_API_KEY=xxxxx
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxx
|
||||
|
||||
# Optional: Set default model
|
||||
DEFAULT_MODEL_PROVIDER=anthropic
|
||||
DEFAULT_MODEL=claude-3-5-sonnet-20241022
|
||||
```
|
||||
|
||||
4. Run development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t dexorder/gateway:latest .
|
||||
docker run -p 3000:3000 --env-file .env dexorder/gateway:latest
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
Required PostgreSQL tables (will be documented separately):
|
||||
|
||||
### `user_licenses`
|
||||
- `user_id` (text, primary key)
|
||||
- `email` (text)
|
||||
- `license_type` (text: 'free', 'pro', 'enterprise')
|
||||
- `features` (jsonb)
|
||||
- `resource_limits` (jsonb)
|
||||
- `mcp_server_url` (text)
|
||||
- `expires_at` (timestamp, nullable)
|
||||
- `created_at` (timestamp)
|
||||
- `updated_at` (timestamp)
|
||||
|
||||
### `user_channel_links`
|
||||
- `id` (serial, primary key)
|
||||
- `user_id` (text, foreign key)
|
||||
- `channel_type` (text: 'telegram', 'slack', 'discord')
|
||||
- `channel_user_id` (text)
|
||||
- `created_at` (timestamp)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### WebSocket
|
||||
|
||||
**`GET /ws/chat`**
|
||||
- WebSocket connection for web client
|
||||
- Auth: Bearer token in headers
|
||||
- Protocol: JSON messages
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:3000/ws/chat', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer your-jwt-token'
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data);
|
||||
console.log(msg);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
content: 'Hello, AI!'
|
||||
}));
|
||||
```
|
||||
|
||||
### Telegram Webhook
|
||||
|
||||
**`POST /webhook/telegram`**
|
||||
- Telegram bot webhook endpoint
|
||||
- Auth: Telegram user linked to platform user
|
||||
- Automatically processes incoming messages
|
||||
|
||||
### Health Check
|
||||
|
||||
**`GET /health`**
|
||||
- Returns server health status
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Implement JWT verification with JWKS
|
||||
- [ ] Implement MCP HTTP/SSE transport
|
||||
- [ ] Add Redis for session persistence
|
||||
- [ ] Add rate limiting per user license
|
||||
- [ ] Add message usage tracking
|
||||
- [ ] Add streaming responses for WebSocket
|
||||
- [ ] Add Slack and Discord channel handlers
|
||||
- [ ] Add session cleanup/timeout logic
|
||||
42
gateway/package.json
Normal file
42
gateway/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@dexorder/gateway",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Multi-channel gateway with agent harness for Dexorder AI platform",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/websocket": "^11.0.1",
|
||||
"@kubernetes/client-node": "^0.21.0",
|
||||
"@langchain/anthropic": "^0.3.8",
|
||||
"@langchain/core": "^0.3.24",
|
||||
"@langchain/google-genai": "^0.1.6",
|
||||
"@langchain/langgraph": "^0.2.26",
|
||||
"@langchain/openai": "^0.3.21",
|
||||
"@langchain/openrouter": "^0.1.2",
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"fastify": "^5.2.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"pg": "^8.13.1",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
79
gateway/schema.sql
Normal file
79
gateway/schema.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- User license and authorization schema
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_licenses (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
license_type TEXT NOT NULL CHECK (license_type IN ('free', 'pro', 'enterprise')),
|
||||
features JSONB NOT NULL DEFAULT '{
|
||||
"maxIndicators": 5,
|
||||
"maxStrategies": 3,
|
||||
"maxBacktestDays": 30,
|
||||
"realtimeData": false,
|
||||
"customExecutors": false,
|
||||
"apiAccess": false
|
||||
}',
|
||||
resource_limits JSONB NOT NULL DEFAULT '{
|
||||
"maxConcurrentSessions": 1,
|
||||
"maxMessagesPerDay": 100,
|
||||
"maxTokensPerMessage": 4096,
|
||||
"rateLimitPerMinute": 10
|
||||
}',
|
||||
mcp_server_url TEXT NOT NULL,
|
||||
preferred_model JSONB DEFAULT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN user_licenses.preferred_model IS 'Optional model preference: {"provider": "anthropic", "model": "claude-3-5-sonnet-20241022", "temperature": 0.7}';
|
||||
|
||||
CREATE INDEX idx_user_licenses_expires_at ON user_licenses(expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
|
||||
-- Channel linking for multi-channel support
|
||||
CREATE TABLE IF NOT EXISTS user_channel_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES user_licenses(user_id) ON DELETE CASCADE,
|
||||
channel_type TEXT NOT NULL CHECK (channel_type IN ('telegram', 'slack', 'discord', 'websocket')),
|
||||
channel_user_id TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(channel_type, channel_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_channel_links_user_id ON user_channel_links(user_id);
|
||||
CREATE INDEX idx_user_channel_links_channel ON user_channel_links(channel_type, channel_user_id);
|
||||
|
||||
-- Example data for development
|
||||
INSERT INTO user_licenses (user_id, email, license_type, mcp_server_url, features, resource_limits, preferred_model)
|
||||
VALUES (
|
||||
'dev-user-001',
|
||||
'dev@example.com',
|
||||
'pro',
|
||||
'http://localhost:8080/mcp',
|
||||
'{
|
||||
"maxIndicators": 50,
|
||||
"maxStrategies": 20,
|
||||
"maxBacktestDays": 365,
|
||||
"realtimeData": true,
|
||||
"customExecutors": true,
|
||||
"apiAccess": true
|
||||
}',
|
||||
'{
|
||||
"maxConcurrentSessions": 5,
|
||||
"maxMessagesPerDay": 1000,
|
||||
"maxTokensPerMessage": 8192,
|
||||
"rateLimitPerMinute": 60
|
||||
}',
|
||||
'{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"temperature": 0.7
|
||||
}'
|
||||
)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- Example Telegram link
|
||||
INSERT INTO user_channel_links (user_id, channel_type, channel_user_id)
|
||||
VALUES ('dev-user-001', 'telegram', '123456789')
|
||||
ON CONFLICT (channel_type, channel_user_id) DO NOTHING;
|
||||
146
gateway/src/auth/authenticator.ts
Normal file
146
gateway/src/auth/authenticator.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { FastifyRequest, FastifyBaseLogger } from 'fastify';
|
||||
import { UserService } from '../db/user-service.js';
|
||||
import { ChannelType, type AuthContext } from '../types/user.js';
|
||||
import type { ContainerManager } from '../k8s/container-manager.js';
|
||||
|
||||
export interface AuthenticatorConfig {
|
||||
userService: UserService;
|
||||
containerManager: ContainerManager;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-channel authenticator
|
||||
* Handles authentication for WebSocket, Telegram, and other channels
|
||||
*/
|
||||
export class Authenticator {
|
||||
private config: AuthenticatorConfig;
|
||||
|
||||
constructor(config: AuthenticatorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket connection via JWT token
|
||||
* Also ensures the user's container is running
|
||||
*/
|
||||
async authenticateWebSocket(
|
||||
request: FastifyRequest
|
||||
): Promise<AuthContext | null> {
|
||||
try {
|
||||
const token = this.extractBearerToken(request);
|
||||
if (!token) {
|
||||
this.config.logger.warn('No bearer token in WebSocket connection');
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = await this.config.userService.verifyWebToken(token);
|
||||
if (!userId) {
|
||||
this.config.logger.warn('Invalid JWT token');
|
||||
return null;
|
||||
}
|
||||
|
||||
const license = await this.config.userService.getUserLicense(userId);
|
||||
if (!license) {
|
||||
this.config.logger.warn({ userId }, 'User license not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure container is running (may take time if creating new container)
|
||||
this.config.logger.info({ userId }, 'Ensuring user container is running');
|
||||
const { mcpEndpoint, wasCreated } = await this.config.containerManager.ensureContainerRunning(
|
||||
userId,
|
||||
license
|
||||
);
|
||||
|
||||
this.config.logger.info(
|
||||
{ userId, mcpEndpoint, wasCreated },
|
||||
'Container is ready'
|
||||
);
|
||||
|
||||
// Update license with actual MCP endpoint
|
||||
license.mcpServerUrl = mcpEndpoint;
|
||||
|
||||
const sessionId = `ws_${userId}_${Date.now()}`;
|
||||
|
||||
return {
|
||||
userId,
|
||||
channelType: ChannelType.WEBSOCKET,
|
||||
channelUserId: userId, // For WebSocket, same as userId
|
||||
sessionId,
|
||||
license,
|
||||
authenticatedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'WebSocket authentication error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate Telegram webhook
|
||||
* Also ensures the user's container is running
|
||||
*/
|
||||
async authenticateTelegram(telegramUserId: string): Promise<AuthContext | null> {
|
||||
try {
|
||||
const userId = await this.config.userService.getUserIdFromChannel(
|
||||
'telegram',
|
||||
telegramUserId
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
this.config.logger.warn(
|
||||
{ telegramUserId },
|
||||
'Telegram user not linked to platform user'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const license = await this.config.userService.getUserLicense(userId);
|
||||
if (!license) {
|
||||
this.config.logger.warn({ userId }, 'User license not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure container is running
|
||||
this.config.logger.info({ userId }, 'Ensuring user container is running');
|
||||
const { mcpEndpoint, wasCreated } = await this.config.containerManager.ensureContainerRunning(
|
||||
userId,
|
||||
license
|
||||
);
|
||||
|
||||
this.config.logger.info(
|
||||
{ userId, mcpEndpoint, wasCreated },
|
||||
'Container is ready'
|
||||
);
|
||||
|
||||
// Update license with actual MCP endpoint
|
||||
license.mcpServerUrl = mcpEndpoint;
|
||||
|
||||
const sessionId = `tg_${telegramUserId}_${Date.now()}`;
|
||||
|
||||
return {
|
||||
userId,
|
||||
channelType: ChannelType.TELEGRAM,
|
||||
channelUserId: telegramUserId,
|
||||
sessionId,
|
||||
license,
|
||||
authenticatedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Telegram authentication error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bearer token from request headers
|
||||
*/
|
||||
private extractBearerToken(request: FastifyRequest): string | null {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return auth.substring(7);
|
||||
}
|
||||
}
|
||||
163
gateway/src/channels/telegram-handler.ts
Normal file
163
gateway/src/channels/telegram-handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { Authenticator } from '../auth/authenticator.js';
|
||||
import { AgentHarness } from '../harness/agent-harness.js';
|
||||
import type { InboundMessage } from '../types/messages.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { ProviderConfig } from '../llm/provider.js';
|
||||
|
||||
export interface TelegramHandlerConfig {
|
||||
authenticator: Authenticator;
|
||||
providerConfig: ProviderConfig;
|
||||
telegramBotToken: string;
|
||||
}
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: {
|
||||
message_id: number;
|
||||
from: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
username?: string;
|
||||
};
|
||||
chat: {
|
||||
id: number;
|
||||
type: string;
|
||||
};
|
||||
text?: string;
|
||||
photo?: Array<{
|
||||
file_id: string;
|
||||
file_size: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram webhook handler
|
||||
*/
|
||||
export class TelegramHandler {
|
||||
private config: TelegramHandlerConfig;
|
||||
private sessions = new Map<string, AgentHarness>();
|
||||
|
||||
constructor(config: TelegramHandlerConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Telegram webhook routes
|
||||
*/
|
||||
register(app: FastifyInstance): void {
|
||||
app.post('/webhook/telegram', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await this.handleWebhook(request, reply, app);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Telegram webhook
|
||||
*/
|
||||
private async handleWebhook(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
app: FastifyInstance
|
||||
): Promise<void> {
|
||||
const logger = app.log;
|
||||
|
||||
try {
|
||||
const update = request.body as TelegramUpdate;
|
||||
|
||||
if (!update.message?.text) {
|
||||
// Ignore non-text messages for now
|
||||
reply.code(200).send({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const telegramUserId = update.message.from.id.toString();
|
||||
const chatId = update.message.chat.id;
|
||||
const text = update.message.text;
|
||||
|
||||
logger.info({ telegramUserId, chatId, text }, 'Received Telegram message');
|
||||
|
||||
// Authenticate
|
||||
const authContext = await this.config.authenticator.authenticateTelegram(telegramUserId);
|
||||
if (!authContext) {
|
||||
logger.warn({ telegramUserId }, 'Telegram user not authenticated');
|
||||
await this.sendTelegramMessage(
|
||||
chatId,
|
||||
'Please link your Telegram account to Dexorder first.'
|
||||
);
|
||||
reply.code(200).send({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create harness
|
||||
let harness = this.sessions.get(authContext.sessionId);
|
||||
if (!harness) {
|
||||
harness = new AgentHarness({
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
license: authContext.license,
|
||||
providerConfig: this.config.providerConfig,
|
||||
logger,
|
||||
});
|
||||
await harness.initialize();
|
||||
this.sessions.set(authContext.sessionId, harness);
|
||||
}
|
||||
|
||||
// Process message
|
||||
const inboundMessage: InboundMessage = {
|
||||
messageId: randomUUID(),
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const response = await harness.handleMessage(inboundMessage);
|
||||
|
||||
// Send response back to Telegram
|
||||
await this.sendTelegramMessage(chatId, response.content);
|
||||
|
||||
reply.code(200).send({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error handling Telegram webhook');
|
||||
reply.code(500).send({ ok: false, error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to Telegram chat
|
||||
*/
|
||||
private async sendTelegramMessage(chatId: number, text: string): Promise<void> {
|
||||
const url = `https://api.telegram.org/bot${this.config.telegramBotToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: 'Markdown',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Telegram API error: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send Telegram message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old sessions (call periodically)
|
||||
*/
|
||||
async cleanupSessions(maxAgeMs = 30 * 60 * 1000): Promise<void> {
|
||||
// TODO: Track session last activity and cleanup
|
||||
// For now, sessions persist until server restart
|
||||
}
|
||||
}
|
||||
161
gateway/src/channels/websocket-handler.ts
Normal file
161
gateway/src/channels/websocket-handler.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import type { Authenticator } from '../auth/authenticator.js';
|
||||
import { AgentHarness } from '../harness/agent-harness.js';
|
||||
import type { InboundMessage } from '../types/messages.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { ProviderConfig } from '../llm/provider.js';
|
||||
|
||||
export interface WebSocketHandlerConfig {
|
||||
authenticator: Authenticator;
|
||||
providerConfig: ProviderConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket channel handler
|
||||
*/
|
||||
export class WebSocketHandler {
|
||||
private config: WebSocketHandlerConfig;
|
||||
private sessions = new Map<string, AgentHarness>();
|
||||
|
||||
constructor(config: WebSocketHandlerConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WebSocket routes
|
||||
*/
|
||||
register(app: FastifyInstance): void {
|
||||
app.get(
|
||||
'/ws/chat',
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, request: FastifyRequest) => {
|
||||
await this.handleConnection(socket, request, app);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket connection
|
||||
*/
|
||||
private async handleConnection(
|
||||
socket: WebSocket,
|
||||
request: FastifyRequest,
|
||||
app: FastifyInstance
|
||||
): Promise<void> {
|
||||
const logger = app.log;
|
||||
|
||||
// Send initial connecting message
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
status: 'authenticating',
|
||||
message: 'Authenticating...',
|
||||
})
|
||||
);
|
||||
|
||||
// Authenticate (this may take time if creating container)
|
||||
const authContext = await this.config.authenticator.authenticateWebSocket(request);
|
||||
if (!authContext) {
|
||||
logger.warn('WebSocket authentication failed');
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Authentication failed',
|
||||
})
|
||||
);
|
||||
socket.close(1008, 'Authentication failed');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: authContext.userId, sessionId: authContext.sessionId },
|
||||
'WebSocket connection authenticated'
|
||||
);
|
||||
|
||||
// Send workspace starting message
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
status: 'initializing',
|
||||
message: 'Starting your workspace...',
|
||||
})
|
||||
);
|
||||
|
||||
// Create agent harness
|
||||
const harness = new AgentHarness({
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
license: authContext.license,
|
||||
providerConfig: this.config.providerConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
await harness.initialize();
|
||||
this.sessions.set(authContext.sessionId, harness);
|
||||
|
||||
// Send connected message
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
sessionId: authContext.sessionId,
|
||||
userId: authContext.userId,
|
||||
licenseType: authContext.license.licenseType,
|
||||
message: 'Connected to Dexorder AI',
|
||||
})
|
||||
);
|
||||
|
||||
// Handle messages
|
||||
socket.on('message', async (data: Buffer) => {
|
||||
try {
|
||||
const payload = JSON.parse(data.toString());
|
||||
|
||||
if (payload.type === 'message') {
|
||||
const inboundMessage: InboundMessage = {
|
||||
messageId: randomUUID(),
|
||||
userId: authContext.userId,
|
||||
sessionId: authContext.sessionId,
|
||||
content: payload.content,
|
||||
attachments: payload.attachments,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const response = await harness.handleMessage(inboundMessage);
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
...response,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error handling WebSocket message');
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Failed to process message',
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('close', async () => {
|
||||
logger.info({ sessionId: authContext.sessionId }, 'WebSocket disconnected');
|
||||
await harness.cleanup();
|
||||
this.sessions.delete(authContext.sessionId);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.error({ error, sessionId: authContext.sessionId }, 'WebSocket error');
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize agent harness');
|
||||
socket.close(1011, 'Internal server error');
|
||||
await harness.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
107
gateway/src/db/user-service.ts
Normal file
107
gateway/src/db/user-service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import type { UserLicense } from '../types/user.js';
|
||||
import { UserLicenseSchema } from '../types/user.js';
|
||||
|
||||
export class UserService {
|
||||
private pool: Pool;
|
||||
|
||||
constructor(connectionString: string) {
|
||||
this.pool = new Pool({
|
||||
connectionString,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user license by user ID
|
||||
*/
|
||||
async getUserLicense(userId: string): Promise<UserLicense | null> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT
|
||||
user_id as "userId",
|
||||
email,
|
||||
license_type as "licenseType",
|
||||
features,
|
||||
resource_limits as "resourceLimits",
|
||||
mcp_server_url as "mcpServerUrl",
|
||||
preferred_model as "preferredModel",
|
||||
expires_at as "expiresAt",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM user_licenses
|
||||
WHERE user_id = $1
|
||||
AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
// Parse and validate
|
||||
return UserLicenseSchema.parse({
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
licenseType: row.licenseType,
|
||||
features: row.features,
|
||||
resourceLimits: row.resourceLimits,
|
||||
mcpServerUrl: row.mcpServerUrl,
|
||||
preferredModel: row.preferredModel,
|
||||
expiresAt: row.expiresAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID from channel-specific identifier
|
||||
*/
|
||||
async getUserIdFromChannel(channelType: string, channelUserId: string): Promise<string | null> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT user_id
|
||||
FROM user_channel_links
|
||||
WHERE channel_type = $1 AND channel_user_id = $2`,
|
||||
[channelType, channelUserId]
|
||||
);
|
||||
|
||||
return result.rows.length > 0 ? result.rows[0].user_id : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token from web client
|
||||
* TODO: Implement JWT verification with JWKS
|
||||
*/
|
||||
async verifyWebToken(token: string): Promise<string | null> {
|
||||
// Placeholder - implement JWT verification
|
||||
// For now, decode without verification (INSECURE - FOR DEV ONLY)
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split('.')[1], 'base64').toString()
|
||||
);
|
||||
return payload.sub || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database pool
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
306
gateway/src/harness/agent-harness.ts
Normal file
306
gateway/src/harness/agent-harness.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { UserLicense } from '../types/user.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../types/messages.js';
|
||||
import { MCPClientConnector } from './mcp-client.js';
|
||||
import { CONTEXT_URIS, type ResourceContent } from '../types/resources.js';
|
||||
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
|
||||
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
|
||||
|
||||
export interface AgentHarnessConfig {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
license: UserLicense;
|
||||
providerConfig: ProviderConfig;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent harness orchestrates between LLM and user's MCP server.
|
||||
*
|
||||
* This is a STATELESS orchestrator - all conversation history, RAG, and context
|
||||
* lives in the user's MCP server container. The harness only:
|
||||
* 1. Fetches context from user's MCP resources
|
||||
* 2. Routes to appropriate LLM model
|
||||
* 3. Calls LLM with embedded context
|
||||
* 4. Routes tool calls to user's MCP or platform tools
|
||||
* 5. Saves messages back to user's MCP
|
||||
*/
|
||||
export class AgentHarness {
|
||||
private config: AgentHarnessConfig;
|
||||
private modelFactory: LLMProviderFactory;
|
||||
private modelRouter: ModelRouter;
|
||||
private mcpClient: MCPClientConnector;
|
||||
|
||||
constructor(config: AgentHarnessConfig) {
|
||||
this.config = config;
|
||||
|
||||
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
|
||||
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
|
||||
|
||||
this.mcpClient = new MCPClientConnector({
|
||||
userId: config.userId,
|
||||
mcpServerUrl: config.license.mcpServerUrl,
|
||||
logger: config.logger,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize harness and connect to user's MCP server
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
this.config.logger.info(
|
||||
{ userId: this.config.userId, sessionId: this.config.sessionId },
|
||||
'Initializing agent harness'
|
||||
);
|
||||
|
||||
try {
|
||||
await this.mcpClient.connect();
|
||||
this.config.logger.info('Agent harness initialized');
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Failed to initialize agent harness');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from user
|
||||
*/
|
||||
async handleMessage(message: InboundMessage): Promise<OutboundMessage> {
|
||||
this.config.logger.info(
|
||||
{ messageId: message.messageId, userId: message.userId },
|
||||
'Processing user message'
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. Fetch context resources from user's MCP server
|
||||
this.config.logger.debug('Fetching context resources from MCP');
|
||||
const contextResources = await this.fetchContextResources();
|
||||
|
||||
// 2. Build system prompt from resources
|
||||
const systemPrompt = this.buildSystemPrompt(contextResources);
|
||||
|
||||
// 3. Build messages with conversation context from MCP
|
||||
const messages = this.buildMessages(message, contextResources);
|
||||
|
||||
// 4. Route to appropriate model
|
||||
const model = await this.modelRouter.route(
|
||||
message.content,
|
||||
this.config.license,
|
||||
RoutingStrategy.COMPLEXITY
|
||||
);
|
||||
|
||||
// 5. Build LangChain messages
|
||||
const langchainMessages = this.buildLangChainMessages(systemPrompt, messages);
|
||||
|
||||
// 6. Call LLM with streaming
|
||||
this.config.logger.debug('Invoking LLM');
|
||||
const response = await model.invoke(langchainMessages);
|
||||
|
||||
// 7. Extract text response (tool handling TODO)
|
||||
const assistantMessage = response.content as string;
|
||||
|
||||
// 8. Save messages to user's MCP server
|
||||
this.config.logger.debug('Saving messages to MCP');
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'user',
|
||||
content: message.content,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
});
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'assistant',
|
||||
content: assistantMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: `msg_${Date.now()}`,
|
||||
sessionId: message.sessionId,
|
||||
content: assistantMessage,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Error processing message');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream response from LLM
|
||||
*/
|
||||
async *streamMessage(message: InboundMessage): AsyncGenerator<string> {
|
||||
try {
|
||||
// Fetch context
|
||||
const contextResources = await this.fetchContextResources();
|
||||
const systemPrompt = this.buildSystemPrompt(contextResources);
|
||||
const messages = this.buildMessages(message, contextResources);
|
||||
|
||||
// Route to model
|
||||
const model = await this.modelRouter.route(
|
||||
message.content,
|
||||
this.config.license,
|
||||
RoutingStrategy.COMPLEXITY
|
||||
);
|
||||
|
||||
// Build messages
|
||||
const langchainMessages = this.buildLangChainMessages(systemPrompt, messages);
|
||||
|
||||
// Stream response
|
||||
const stream = await model.stream(langchainMessages);
|
||||
|
||||
let fullResponse = '';
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.content as string;
|
||||
fullResponse += content;
|
||||
yield content;
|
||||
}
|
||||
|
||||
// Save after streaming completes
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'user',
|
||||
content: message.content,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
});
|
||||
await this.mcpClient.callTool('save_message', {
|
||||
role: 'assistant',
|
||||
content: fullResponse,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Error streaming message');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch context resources from user's MCP server
|
||||
*/
|
||||
private async fetchContextResources(): Promise<ResourceContent[]> {
|
||||
const contextUris = [
|
||||
CONTEXT_URIS.USER_PROFILE,
|
||||
CONTEXT_URIS.CONVERSATION_SUMMARY,
|
||||
CONTEXT_URIS.WORKSPACE_STATE,
|
||||
CONTEXT_URIS.SYSTEM_PROMPT,
|
||||
];
|
||||
|
||||
const resources = await Promise.all(
|
||||
contextUris.map(async (uri) => {
|
||||
try {
|
||||
return await this.mcpClient.readResource(uri);
|
||||
} catch (error) {
|
||||
this.config.logger.warn({ error, uri }, 'Failed to fetch resource, using empty');
|
||||
return { uri, text: '' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build messages array with context from resources
|
||||
*/
|
||||
private buildMessages(
|
||||
currentMessage: InboundMessage,
|
||||
contextResources: ResourceContent[]
|
||||
): Array<{ role: string; content: string }> {
|
||||
const conversationSummary = contextResources.find(
|
||||
(r) => r.uri === CONTEXT_URIS.CONVERSATION_SUMMARY
|
||||
);
|
||||
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
// Add conversation context as a system-like user message
|
||||
if (conversationSummary?.text) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `[Previous Conversation Context]\n${conversationSummary.text}`,
|
||||
});
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: 'I understand the context from our previous conversations.',
|
||||
});
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: currentMessage.content,
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to LangChain message format
|
||||
*/
|
||||
private buildLangChainMessages(
|
||||
systemPrompt: string,
|
||||
messages: Array<{ role: string; content: string }>
|
||||
): BaseMessage[] {
|
||||
const langchainMessages: BaseMessage[] = [new SystemMessage(systemPrompt)];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'user') {
|
||||
langchainMessages.push(new HumanMessage(msg.content));
|
||||
} else if (msg.role === 'assistant') {
|
||||
langchainMessages.push(new AIMessage(msg.content));
|
||||
}
|
||||
}
|
||||
|
||||
return langchainMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt from platform base + user resources
|
||||
*/
|
||||
private buildSystemPrompt(contextResources: ResourceContent[]): string {
|
||||
const userProfile = contextResources.find((r) => r.uri === CONTEXT_URIS.USER_PROFILE);
|
||||
const customPrompt = contextResources.find((r) => r.uri === CONTEXT_URIS.SYSTEM_PROMPT);
|
||||
const workspaceState = contextResources.find((r) => r.uri === CONTEXT_URIS.WORKSPACE_STATE);
|
||||
|
||||
// Base platform prompt
|
||||
let prompt = `You are a helpful AI assistant for Dexorder, an AI-first trading platform.
|
||||
You help users research markets, develop indicators and strategies, and analyze trading data.
|
||||
|
||||
User license: ${this.config.license.licenseType}
|
||||
Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
|
||||
|
||||
// Add user profile context
|
||||
if (userProfile?.text) {
|
||||
prompt += `\n\n# User Profile\n${userProfile.text}`;
|
||||
}
|
||||
|
||||
// Add workspace context
|
||||
if (workspaceState?.text) {
|
||||
prompt += `\n\n# Current Workspace\n${workspaceState.text}`;
|
||||
}
|
||||
|
||||
// Add user's custom instructions (highest priority)
|
||||
if (customPrompt?.text) {
|
||||
prompt += `\n\n# User Instructions\n${customPrompt.text}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform tools (non-user-specific tools)
|
||||
*/
|
||||
private getPlatformTools(): Array<{ name: string; description?: string }> {
|
||||
// Platform tools that don't need user's MCP
|
||||
return [
|
||||
// TODO: Add platform tools like market data queries, chart rendering, etc.
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
this.config.logger.info('Cleaning up agent harness');
|
||||
await this.mcpClient.disconnect();
|
||||
}
|
||||
}
|
||||
259
gateway/src/harness/mcp-client.ts
Normal file
259
gateway/src/harness/mcp-client.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
export interface MCPClientConfig {
|
||||
userId: string;
|
||||
mcpServerUrl: string;
|
||||
platformJWT?: string;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP client connector for user's container
|
||||
* Manages connection to user-specific MCP server
|
||||
*/
|
||||
export class MCPClientConnector {
|
||||
private client: Client | null = null;
|
||||
private connected = false;
|
||||
private config: MCPClientConfig;
|
||||
|
||||
constructor(config: MCPClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to user's MCP server
|
||||
* TODO: Implement HTTP/SSE transport instead of stdio for container communication
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.config.logger.info(
|
||||
{ userId: this.config.userId, url: this.config.mcpServerUrl },
|
||||
'Connecting to user MCP server'
|
||||
);
|
||||
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'dexorder-gateway',
|
||||
version: '0.1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Replace with HTTP transport when user containers are ready
|
||||
// For now, this is a placeholder structure
|
||||
// const transport = new HTTPTransport(this.config.mcpServerUrl, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.config.platformJWT}`
|
||||
// }
|
||||
// });
|
||||
|
||||
// Placeholder: will be replaced with actual container transport
|
||||
this.config.logger.warn(
|
||||
'MCP transport not yet implemented - using placeholder'
|
||||
);
|
||||
|
||||
this.connected = true;
|
||||
this.config.logger.info('Connected to user MCP server');
|
||||
} catch (error) {
|
||||
this.config.logger.error(
|
||||
{ error, userId: this.config.userId },
|
||||
'Failed to connect to user MCP server'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on the user's MCP server
|
||||
*/
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
if (!this.client || !this.connected) {
|
||||
throw new Error('MCP client not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');
|
||||
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const result = await this.client.callTool({ name, arguments: args });
|
||||
// return result;
|
||||
|
||||
// Placeholder response
|
||||
return { success: true, message: 'MCP tool call placeholder' };
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools from user's MCP server
|
||||
*/
|
||||
async listTools(): Promise<Array<{ name: string; description?: string }>> {
|
||||
if (!this.client || !this.connected) {
|
||||
throw new Error('MCP client not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const tools = await this.client.listTools();
|
||||
// return tools;
|
||||
|
||||
// Placeholder tools (actions only, not context)
|
||||
return [
|
||||
{ name: 'save_message', description: 'Save message to conversation history' },
|
||||
{ name: 'list_strategies', description: 'List user strategies' },
|
||||
{ name: 'read_strategy', description: 'Read strategy code' },
|
||||
{ name: 'write_strategy', description: 'Write strategy code' },
|
||||
{ name: 'run_backtest', description: 'Run backtest on strategy' },
|
||||
{ name: 'get_watchlist', description: 'Get user watchlist' },
|
||||
{ name: 'execute_trade', description: 'Execute trade' },
|
||||
];
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Failed to list MCP tools');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available resources from user's MCP server
|
||||
*/
|
||||
async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
|
||||
if (!this.client || !this.connected) {
|
||||
throw new Error('MCP client not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const resources = await this.client.listResources();
|
||||
// return resources;
|
||||
|
||||
// Placeholder resources for user context
|
||||
return [
|
||||
{
|
||||
uri: 'context://user-profile',
|
||||
name: 'User Profile',
|
||||
description: 'User trading style, preferences, and background',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{
|
||||
uri: 'context://conversation-summary',
|
||||
name: 'Conversation Summary',
|
||||
description: 'Semantic summary of recent conversation history with RAG',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{
|
||||
uri: 'context://workspace-state',
|
||||
name: 'Workspace State',
|
||||
description: 'Current chart, watchlist, and open positions',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'context://system-prompt',
|
||||
name: 'Custom System Prompt',
|
||||
description: 'User custom instructions for the assistant',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Failed to list MCP resources');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resource from user's MCP server
|
||||
*/
|
||||
async readResource(uri: string): Promise<{ uri: string; mimeType?: string; text?: string; blob?: string }> {
|
||||
if (!this.client || !this.connected) {
|
||||
throw new Error('MCP client not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
this.config.logger.debug({ uri }, 'Reading MCP resource');
|
||||
|
||||
// TODO: Implement when MCP client is connected
|
||||
// const resource = await this.client.readResource({ uri });
|
||||
// return resource;
|
||||
|
||||
// Placeholder resource content
|
||||
if (uri === 'context://user-profile') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `User Profile:
|
||||
- Trading experience: Intermediate
|
||||
- Preferred timeframes: 1h, 4h, 1d
|
||||
- Risk tolerance: Medium
|
||||
- Focus: Swing trading with technical indicators`,
|
||||
};
|
||||
} else if (uri === 'context://conversation-summary') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `Recent Conversation Summary:
|
||||
[RAG-generated summary would go here]
|
||||
|
||||
User recently discussed:
|
||||
- Moving average crossover strategies
|
||||
- Backtesting on BTC/USDT
|
||||
- Risk management techniques`,
|
||||
};
|
||||
} else if (uri === 'context://workspace-state') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
currentChart: { ticker: 'BINANCE:BTC/USDT', timeframe: '1h' },
|
||||
watchlist: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'],
|
||||
openPositions: [],
|
||||
}, null, 2),
|
||||
};
|
||||
} else if (uri === 'context://system-prompt') {
|
||||
return {
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: `Custom Instructions:
|
||||
- Be concise and data-driven
|
||||
- Always show risk/reward ratios
|
||||
- Prefer simple strategies over complex ones`,
|
||||
};
|
||||
}
|
||||
|
||||
return { uri, text: '' };
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error, uri }, 'MCP resource read failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from MCP server
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client && this.connected) {
|
||||
try {
|
||||
await this.client.close();
|
||||
this.connected = false;
|
||||
this.config.logger.info('Disconnected from user MCP server');
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Error disconnecting from MCP server');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
327
gateway/src/k8s/client.ts
Normal file
327
gateway/src/k8s/client.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import * as yaml from 'js-yaml';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export interface K8sClientConfig {
|
||||
namespace: string;
|
||||
inCluster: boolean;
|
||||
context?: string; // For local dev
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
export interface DeploymentSpec {
|
||||
userId: string;
|
||||
licenseType: 'free' | 'pro' | 'enterprise';
|
||||
agentImage: string;
|
||||
sidecarImage: string;
|
||||
storageClass: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kubernetes client wrapper for managing agent deployments
|
||||
*/
|
||||
export class KubernetesClient {
|
||||
private config: K8sClientConfig;
|
||||
private k8sConfig: k8s.KubeConfig;
|
||||
private appsApi: k8s.AppsV1Api;
|
||||
private coreApi: k8s.CoreV1Api;
|
||||
|
||||
constructor(config: K8sClientConfig) {
|
||||
this.config = config;
|
||||
this.k8sConfig = new k8s.KubeConfig();
|
||||
|
||||
if (config.inCluster) {
|
||||
this.k8sConfig.loadFromCluster();
|
||||
this.config.logger.info('Loaded in-cluster Kubernetes config');
|
||||
} else {
|
||||
this.k8sConfig.loadFromDefault();
|
||||
if (config.context) {
|
||||
this.k8sConfig.setCurrentContext(config.context);
|
||||
this.config.logger.info({ context: config.context }, 'Set Kubernetes context');
|
||||
}
|
||||
this.config.logger.info('Loaded Kubernetes config from default location');
|
||||
}
|
||||
|
||||
this.appsApi = this.k8sConfig.makeApiClient(k8s.AppsV1Api);
|
||||
this.coreApi = this.k8sConfig.makeApiClient(k8s.CoreV1Api);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deployment name from user ID
|
||||
*/
|
||||
static getDeploymentName(userId: string): string {
|
||||
// Sanitize userId to be k8s-compliant (lowercase alphanumeric + hyphens)
|
||||
const sanitized = userId.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
return `agent-${sanitized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate service name (same as deployment)
|
||||
*/
|
||||
static getServiceName(userId: string): string {
|
||||
return this.getDeploymentName(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PVC name
|
||||
*/
|
||||
static getPvcName(userId: string): string {
|
||||
return `${this.getDeploymentName(userId)}-data`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute MCP endpoint URL from service name
|
||||
*/
|
||||
static getMcpEndpoint(userId: string, namespace: string): string {
|
||||
const serviceName = this.getServiceName(userId);
|
||||
return `http://${serviceName}.${namespace}.svc.cluster.local:3000`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if deployment exists
|
||||
*/
|
||||
async deploymentExists(deploymentName: string): Promise<boolean> {
|
||||
try {
|
||||
await this.appsApi.readNamespacedDeployment(deploymentName, this.config.namespace);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent deployment from template
|
||||
*/
|
||||
async createAgentDeployment(spec: DeploymentSpec): Promise<void> {
|
||||
const deploymentName = KubernetesClient.getDeploymentName(spec.userId);
|
||||
const serviceName = KubernetesClient.getServiceName(spec.userId);
|
||||
const pvcName = KubernetesClient.getPvcName(spec.userId);
|
||||
|
||||
this.config.logger.info(
|
||||
{ userId: spec.userId, licenseType: spec.licenseType, deploymentName },
|
||||
'Creating agent deployment'
|
||||
);
|
||||
|
||||
// Load template based on license type
|
||||
const templatePath = path.join(
|
||||
__dirname,
|
||||
'templates',
|
||||
`${spec.licenseType}-tier.yaml`
|
||||
);
|
||||
|
||||
const templateContent = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
// Substitute variables
|
||||
const rendered = templateContent
|
||||
.replace(/\{\{userId\}\}/g, spec.userId)
|
||||
.replace(/\{\{deploymentName\}\}/g, deploymentName)
|
||||
.replace(/\{\{serviceName\}\}/g, serviceName)
|
||||
.replace(/\{\{pvcName\}\}/g, pvcName)
|
||||
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
|
||||
.replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage)
|
||||
.replace(/\{\{storageClass\}\}/g, spec.storageClass);
|
||||
|
||||
// Parse YAML documents (deployment, pvc, service)
|
||||
const documents = yaml.loadAll(rendered) as any[];
|
||||
|
||||
// Apply each resource
|
||||
for (const doc of documents) {
|
||||
if (!doc || !doc.kind) continue;
|
||||
|
||||
try {
|
||||
switch (doc.kind) {
|
||||
case 'Deployment':
|
||||
await this.appsApi.createNamespacedDeployment(this.config.namespace, doc);
|
||||
this.config.logger.info({ deploymentName }, 'Created deployment');
|
||||
break;
|
||||
|
||||
case 'PersistentVolumeClaim':
|
||||
await this.coreApi.createNamespacedPersistentVolumeClaim(
|
||||
this.config.namespace,
|
||||
doc
|
||||
);
|
||||
this.config.logger.info({ pvcName }, 'Created PVC');
|
||||
break;
|
||||
|
||||
case 'Service':
|
||||
await this.coreApi.createNamespacedService(this.config.namespace, doc);
|
||||
this.config.logger.info({ serviceName }, 'Created service');
|
||||
break;
|
||||
|
||||
default:
|
||||
this.config.logger.warn({ kind: doc.kind }, 'Unknown resource kind in template');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If resource already exists, log warning but continue
|
||||
if (error.response?.statusCode === 409) {
|
||||
this.config.logger.warn(
|
||||
{ kind: doc.kind, name: doc.metadata?.name },
|
||||
'Resource already exists, skipping'
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.config.logger.info({ deploymentName }, 'Agent deployment created successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for deployment to be ready
|
||||
*/
|
||||
async waitForDeploymentReady(
|
||||
deploymentName: string,
|
||||
timeoutMs: number = 120000
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 2000; // 2 seconds
|
||||
|
||||
this.config.logger.info(
|
||||
{ deploymentName, timeoutMs },
|
||||
'Waiting for deployment to be ready'
|
||||
);
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
try {
|
||||
const response = await this.appsApi.readNamespacedDeployment(
|
||||
deploymentName,
|
||||
this.config.namespace
|
||||
);
|
||||
|
||||
const deployment = response.body;
|
||||
const status = deployment.status;
|
||||
|
||||
// Check if deployment is ready
|
||||
if (
|
||||
status?.availableReplicas &&
|
||||
status.availableReplicas > 0 &&
|
||||
status.readyReplicas &&
|
||||
status.readyReplicas > 0
|
||||
) {
|
||||
this.config.logger.info({ deploymentName }, 'Deployment is ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for failure conditions
|
||||
if (status?.conditions) {
|
||||
const failedCondition = status.conditions.find(
|
||||
(c) => c.type === 'Progressing' && c.status === 'False'
|
||||
);
|
||||
if (failedCondition) {
|
||||
this.config.logger.error(
|
||||
{ deploymentName, reason: failedCondition.reason, message: failedCondition.message },
|
||||
'Deployment failed to progress'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.config.logger.debug(
|
||||
{
|
||||
deploymentName,
|
||||
replicas: status?.replicas,
|
||||
ready: status?.readyReplicas,
|
||||
available: status?.availableReplicas,
|
||||
},
|
||||
'Deployment not ready yet, waiting...'
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode === 404) {
|
||||
this.config.logger.warn({ deploymentName }, 'Deployment not found');
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.config.logger.warn({ deploymentName, timeoutMs }, 'Deployment readiness timeout');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service endpoint URL
|
||||
*/
|
||||
async getServiceEndpoint(serviceName: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.coreApi.readNamespacedService(
|
||||
serviceName,
|
||||
this.config.namespace
|
||||
);
|
||||
|
||||
const service = response.body;
|
||||
|
||||
// For ClusterIP services, return internal DNS name
|
||||
if (service.spec?.type === 'ClusterIP') {
|
||||
const port = service.spec.ports?.find((p) => p.name === 'mcp')?.port || 3000;
|
||||
return `http://${serviceName}.${this.config.namespace}.svc.cluster.local:${port}`;
|
||||
}
|
||||
|
||||
// For other service types (NodePort, LoadBalancer), would need different logic
|
||||
this.config.logger.warn(
|
||||
{ serviceName, type: service.spec?.type },
|
||||
'Unexpected service type'
|
||||
);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode === 404) {
|
||||
this.config.logger.warn({ serviceName }, 'Service not found');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete deployment and associated resources
|
||||
* (Used for cleanup/testing - normally handled by lifecycle sidecar)
|
||||
*/
|
||||
async deleteAgentDeployment(userId: string): Promise<void> {
|
||||
const deploymentName = KubernetesClient.getDeploymentName(userId);
|
||||
const serviceName = KubernetesClient.getServiceName(userId);
|
||||
const pvcName = KubernetesClient.getPvcName(userId);
|
||||
|
||||
this.config.logger.info({ userId, deploymentName }, 'Deleting agent deployment');
|
||||
|
||||
// Delete deployment
|
||||
try {
|
||||
await this.appsApi.deleteNamespacedDeployment(deploymentName, this.config.namespace);
|
||||
this.config.logger.info({ deploymentName }, 'Deleted deployment');
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode !== 404) {
|
||||
this.config.logger.warn({ deploymentName, error }, 'Failed to delete deployment');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete service
|
||||
try {
|
||||
await this.coreApi.deleteNamespacedService(serviceName, this.config.namespace);
|
||||
this.config.logger.info({ serviceName }, 'Deleted service');
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode !== 404) {
|
||||
this.config.logger.warn({ serviceName, error }, 'Failed to delete service');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete PVC
|
||||
try {
|
||||
await this.coreApi.deleteNamespacedPersistentVolumeClaim(pvcName, this.config.namespace);
|
||||
this.config.logger.info({ pvcName }, 'Deleted PVC');
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode !== 404) {
|
||||
this.config.logger.warn({ pvcName, error }, 'Failed to delete PVC');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
gateway/src/k8s/container-manager.ts
Normal file
118
gateway/src/k8s/container-manager.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { KubernetesClient, type DeploymentSpec } from './client.js';
|
||||
import type { UserLicense } from '../types/user.js';
|
||||
|
||||
export interface ContainerManagerConfig {
|
||||
k8sClient: KubernetesClient;
|
||||
agentImage: string;
|
||||
sidecarImage: string;
|
||||
storageClass: string;
|
||||
namespace: string;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
export interface ContainerStatus {
|
||||
exists: boolean;
|
||||
ready: boolean;
|
||||
mcpEndpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container manager orchestrates agent container lifecycle
|
||||
*/
|
||||
export class ContainerManager {
|
||||
private config: ContainerManagerConfig;
|
||||
|
||||
constructor(config: ContainerManagerConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user's container is running and ready
|
||||
* Returns the MCP endpoint URL
|
||||
*/
|
||||
async ensureContainerRunning(
|
||||
userId: string,
|
||||
license: UserLicense
|
||||
): Promise<{ mcpEndpoint: string; wasCreated: boolean }> {
|
||||
const deploymentName = KubernetesClient.getDeploymentName(userId);
|
||||
const mcpEndpoint = KubernetesClient.getMcpEndpoint(userId, this.config.namespace);
|
||||
|
||||
this.config.logger.info(
|
||||
{ userId, licenseType: license.licenseType, deploymentName },
|
||||
'Ensuring container is running'
|
||||
);
|
||||
|
||||
// Check if deployment already exists
|
||||
const exists = await this.config.k8sClient.deploymentExists(deploymentName);
|
||||
|
||||
if (exists) {
|
||||
this.config.logger.info({ userId, deploymentName }, 'Container deployment already exists');
|
||||
|
||||
// Wait for it to be ready (in case it's starting up)
|
||||
const ready = await this.config.k8sClient.waitForDeploymentReady(deploymentName, 30000);
|
||||
|
||||
if (!ready) {
|
||||
this.config.logger.warn(
|
||||
{ userId, deploymentName },
|
||||
'Existing deployment not ready within timeout'
|
||||
);
|
||||
// Continue anyway - might be an image pull or other transient issue
|
||||
}
|
||||
|
||||
return { mcpEndpoint, wasCreated: false };
|
||||
}
|
||||
|
||||
// Create new deployment
|
||||
this.config.logger.info({ userId, licenseType: license.licenseType }, 'Creating new container');
|
||||
|
||||
const spec: DeploymentSpec = {
|
||||
userId,
|
||||
licenseType: license.licenseType,
|
||||
agentImage: this.config.agentImage,
|
||||
sidecarImage: this.config.sidecarImage,
|
||||
storageClass: this.config.storageClass,
|
||||
};
|
||||
|
||||
await this.config.k8sClient.createAgentDeployment(spec);
|
||||
|
||||
// Wait for deployment to be ready
|
||||
const ready = await this.config.k8sClient.waitForDeploymentReady(deploymentName, 120000);
|
||||
|
||||
if (!ready) {
|
||||
throw new Error(
|
||||
`Container deployment failed to become ready within timeout: ${deploymentName}`
|
||||
);
|
||||
}
|
||||
|
||||
this.config.logger.info({ userId, mcpEndpoint }, 'Container is ready');
|
||||
|
||||
return { mcpEndpoint, wasCreated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check container status without creating it
|
||||
*/
|
||||
async getContainerStatus(userId: string): Promise<ContainerStatus> {
|
||||
const deploymentName = KubernetesClient.getDeploymentName(userId);
|
||||
const mcpEndpoint = KubernetesClient.getMcpEndpoint(userId, this.config.namespace);
|
||||
|
||||
const exists = await this.config.k8sClient.deploymentExists(deploymentName);
|
||||
|
||||
if (!exists) {
|
||||
return { exists: false, ready: false, mcpEndpoint };
|
||||
}
|
||||
|
||||
// Check if ready (with short timeout)
|
||||
const ready = await this.config.k8sClient.waitForDeploymentReady(deploymentName, 5000);
|
||||
|
||||
return { exists: true, ready, mcpEndpoint };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete container (for cleanup/testing)
|
||||
*/
|
||||
async deleteContainer(userId: string): Promise<void> {
|
||||
await this.config.k8sClient.deleteAgentDeployment(userId);
|
||||
}
|
||||
}
|
||||
199
gateway/src/k8s/templates/enterprise-tier.yaml
Normal file
199
gateway/src/k8s/templates/enterprise-tier.yaml
Normal file
@@ -0,0 +1,199 @@
|
||||
# Enterprise tier agent deployment template
|
||||
# Variables: {{userId}}, {{deploymentName}}, {{pvcName}}, {{serviceName}}
|
||||
# Enterprise: No idle shutdown, larger resources
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{deploymentName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
app.kubernetes.io/name: agent
|
||||
app.kubernetes.io/component: user-agent
|
||||
dexorder.io/component: agent
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/deployment: {{deploymentName}}
|
||||
dexorder.io/license-tier: enterprise
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
dexorder.io/component: agent
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/deployment: {{deploymentName}}
|
||||
dexorder.io/license-tier: enterprise
|
||||
spec:
|
||||
serviceAccountName: agent-lifecycle
|
||||
shareProcessNamespace: true
|
||||
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{agentImage}}
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "4000m"
|
||||
|
||||
env:
|
||||
- name: USER_ID
|
||||
value: {{userId}}
|
||||
- name: IDLE_TIMEOUT_MINUTES
|
||||
value: "0"
|
||||
- name: IDLE_CHECK_INTERVAL_SECONDS
|
||||
value: "60"
|
||||
- name: ENABLE_IDLE_SHUTDOWN
|
||||
value: "false"
|
||||
- name: MCP_SERVER_PORT
|
||||
value: "3000"
|
||||
- name: ZMQ_CONTROL_PORT
|
||||
value: "5555"
|
||||
|
||||
ports:
|
||||
- name: mcp
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
- name: zmq-control
|
||||
containerPort: 5555
|
||||
protocol: TCP
|
||||
|
||||
volumeMounts:
|
||||
- name: agent-data
|
||||
mountPath: /app/data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: shared-run
|
||||
mountPath: /var/run/agent
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: mcp
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: mcp
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
- name: lifecycle-sidecar
|
||||
image: {{sidecarImage}}
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
cpu: "10m"
|
||||
limits:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
|
||||
env:
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: DEPLOYMENT_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels['dexorder.io/deployment']
|
||||
- name: USER_TYPE
|
||||
value: "enterprise"
|
||||
- name: MAIN_CONTAINER_PID
|
||||
value: "1"
|
||||
|
||||
volumeMounts:
|
||||
- name: shared-run
|
||||
mountPath: /var/run/agent
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
- name: agent-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{pvcName}}
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 512Mi
|
||||
- name: shared-run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{pvcName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/license-tier: enterprise
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
storageClassName: {{storageClass}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{serviceName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/license-tier: enterprise
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
ports:
|
||||
- name: mcp
|
||||
port: 3000
|
||||
targetPort: mcp
|
||||
protocol: TCP
|
||||
- name: zmq-control
|
||||
port: 5555
|
||||
targetPort: zmq-control
|
||||
protocol: TCP
|
||||
198
gateway/src/k8s/templates/free-tier.yaml
Normal file
198
gateway/src/k8s/templates/free-tier.yaml
Normal file
@@ -0,0 +1,198 @@
|
||||
# Free tier agent deployment template
|
||||
# Variables: {{userId}}, {{deploymentName}}, {{pvcName}}, {{serviceName}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{deploymentName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
app.kubernetes.io/name: agent
|
||||
app.kubernetes.io/component: user-agent
|
||||
dexorder.io/component: agent
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/deployment: {{deploymentName}}
|
||||
dexorder.io/license-tier: free
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
dexorder.io/component: agent
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/deployment: {{deploymentName}}
|
||||
dexorder.io/license-tier: free
|
||||
spec:
|
||||
serviceAccountName: agent-lifecycle
|
||||
shareProcessNamespace: true
|
||||
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{agentImage}}
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
|
||||
env:
|
||||
- name: USER_ID
|
||||
value: {{userId}}
|
||||
- name: IDLE_TIMEOUT_MINUTES
|
||||
value: "15"
|
||||
- name: IDLE_CHECK_INTERVAL_SECONDS
|
||||
value: "60"
|
||||
- name: ENABLE_IDLE_SHUTDOWN
|
||||
value: "true"
|
||||
- name: MCP_SERVER_PORT
|
||||
value: "3000"
|
||||
- name: ZMQ_CONTROL_PORT
|
||||
value: "5555"
|
||||
|
||||
ports:
|
||||
- name: mcp
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
- name: zmq-control
|
||||
containerPort: 5555
|
||||
protocol: TCP
|
||||
|
||||
volumeMounts:
|
||||
- name: agent-data
|
||||
mountPath: /app/data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: shared-run
|
||||
mountPath: /var/run/agent
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: mcp
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: mcp
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
- name: lifecycle-sidecar
|
||||
image: {{sidecarImage}}
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
cpu: "10m"
|
||||
limits:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
|
||||
env:
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: DEPLOYMENT_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels['dexorder.io/deployment']
|
||||
- name: USER_TYPE
|
||||
value: "free"
|
||||
- name: MAIN_CONTAINER_PID
|
||||
value: "1"
|
||||
|
||||
volumeMounts:
|
||||
- name: shared-run
|
||||
mountPath: /var/run/agent
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
- name: agent-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{pvcName}}
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 128Mi
|
||||
- name: shared-run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{pvcName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/license-tier: free
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storageClassName: {{storageClass}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{serviceName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/license-tier: free
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
ports:
|
||||
- name: mcp
|
||||
port: 3000
|
||||
targetPort: mcp
|
||||
protocol: TCP
|
||||
- name: zmq-control
|
||||
port: 5555
|
||||
targetPort: zmq-control
|
||||
protocol: TCP
|
||||
198
gateway/src/k8s/templates/pro-tier.yaml
Normal file
198
gateway/src/k8s/templates/pro-tier.yaml
Normal file
@@ -0,0 +1,198 @@
|
||||
# Pro tier agent deployment template
|
||||
# Variables: {{userId}}, {{deploymentName}}, {{pvcName}}, {{serviceName}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{deploymentName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
app.kubernetes.io/name: agent
|
||||
app.kubernetes.io/component: user-agent
|
||||
dexorder.io/component: agent
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/deployment: {{deploymentName}}
|
||||
dexorder.io/license-tier: pro
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
dexorder.io/component: agent
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/deployment: {{deploymentName}}
|
||||
dexorder.io/license-tier: pro
|
||||
spec:
|
||||
serviceAccountName: agent-lifecycle
|
||||
shareProcessNamespace: true
|
||||
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{agentImage}}
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "2000m"
|
||||
|
||||
env:
|
||||
- name: USER_ID
|
||||
value: {{userId}}
|
||||
- name: IDLE_TIMEOUT_MINUTES
|
||||
value: "60"
|
||||
- name: IDLE_CHECK_INTERVAL_SECONDS
|
||||
value: "60"
|
||||
- name: ENABLE_IDLE_SHUTDOWN
|
||||
value: "true"
|
||||
- name: MCP_SERVER_PORT
|
||||
value: "3000"
|
||||
- name: ZMQ_CONTROL_PORT
|
||||
value: "5555"
|
||||
|
||||
ports:
|
||||
- name: mcp
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
- name: zmq-control
|
||||
containerPort: 5555
|
||||
protocol: TCP
|
||||
|
||||
volumeMounts:
|
||||
- name: agent-data
|
||||
mountPath: /app/data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: shared-run
|
||||
mountPath: /var/run/agent
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: mcp
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: mcp
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
- name: lifecycle-sidecar
|
||||
image: {{sidecarImage}}
|
||||
imagePullPolicy: Always
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
cpu: "10m"
|
||||
limits:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
|
||||
env:
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: DEPLOYMENT_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels['dexorder.io/deployment']
|
||||
- name: USER_TYPE
|
||||
value: "pro"
|
||||
- name: MAIN_CONTAINER_PID
|
||||
value: "1"
|
||||
|
||||
volumeMounts:
|
||||
- name: shared-run
|
||||
mountPath: /var/run/agent
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
- name: agent-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{pvcName}}
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 256Mi
|
||||
- name: shared-run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
|
||||
restartPolicy: Always
|
||||
terminationGracePeriodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{pvcName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/license-tier: pro
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
storageClassName: {{storageClass}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{serviceName}}
|
||||
namespace: dexorder-agents
|
||||
labels:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
dexorder.io/license-tier: pro
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
dexorder.io/user-id: {{userId}}
|
||||
ports:
|
||||
- name: mcp
|
||||
port: 3000
|
||||
targetPort: mcp
|
||||
protocol: TCP
|
||||
- name: zmq-control
|
||||
port: 5555
|
||||
targetPort: zmq-control
|
||||
protocol: TCP
|
||||
216
gateway/src/llm/provider.ts
Normal file
216
gateway/src/llm/provider.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatOpenRouter } from '@langchain/openrouter';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
/**
|
||||
* Supported LLM providers
|
||||
*/
|
||||
export enum LLMProvider {
|
||||
ANTHROPIC = 'anthropic',
|
||||
OPENAI = 'openai',
|
||||
GOOGLE = 'google',
|
||||
OPENROUTER = 'openrouter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Model configuration
|
||||
*/
|
||||
export interface ModelConfig {
|
||||
provider: LLMProvider;
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration with API keys
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
anthropicApiKey?: string;
|
||||
openaiApiKey?: string;
|
||||
googleApiKey?: string;
|
||||
openrouterApiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Provider factory
|
||||
* Creates model instances with unified interface across providers
|
||||
*/
|
||||
export class LLMProviderFactory {
|
||||
private config: ProviderConfig;
|
||||
private logger: FastifyBaseLogger;
|
||||
|
||||
constructor(config: ProviderConfig, logger: FastifyBaseLogger) {
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a chat model instance
|
||||
*/
|
||||
createModel(modelConfig: ModelConfig): BaseChatModel {
|
||||
this.logger.debug(
|
||||
{ provider: modelConfig.provider, model: modelConfig.model },
|
||||
'Creating LLM model'
|
||||
);
|
||||
|
||||
switch (modelConfig.provider) {
|
||||
case LLMProvider.ANTHROPIC:
|
||||
return this.createAnthropicModel(modelConfig);
|
||||
|
||||
case LLMProvider.OPENAI:
|
||||
return this.createOpenAIModel(modelConfig);
|
||||
|
||||
case LLMProvider.GOOGLE:
|
||||
return this.createGoogleModel(modelConfig);
|
||||
|
||||
case LLMProvider.OPENROUTER:
|
||||
return this.createOpenRouterModel(modelConfig);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${modelConfig.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Anthropic Claude model
|
||||
*/
|
||||
private createAnthropicModel(config: ModelConfig): ChatAnthropic {
|
||||
if (!this.config.anthropicApiKey) {
|
||||
throw new Error('Anthropic API key not configured');
|
||||
}
|
||||
|
||||
return new ChatAnthropic({
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0.7,
|
||||
maxTokens: config.maxTokens ?? 4096,
|
||||
anthropicApiKey: this.config.anthropicApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI GPT model
|
||||
*/
|
||||
private createOpenAIModel(config: ModelConfig): ChatOpenAI {
|
||||
if (!this.config.openaiApiKey) {
|
||||
throw new Error('OpenAI API key not configured');
|
||||
}
|
||||
|
||||
return new ChatOpenAI({
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0.7,
|
||||
maxTokens: config.maxTokens ?? 4096,
|
||||
openAIApiKey: this.config.openaiApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Google Gemini model
|
||||
*/
|
||||
private createGoogleModel(config: ModelConfig): ChatGoogleGenerativeAI {
|
||||
if (!this.config.googleApiKey) {
|
||||
throw new Error('Google API key not configured');
|
||||
}
|
||||
|
||||
return new ChatGoogleGenerativeAI({
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0.7,
|
||||
maxOutputTokens: config.maxTokens ?? 4096,
|
||||
apiKey: this.config.googleApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenRouter model (access to 300+ models)
|
||||
*/
|
||||
private createOpenRouterModel(config: ModelConfig): ChatOpenRouter {
|
||||
if (!this.config.openrouterApiKey) {
|
||||
throw new Error('OpenRouter API key not configured');
|
||||
}
|
||||
|
||||
return new ChatOpenRouter({
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0.7,
|
||||
maxTokens: config.maxTokens ?? 4096,
|
||||
apiKey: this.config.openrouterApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model based on environment
|
||||
*/
|
||||
getDefaultModel(): ModelConfig {
|
||||
// Check which API keys are available
|
||||
if (this.config.anthropicApiKey) {
|
||||
return {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.openaiApiKey) {
|
||||
return {
|
||||
provider: LLMProvider.OPENAI,
|
||||
model: 'gpt-4o',
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.googleApiKey) {
|
||||
return {
|
||||
provider: LLMProvider.GOOGLE,
|
||||
model: 'gemini-2.0-flash-exp',
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.openrouterApiKey) {
|
||||
return {
|
||||
provider: LLMProvider.OPENROUTER,
|
||||
model: 'anthropic/claude-3.5-sonnet',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('No LLM API keys configured');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined model configurations
|
||||
*/
|
||||
export const MODELS = {
|
||||
// Anthropic
|
||||
CLAUDE_SONNET: {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
},
|
||||
CLAUDE_HAIKU: {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
},
|
||||
CLAUDE_OPUS: {
|
||||
provider: LLMProvider.ANTHROPIC,
|
||||
model: 'claude-3-opus-20240229',
|
||||
},
|
||||
|
||||
// OpenAI
|
||||
GPT4O: {
|
||||
provider: LLMProvider.OPENAI,
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
GPT4O_MINI: {
|
||||
provider: LLMProvider.OPENAI,
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
|
||||
// Google
|
||||
GEMINI_2_FLASH: {
|
||||
provider: LLMProvider.GOOGLE,
|
||||
model: 'gemini-2.0-flash-exp',
|
||||
},
|
||||
GEMINI_PRO: {
|
||||
provider: LLMProvider.GOOGLE,
|
||||
model: 'gemini-1.5-pro',
|
||||
},
|
||||
} as const satisfies Record<string, ModelConfig>;
|
||||
202
gateway/src/llm/router.ts
Normal file
202
gateway/src/llm/router.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { LLMProviderFactory, type ModelConfig, LLMProvider } from './provider.js';
|
||||
import type { UserLicense } from '../types/user.js';
|
||||
|
||||
/**
|
||||
* Model routing strategies
|
||||
*/
|
||||
export enum RoutingStrategy {
|
||||
/** Use user's preferred model from license */
|
||||
USER_PREFERENCE = 'user_preference',
|
||||
/** Route based on query complexity */
|
||||
COMPLEXITY = 'complexity',
|
||||
/** Route based on license tier */
|
||||
LICENSE_TIER = 'license_tier',
|
||||
/** Use cheapest available model */
|
||||
COST_OPTIMIZED = 'cost_optimized',
|
||||
}
|
||||
|
||||
/**
|
||||
* Model router
|
||||
* Intelligently selects which model to use based on various factors
|
||||
*/
|
||||
export class ModelRouter {
|
||||
private factory: LLMProviderFactory;
|
||||
private logger: FastifyBaseLogger;
|
||||
private defaultModel: ModelConfig;
|
||||
|
||||
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
|
||||
this.factory = factory;
|
||||
this.logger = logger;
|
||||
this.defaultModel = factory.getDefaultModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Route to appropriate model based on context
|
||||
*/
|
||||
async route(
|
||||
message: string,
|
||||
license: UserLicense,
|
||||
strategy: RoutingStrategy = RoutingStrategy.USER_PREFERENCE
|
||||
): Promise<BaseChatModel> {
|
||||
let modelConfig: ModelConfig;
|
||||
|
||||
switch (strategy) {
|
||||
case RoutingStrategy.USER_PREFERENCE:
|
||||
modelConfig = this.routeByUserPreference(license);
|
||||
break;
|
||||
|
||||
case RoutingStrategy.COMPLEXITY:
|
||||
modelConfig = this.routeByComplexity(message, license);
|
||||
break;
|
||||
|
||||
case RoutingStrategy.LICENSE_TIER:
|
||||
modelConfig = this.routeByLicenseTier(license);
|
||||
break;
|
||||
|
||||
case RoutingStrategy.COST_OPTIMIZED:
|
||||
modelConfig = this.routeByCost(license);
|
||||
break;
|
||||
|
||||
default:
|
||||
modelConfig = this.defaultModel;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
userId: license.userId,
|
||||
strategy,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
},
|
||||
'Routing to model'
|
||||
);
|
||||
|
||||
return this.factory.createModel(modelConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route based on user's preferred model (if set in license)
|
||||
*/
|
||||
private routeByUserPreference(license: UserLicense): ModelConfig {
|
||||
// Check if user has custom model preference
|
||||
const preferredModel = (license as any).preferredModel as ModelConfig | undefined;
|
||||
|
||||
if (preferredModel && this.isModelAllowed(preferredModel, license)) {
|
||||
return preferredModel;
|
||||
}
|
||||
|
||||
// Fall back to license tier default
|
||||
return this.routeByLicenseTier(license);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route based on query complexity
|
||||
*/
|
||||
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
|
||||
const isComplex = this.isComplexQuery(message);
|
||||
|
||||
if (license.licenseType === 'enterprise') {
|
||||
// Enterprise users get best models for complex queries
|
||||
return isComplex
|
||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-opus-20240229' }
|
||||
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
||||
}
|
||||
|
||||
if (license.licenseType === 'pro') {
|
||||
// Pro users get good models
|
||||
return isComplex
|
||||
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }
|
||||
: { provider: LLMProvider.OPENAI, model: 'gpt-4o-mini' };
|
||||
}
|
||||
|
||||
// Free users get efficient models
|
||||
return { provider: LLMProvider.GOOGLE, model: 'gemini-2.0-flash-exp' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route based on license tier
|
||||
*/
|
||||
private routeByLicenseTier(license: UserLicense): ModelConfig {
|
||||
switch (license.licenseType) {
|
||||
case 'enterprise':
|
||||
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
|
||||
|
||||
case 'pro':
|
||||
return { provider: LLMProvider.OPENAI, model: 'gpt-4o' };
|
||||
|
||||
case 'free':
|
||||
return { provider: LLMProvider.GOOGLE, model: 'gemini-2.0-flash-exp' };
|
||||
|
||||
default:
|
||||
return this.defaultModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route to cheapest available model
|
||||
*/
|
||||
private routeByCost(license: UserLicense): ModelConfig {
|
||||
// Free tier: use cheapest
|
||||
if (license.licenseType === 'free') {
|
||||
return { provider: LLMProvider.GOOGLE, model: 'gemini-2.0-flash-exp' };
|
||||
}
|
||||
|
||||
// Paid tiers: use GPT-4o-mini for cost efficiency
|
||||
return { provider: LLMProvider.OPENAI, model: 'gpt-4o-mini' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model is allowed for user's license
|
||||
*/
|
||||
private isModelAllowed(model: ModelConfig, license: UserLicense): boolean {
|
||||
// Free tier: only cheap models
|
||||
if (license.licenseType === 'free') {
|
||||
const allowedModels = ['gemini-2.0-flash-exp', 'gpt-4o-mini', 'claude-3-5-haiku-20241022'];
|
||||
return allowedModels.includes(model.model);
|
||||
}
|
||||
|
||||
// Pro: all except Opus
|
||||
if (license.licenseType === 'pro') {
|
||||
const blockedModels = ['claude-3-opus-20240229'];
|
||||
return !blockedModels.includes(model.model);
|
||||
}
|
||||
|
||||
// Enterprise: all models allowed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if query is complex
|
||||
*/
|
||||
private isComplexQuery(message: string): boolean {
|
||||
const complexityIndicators = [
|
||||
// Multi-step analysis
|
||||
'backtest',
|
||||
'analyze',
|
||||
'compare',
|
||||
'optimize',
|
||||
|
||||
// Code generation
|
||||
'write',
|
||||
'create',
|
||||
'implement',
|
||||
'build',
|
||||
|
||||
// Deep reasoning
|
||||
'explain why',
|
||||
'what if',
|
||||
'how would',
|
||||
|
||||
// Long messages (> 200 chars likely complex)
|
||||
message.length > 200,
|
||||
];
|
||||
|
||||
const messageLower = message.toLowerCase();
|
||||
|
||||
return complexityIndicators.some((indicator) =>
|
||||
typeof indicator === 'string' ? messageLower.includes(indicator) : indicator
|
||||
);
|
||||
}
|
||||
}
|
||||
154
gateway/src/main.ts
Normal file
154
gateway/src/main.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import Fastify from 'fastify';
|
||||
import websocket from '@fastify/websocket';
|
||||
import cors from '@fastify/cors';
|
||||
import { UserService } from './db/user-service.js';
|
||||
import { Authenticator } from './auth/authenticator.js';
|
||||
import { WebSocketHandler } from './channels/websocket-handler.js';
|
||||
import { TelegramHandler } from './channels/telegram-handler.js';
|
||||
import { KubernetesClient } from './k8s/client.js';
|
||||
import { ContainerManager } from './k8s/container-manager.js';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Configuration from environment
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT || '3000'),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://localhost/dexorder',
|
||||
|
||||
// LLM provider API keys
|
||||
providerConfig: {
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
openaiApiKey: process.env.OPENAI_API_KEY,
|
||||
googleApiKey: process.env.GOOGLE_API_KEY,
|
||||
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
||||
},
|
||||
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
|
||||
// Kubernetes configuration
|
||||
kubernetes: {
|
||||
namespace: process.env.KUBERNETES_NAMESPACE || 'dexorder-agents',
|
||||
inCluster: process.env.KUBERNETES_IN_CLUSTER === 'true',
|
||||
context: process.env.KUBERNETES_CONTEXT,
|
||||
agentImage: process.env.AGENT_IMAGE || 'ghcr.io/dexorder/agent:latest',
|
||||
sidecarImage: process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest',
|
||||
storageClass: process.env.AGENT_STORAGE_CLASS || 'standard',
|
||||
},
|
||||
};
|
||||
|
||||
// Validate at least one LLM provider is configured
|
||||
const hasAnyProvider = Object.values(config.providerConfig).some(key => !!key);
|
||||
if (!hasAnyProvider) {
|
||||
app.log.error('At least one LLM provider API key is required (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, or OPENROUTER_API_KEY)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Register plugins
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
});
|
||||
|
||||
await app.register(websocket, {
|
||||
options: {
|
||||
maxPayload: 1024 * 1024, // 1MB
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize services
|
||||
const userService = new UserService(config.databaseUrl);
|
||||
|
||||
// Initialize Kubernetes client and container manager
|
||||
const k8sClient = new KubernetesClient({
|
||||
namespace: config.kubernetes.namespace,
|
||||
inCluster: config.kubernetes.inCluster,
|
||||
context: config.kubernetes.context,
|
||||
logger: app.log,
|
||||
});
|
||||
|
||||
const containerManager = new ContainerManager({
|
||||
k8sClient,
|
||||
agentImage: config.kubernetes.agentImage,
|
||||
sidecarImage: config.kubernetes.sidecarImage,
|
||||
storageClass: config.kubernetes.storageClass,
|
||||
namespace: config.kubernetes.namespace,
|
||||
logger: app.log,
|
||||
});
|
||||
|
||||
const authenticator = new Authenticator({
|
||||
userService,
|
||||
containerManager,
|
||||
logger: app.log,
|
||||
});
|
||||
|
||||
// Initialize channel handlers
|
||||
const websocketHandler = new WebSocketHandler({
|
||||
authenticator,
|
||||
providerConfig: config.providerConfig,
|
||||
});
|
||||
|
||||
const telegramHandler = new TelegramHandler({
|
||||
authenticator,
|
||||
providerConfig: config.providerConfig,
|
||||
telegramBotToken: config.telegramBotToken,
|
||||
});
|
||||
|
||||
// Register routes
|
||||
websocketHandler.register(app);
|
||||
telegramHandler.register(app);
|
||||
|
||||
// Health check
|
||||
app.get('/health', async () => {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
app.log.info('Shutting down gracefully...');
|
||||
try {
|
||||
await userService.close();
|
||||
await app.close();
|
||||
app.log.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
app.log.error({ error }, 'Error during shutdown');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Start server
|
||||
try {
|
||||
await app.listen({
|
||||
port: config.port,
|
||||
host: config.host,
|
||||
});
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
port: config.port,
|
||||
host: config.host,
|
||||
},
|
||||
'Gateway server started'
|
||||
);
|
||||
} catch (error) {
|
||||
app.log.error({ error }, 'Failed to start server');
|
||||
process.exit(1);
|
||||
}
|
||||
37
gateway/src/types/messages.ts
Normal file
37
gateway/src/types/messages.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Inbound user message from any channel
|
||||
*/
|
||||
export const InboundMessageSchema = z.object({
|
||||
messageId: z.string(),
|
||||
userId: z.string(),
|
||||
sessionId: z.string(),
|
||||
content: z.string(),
|
||||
attachments: z.array(z.object({
|
||||
type: z.enum(['image', 'file', 'url']),
|
||||
url: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
})).optional(),
|
||||
timestamp: z.date(),
|
||||
});
|
||||
|
||||
export type InboundMessage = z.infer<typeof InboundMessageSchema>;
|
||||
|
||||
/**
|
||||
* Outbound response to channel
|
||||
*/
|
||||
export const OutboundMessageSchema = z.object({
|
||||
messageId: z.string(),
|
||||
sessionId: z.string(),
|
||||
content: z.string(),
|
||||
attachments: z.array(z.object({
|
||||
type: z.enum(['image', 'chart', 'file']),
|
||||
url: z.string(),
|
||||
caption: z.string().optional(),
|
||||
})).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
timestamp: z.date(),
|
||||
});
|
||||
|
||||
export type OutboundMessage = z.infer<typeof OutboundMessageSchema>;
|
||||
101
gateway/src/types/resources.ts
Normal file
101
gateway/src/types/resources.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* MCP Resource types for user context
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base resource structure from MCP server
|
||||
*/
|
||||
export const MCPResourceSchema = z.object({
|
||||
uri: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
blob: z.string().optional(), // base64 encoded
|
||||
});
|
||||
|
||||
export type MCPResource = z.infer<typeof MCPResourceSchema>;
|
||||
|
||||
/**
|
||||
* User profile context
|
||||
*/
|
||||
export const UserProfileContextSchema = z.object({
|
||||
tradingExperience: z.enum(['beginner', 'intermediate', 'advanced', 'professional']),
|
||||
preferredTimeframes: z.array(z.string()),
|
||||
riskTolerance: z.enum(['low', 'medium', 'high']),
|
||||
tradingStyle: z.string(),
|
||||
favoriteIndicators: z.array(z.string()).optional(),
|
||||
activeTradingPairs: z.array(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserProfileContext = z.infer<typeof UserProfileContextSchema>;
|
||||
|
||||
/**
|
||||
* Workspace state (current chart, positions, etc.)
|
||||
*/
|
||||
export const WorkspaceStateSchema = z.object({
|
||||
currentChart: z.object({
|
||||
ticker: z.string(),
|
||||
timeframe: z.string(),
|
||||
indicators: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
watchlist: z.array(z.string()),
|
||||
openPositions: z.array(z.object({
|
||||
ticker: z.string(),
|
||||
side: z.enum(['long', 'short']),
|
||||
size: z.number(),
|
||||
entryPrice: z.number(),
|
||||
currentPrice: z.number().optional(),
|
||||
unrealizedPnL: z.number().optional(),
|
||||
})),
|
||||
recentAlerts: z.array(z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
timestamp: z.string(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
export type WorkspaceState = z.infer<typeof WorkspaceStateSchema>;
|
||||
|
||||
/**
|
||||
* Standard context resource URIs
|
||||
*/
|
||||
export const CONTEXT_URIS = {
|
||||
USER_PROFILE: 'context://user-profile',
|
||||
CONVERSATION_SUMMARY: 'context://conversation-summary',
|
||||
WORKSPACE_STATE: 'context://workspace-state',
|
||||
SYSTEM_PROMPT: 'context://system-prompt',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resource content interface
|
||||
*/
|
||||
export interface ResourceContent {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse resource content
|
||||
*/
|
||||
export function parseResource<T>(resource: ResourceContent, schema: z.ZodSchema<T>): T | null {
|
||||
if (!resource.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try JSON parsing if mime type is JSON
|
||||
if (resource.mimeType?.includes('json')) {
|
||||
const data = JSON.parse(resource.text);
|
||||
return schema.parse(data);
|
||||
}
|
||||
|
||||
// Otherwise return as-is for text resources
|
||||
return resource.text as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
66
gateway/src/types/user.ts
Normal file
66
gateway/src/types/user.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Model preference configuration
|
||||
*/
|
||||
export const ModelPreferenceSchema = z.object({
|
||||
provider: z.enum(['anthropic', 'openai', 'google', 'openrouter']),
|
||||
model: z.string(),
|
||||
temperature: z.number().optional(),
|
||||
});
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>;
|
||||
|
||||
/**
|
||||
* User license and feature authorization
|
||||
*/
|
||||
export const UserLicenseSchema = z.object({
|
||||
userId: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
licenseType: z.enum(['free', 'pro', 'enterprise']),
|
||||
features: z.object({
|
||||
maxIndicators: z.number(),
|
||||
maxStrategies: z.number(),
|
||||
maxBacktestDays: z.number(),
|
||||
realtimeData: z.boolean(),
|
||||
customExecutors: z.boolean(),
|
||||
apiAccess: z.boolean(),
|
||||
}),
|
||||
resourceLimits: z.object({
|
||||
maxConcurrentSessions: z.number(),
|
||||
maxMessagesPerDay: z.number(),
|
||||
maxTokensPerMessage: z.number(),
|
||||
rateLimitPerMinute: z.number(),
|
||||
}),
|
||||
mcpServerUrl: z.string().url(),
|
||||
preferredModel: ModelPreferenceSchema.optional(),
|
||||
expiresAt: z.date().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type UserLicense = z.infer<typeof UserLicenseSchema>;
|
||||
|
||||
/**
|
||||
* Channel types for multi-channel support
|
||||
*/
|
||||
export enum ChannelType {
|
||||
WEBSOCKET = 'websocket',
|
||||
TELEGRAM = 'telegram',
|
||||
SLACK = 'slack',
|
||||
DISCORD = 'discord',
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication context per channel
|
||||
*/
|
||||
export const AuthContextSchema = z.object({
|
||||
userId: z.string(),
|
||||
channelType: z.nativeEnum(ChannelType),
|
||||
channelUserId: z.string(), // Platform-specific ID (telegram_id, discord_id, etc)
|
||||
sessionId: z.string(),
|
||||
license: UserLicenseSchema,
|
||||
authenticatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type AuthContext = z.infer<typeof AuthContextSchema>;
|
||||
253
gateway/src/workflows/README.md
Normal file
253
gateway/src/workflows/README.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# LangGraph Workflows for Trading
|
||||
|
||||
Complex, stateful workflows built with LangGraph for trading-specific tasks.
|
||||
|
||||
## Overview
|
||||
|
||||
LangGraph provides:
|
||||
- **Stateful execution**: Workflow state persists across failures
|
||||
- **Conditional branching**: Route based on market conditions, backtest results, etc.
|
||||
- **Human-in-the-loop**: Pause for user approval before executing trades
|
||||
- **Loops & retries**: Backtest with different parameters, retry failed operations
|
||||
- **Multi-agent**: Different LLMs for different tasks (analysis, risk, execution)
|
||||
|
||||
## Workflows
|
||||
|
||||
### Strategy Analysis (`strategy-analysis.ts`)
|
||||
|
||||
Multi-step pipeline for analyzing trading strategies:
|
||||
|
||||
```typescript
|
||||
import { buildStrategyAnalysisWorkflow } from './workflows/strategy-analysis.js';
|
||||
|
||||
const workflow = buildStrategyAnalysisWorkflow(model, logger, mcpBacktestFn);
|
||||
|
||||
const result = await workflow.invoke({
|
||||
strategyCode: userStrategy,
|
||||
ticker: 'BTC/USDT',
|
||||
timeframe: '1h',
|
||||
});
|
||||
|
||||
console.log(result.recommendation); // Go/no-go decision
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. **Code Review** - LLM analyzes strategy code for bugs, logic errors
|
||||
2. **Backtest** - Runs backtest via user's MCP server
|
||||
3. **Risk Assessment** - LLM evaluates results (drawdown, Sharpe, etc.)
|
||||
4. **Human Approval** - Pauses for user review
|
||||
5. **Recommendation** - Final go/no-go decision
|
||||
|
||||
**Benefits:**
|
||||
- Stateful: Can resume if server restarts
|
||||
- Human-in-the-loop: User must approve before deployment
|
||||
- Multi-step reasoning: Each step builds on previous
|
||||
|
||||
---
|
||||
|
||||
## Future Workflows
|
||||
|
||||
### Market Scanner
|
||||
|
||||
Scan multiple tickers for trading opportunities:
|
||||
|
||||
```typescript
|
||||
const scanner = buildMarketScannerWorkflow(model, logger);
|
||||
|
||||
const result = await scanner.invoke({
|
||||
tickers: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'],
|
||||
strategies: ['momentum', 'mean_reversion'],
|
||||
timeframe: '1h',
|
||||
});
|
||||
|
||||
// Returns ranked opportunities
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. **Fetch Data** - Get OHLC for all tickers
|
||||
2. **Apply Strategies** - Run each strategy on each ticker (parallel)
|
||||
3. **Rank Signals** - Score by confidence, risk/reward
|
||||
4. **Filter** - Apply user's risk limits
|
||||
5. **Return Top N** - Best opportunities
|
||||
|
||||
---
|
||||
|
||||
### Portfolio Optimization
|
||||
|
||||
Optimize position sizing across multiple strategies:
|
||||
|
||||
```typescript
|
||||
const optimizer = buildPortfolioOptimizerWorkflow(model, logger);
|
||||
|
||||
const result = await optimizer.invoke({
|
||||
strategies: [strategy1, strategy2, strategy3],
|
||||
totalCapital: 100000,
|
||||
maxRiskPerTrade: 0.02,
|
||||
});
|
||||
|
||||
// Returns optimal allocation
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. **Backtest All** - Run backtests for each strategy
|
||||
2. **Correlation Analysis** - Check strategy correlation
|
||||
3. **Monte Carlo** - Simulate portfolio performance
|
||||
4. **Optimize** - Find optimal weights (Sharpe maximization)
|
||||
5. **Risk Check** - Validate against user limits
|
||||
|
||||
---
|
||||
|
||||
### Trade Execution Monitor
|
||||
|
||||
Monitor trade execution and adapt to market conditions:
|
||||
|
||||
```typescript
|
||||
const monitor = buildTradeExecutionWorkflow(model, logger, exchange);
|
||||
|
||||
const result = await monitor.invoke({
|
||||
tradeId: 'xyz',
|
||||
targetPrice: 45000,
|
||||
maxSlippage: 0.001,
|
||||
timeLimit: 60, // seconds
|
||||
});
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. **Place Order** - Submit order to exchange
|
||||
2. **Monitor Fill** - Check fill status every second
|
||||
3. **Adapt** - If not filling, adjust price (within slippage)
|
||||
4. **Retry Logic** - If rejected, retry with backoff
|
||||
5. **Timeout** - Cancel if time limit exceeded
|
||||
6. **Report** - Final execution report
|
||||
|
||||
---
|
||||
|
||||
## Using Workflows in Gateway
|
||||
|
||||
### Simple Chat vs Complex Workflow
|
||||
|
||||
```typescript
|
||||
// gateway/src/orchestrator.ts
|
||||
|
||||
export class MessageOrchestrator {
|
||||
async handleMessage(msg: InboundMessage) {
|
||||
// Route based on complexity
|
||||
if (this.isSimpleQuery(msg)) {
|
||||
// Use agent harness for streaming chat
|
||||
return this.harness.streamMessage(msg);
|
||||
}
|
||||
|
||||
if (this.isWorkflowRequest(msg)) {
|
||||
// Use LangGraph for complex analysis
|
||||
return this.executeWorkflow(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async executeWorkflow(msg: InboundMessage) {
|
||||
const { type, params } = this.parseWorkflowRequest(msg);
|
||||
|
||||
switch (type) {
|
||||
case 'analyze_strategy':
|
||||
const workflow = buildStrategyAnalysisWorkflow(...);
|
||||
return await workflow.invoke(params);
|
||||
|
||||
case 'scan_market':
|
||||
const scanner = buildMarketScannerWorkflow(...);
|
||||
return await scanner.invoke(params);
|
||||
|
||||
// ... more workflows
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits for Trading
|
||||
|
||||
### vs Simple LLM Calls
|
||||
|
||||
| Scenario | Simple LLM | LangGraph Workflow |
|
||||
|----------|-----------|-------------------|
|
||||
| "What's the RSI?" | ✅ Fast, streaming | ❌ Overkill |
|
||||
| "Analyze this strategy" | ❌ Limited context | ✅ Multi-step analysis |
|
||||
| "Backtest 10 param combos" | ❌ No loops | ✅ Conditional loops |
|
||||
| "Execute if approved" | ❌ No state | ✅ Human-in-the-loop |
|
||||
| Server crashes mid-analysis | ❌ Lost progress | ✅ Resume from checkpoint |
|
||||
|
||||
### When to Use Workflows
|
||||
|
||||
**Use LangGraph when:**
|
||||
- Multi-step analysis (backtest → risk → approval)
|
||||
- Conditional logic (if bullish → momentum, else → mean-reversion)
|
||||
- Human approval required (pause workflow)
|
||||
- Loops needed (try different parameters)
|
||||
- Long-running (can survive restarts)
|
||||
|
||||
**Use Agent Harness when:**
|
||||
- Simple Q&A ("What is RSI?")
|
||||
- Fast response needed (streaming chat)
|
||||
- Single tool call ("Get my watchlist")
|
||||
- Real-time interaction (Telegram, WebSocket)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### State Persistence
|
||||
|
||||
LangGraph can persist state to database:
|
||||
|
||||
```typescript
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
|
||||
const checkpointer = new MemorySaver();
|
||||
|
||||
const workflow = graph.compile({ checkpointer });
|
||||
|
||||
// Resume from checkpoint
|
||||
const result = await workflow.invoke(input, {
|
||||
configurable: { thread_id: 'user-123-strategy-analysis' }
|
||||
});
|
||||
```
|
||||
|
||||
### Human-in-the-Loop
|
||||
|
||||
Pause workflow for user input:
|
||||
|
||||
```typescript
|
||||
const workflow = graph
|
||||
.addNode('human_approval', humanApprovalNode)
|
||||
.interrupt('human_approval'); // Pauses here
|
||||
|
||||
// User reviews in UI
|
||||
const approved = await getUserApproval(workflowId);
|
||||
|
||||
// Resume workflow
|
||||
await workflow.resume(state, { approved });
|
||||
```
|
||||
|
||||
### Multi-Agent
|
||||
|
||||
Use different models for different tasks:
|
||||
|
||||
```typescript
|
||||
const analysisModel = new ChatAnthropic({ model: 'claude-3-opus' }); // Smart
|
||||
const codeModel = new ChatOpenAI({ model: 'gpt-4o' }); // Good at code
|
||||
const cheapModel = new ChatOpenAI({ model: 'gpt-4o-mini' }); // Fast
|
||||
|
||||
const workflow = graph
|
||||
.addNode('analyze', (state) => analysisModel.invoke(...))
|
||||
.addNode('code_review', (state) => codeModel.invoke(...))
|
||||
.addNode('summarize', (state) => cheapModel.invoke(...));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement remaining workflows (scanner, optimizer, execution)
|
||||
2. Add state persistence (PostgreSQL checkpointer)
|
||||
3. Integrate human-in-the-loop with WebSocket
|
||||
4. Add workflow monitoring dashboard
|
||||
5. Performance optimization (parallel execution)
|
||||
162
gateway/src/workflows/strategy-analysis.ts
Normal file
162
gateway/src/workflows/strategy-analysis.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { StateGraph, Annotation } from '@langchain/langgraph';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
/**
|
||||
* State for strategy analysis workflow
|
||||
*/
|
||||
const StrategyAnalysisState = Annotation.Root({
|
||||
strategyCode: Annotation<string>(),
|
||||
ticker: Annotation<string>(),
|
||||
timeframe: Annotation<string>(),
|
||||
|
||||
// Analysis steps
|
||||
codeReview: Annotation<string | null>({
|
||||
default: () => null,
|
||||
}),
|
||||
backtestResults: Annotation<Record<string, unknown> | null>({
|
||||
default: () => null,
|
||||
}),
|
||||
riskAssessment: Annotation<string | null>({
|
||||
default: () => null,
|
||||
}),
|
||||
humanApproved: Annotation<boolean>({
|
||||
default: () => false,
|
||||
}),
|
||||
|
||||
// Final output
|
||||
recommendation: Annotation<string | null>({
|
||||
default: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
type StrategyAnalysisStateType = typeof StrategyAnalysisState.State;
|
||||
|
||||
/**
|
||||
* Build strategy analysis workflow using LangGraph
|
||||
*
|
||||
* Workflow steps:
|
||||
* 1. Code review (LLM analyzes strategy code)
|
||||
* 2. Backtest (calls user's MCP backtest tool)
|
||||
* 3. Risk assessment (LLM evaluates results)
|
||||
* 4. Human approval (pause for user review)
|
||||
* 5. Final recommendation
|
||||
*/
|
||||
export function buildStrategyAnalysisWorkflow(
|
||||
model: BaseChatModel,
|
||||
logger: FastifyBaseLogger,
|
||||
mcpBacktestFn: (strategy: string, ticker: string, timeframe: string) => Promise<Record<string, unknown>>
|
||||
) {
|
||||
// Node: Code Review
|
||||
const codeReviewNode = async (state: StrategyAnalysisStateType) => {
|
||||
logger.info('Strategy workflow: Code review');
|
||||
|
||||
const systemPrompt = `You are an expert trading strategy analyst.
|
||||
Review the following strategy code for potential issues, bugs, or improvements.
|
||||
Focus on: logic errors, edge cases, performance, and trading best practices.`;
|
||||
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(`Review this strategy:\n\n${state.strategyCode}`),
|
||||
]);
|
||||
|
||||
return {
|
||||
codeReview: response.content as string,
|
||||
};
|
||||
};
|
||||
|
||||
// Node: Backtest
|
||||
const backtestNode = async (state: StrategyAnalysisStateType) => {
|
||||
logger.info('Strategy workflow: Running backtest');
|
||||
|
||||
const results = await mcpBacktestFn(state.strategyCode, state.ticker, state.timeframe);
|
||||
|
||||
return {
|
||||
backtestResults: results,
|
||||
};
|
||||
};
|
||||
|
||||
// Node: Risk Assessment
|
||||
const riskAssessmentNode = async (state: StrategyAnalysisStateType) => {
|
||||
logger.info('Strategy workflow: Risk assessment');
|
||||
|
||||
const systemPrompt = `You are a risk management expert for trading strategies.
|
||||
Analyze the backtest results and provide a risk assessment.
|
||||
Focus on: drawdown, win rate, Sharpe ratio, position sizing, and risk of ruin.`;
|
||||
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(
|
||||
`Code review: ${state.codeReview}\n\nBacktest results: ${JSON.stringify(state.backtestResults, null, 2)}\n\nProvide risk assessment:`
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
riskAssessment: response.content as string,
|
||||
};
|
||||
};
|
||||
|
||||
// Node: Human Approval (placeholder - would integrate with UI)
|
||||
const humanApprovalNode = async (state: StrategyAnalysisStateType) => {
|
||||
logger.info('Strategy workflow: Awaiting human approval');
|
||||
|
||||
// In real implementation, this would pause and wait for user input
|
||||
// For now, auto-approve
|
||||
return {
|
||||
humanApproved: true,
|
||||
};
|
||||
};
|
||||
|
||||
// Node: Final Recommendation
|
||||
const recommendationNode = async (state: StrategyAnalysisStateType) => {
|
||||
logger.info('Strategy workflow: Generating recommendation');
|
||||
|
||||
const systemPrompt = `Provide a final recommendation on whether to deploy this trading strategy.
|
||||
Summarize the code review, backtest results, and risk assessment.
|
||||
Give clear go/no-go decision with reasoning.`;
|
||||
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(
|
||||
`Code review: ${state.codeReview}\n\nBacktest: ${JSON.stringify(state.backtestResults)}\n\nRisk: ${state.riskAssessment}\n\nApproved: ${state.humanApproved}\n\nYour recommendation:`
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
recommendation: response.content as string,
|
||||
};
|
||||
};
|
||||
|
||||
// Build graph
|
||||
const workflow = new StateGraph(StrategyAnalysisState)
|
||||
.addNode('code_review', codeReviewNode)
|
||||
.addNode('backtest', backtestNode)
|
||||
.addNode('risk_assessment', riskAssessmentNode)
|
||||
.addNode('human_approval', humanApprovalNode)
|
||||
.addNode('recommendation', recommendationNode)
|
||||
.addEdge('__start__', 'code_review')
|
||||
.addEdge('code_review', 'backtest')
|
||||
.addEdge('backtest', 'risk_assessment')
|
||||
.addEdge('risk_assessment', 'human_approval')
|
||||
.addConditionalEdges('human_approval', (state) => {
|
||||
return state.humanApproved ? 'recommendation' : '__end__';
|
||||
})
|
||||
.addEdge('recommendation', '__end__');
|
||||
|
||||
return workflow.compile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage:
|
||||
*
|
||||
* const workflow = buildStrategyAnalysisWorkflow(model, logger, mcpBacktestFn);
|
||||
*
|
||||
* const result = await workflow.invoke({
|
||||
* strategyCode: "strategy code here",
|
||||
* ticker: "BTC/USDT",
|
||||
* timeframe: "1h",
|
||||
* });
|
||||
*
|
||||
* console.log(result.recommendation);
|
||||
*/
|
||||
26
gateway/tsconfig.json
Normal file
26
gateway/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": false,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user