client-py connected

This commit is contained in:
2026-03-27 16:33:40 -04:00
parent c76887ab92
commit c3a8fae132
55 changed files with 1598 additions and 426 deletions

View File

@@ -22,7 +22,7 @@ OPENROUTER_API_KEY=sk-or-xxxxx
# Default model (if user has no preference)
DEFAULT_MODEL_PROVIDER=anthropic
DEFAULT_MODEL=claude-3-5-sonnet-20241022
DEFAULT_MODEL=claude-sonnet-4-6
# Telegram (optional)
TELEGRAM_BOT_TOKEN=

View File

@@ -32,7 +32,7 @@ const factory = new LLMProviderFactory(config, logger);
// Create any model
const claude = factory.createModel({
provider: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
model: 'claude-sonnet-4-6',
});
const gpt4 = factory.createModel({
@@ -205,7 +205,7 @@ const conversationSummary = await mcpClient.readResource('context://conversation
{
"preferredModel": {
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022"
"model": "claude-sonnet-4-6"
}
}

View File

@@ -120,7 +120,7 @@ ANTHROPIC_API_KEY=sk-ant-xxxxx
# Optional: Set default model
DEFAULT_MODEL_PROVIDER=anthropic
DEFAULT_MODEL=claude-3-5-sonnet-20241022
DEFAULT_MODEL=claude-sonnet-4-6
```
4. Start Ollama and pull embedding model:

View File

@@ -19,7 +19,31 @@ database:
# Default model (if user has no preference)
defaults:
model_provider: anthropic
model: claude-3-5-sonnet-20241022
model: claude-sonnet-4-6
# License tier model configuration
license_models:
# Free tier models
free:
default: claude-haiku-4-5-20251001
cost_optimized: claude-haiku-4-5-20251001
complex: claude-haiku-4-5-20251001
allowed_models:
- claude-haiku-4-5-20251001
# Pro tier models
pro:
default: claude-sonnet-4-6
cost_optimized: claude-haiku-4-5-20251001
complex: claude-sonnet-4-6
blocked_models:
- claude-opus-4-6
# Enterprise tier models
enterprise:
default: claude-sonnet-4-6
cost_optimized: claude-haiku-4-5-20251001
complex: claude-opus-4-6
# Kubernetes configuration
kubernetes:

View File

@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS user_licenses (
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}';
COMMENT ON COLUMN user_licenses.preferred_model IS 'Optional model preference: {"provider": "anthropic", "model": "claude-sonnet-4-6", "temperature": 0.7}';
CREATE INDEX idx_user_licenses_expires_at ON user_licenses(expires_at)
WHERE expires_at IS NOT NULL;

View File

@@ -110,6 +110,7 @@ export class TelegramHandler {
userId: authContext.userId,
sessionId: authContext.sessionId,
content: text,
attachments: [], // TODO: Add image support for Telegram
timestamp: new Date(),
};

View File

@@ -158,21 +158,25 @@ export class WebSocketHandler {
}),
};
// Create agent harness
const harness = new AgentHarness({
userId: authContext.userId,
sessionId: authContext.sessionId,
license: authContext.license,
providerConfig: this.config.providerConfig,
logger,
});
// Declare harness outside try block so it's available in catch
let harness: AgentHarness | undefined;
try {
// Initialize workspace and harness
// Initialize workspace first
await workspace.initialize();
workspace.setAdapter(wsAdapter);
this.workspaces.set(authContext.sessionId, workspace);
// Create agent harness with workspace manager
harness = new AgentHarness({
userId: authContext.userId,
sessionId: authContext.sessionId,
license: authContext.license,
providerConfig: this.config.providerConfig,
logger,
workspaceManager: workspace,
});
await harness.initialize();
this.harnesses.set(authContext.sessionId, harness);
@@ -220,8 +224,8 @@ export class WebSocketHandler {
logger.info({ type: payload.type, request_id: payload.request_id }, 'WebSocket message parsed');
// Route based on message type
if (payload.type === 'message') {
// Chat message - send to agent harness
if (payload.type === 'message' || payload.type === 'agent_user_message') {
// Chat message - send to agent harness with streaming
const inboundMessage: InboundMessage = {
messageId: randomUUID(),
userId: authContext.userId,
@@ -231,14 +235,41 @@ export class WebSocketHandler {
timestamp: new Date(),
};
const response = await harness.handleMessage(inboundMessage);
if (!harness) {
logger.error('Harness not initialized');
socket.send(JSON.stringify({ type: 'error', message: 'Session not ready' }));
return;
}
socket.send(
JSON.stringify({
type: 'message',
...response,
})
);
// Stream response chunks to client
try {
for await (const chunk of harness.streamMessage(inboundMessage)) {
socket.send(
JSON.stringify({
type: 'agent_chunk',
content: chunk,
done: false,
})
);
}
// Send final chunk with done flag
socket.send(
JSON.stringify({
type: 'agent_chunk',
content: '',
done: true,
})
);
} catch (error) {
logger.error({ error }, 'Error streaming response');
socket.send(
JSON.stringify({
type: 'error',
message: 'Failed to generate response',
})
);
}
} else if (payload.type === 'hello') {
// Workspace sync: hello message
logger.debug({ seqs: payload.seqs }, 'Handling workspace hello');
@@ -280,8 +311,10 @@ export class WebSocketHandler {
this.workspaces.delete(authContext.sessionId);
// Cleanup harness
await harness.cleanup();
this.harnesses.delete(authContext.sessionId);
if (harness) {
await harness.cleanup();
this.harnesses.delete(authContext.sessionId);
}
});
socket.on('error', (error: any) => {
@@ -292,7 +325,9 @@ export class WebSocketHandler {
socket.close(1011, 'Internal server error');
await workspace.shutdown();
this.workspaces.delete(authContext.sessionId);
await harness.cleanup();
if (harness) {
await harness.cleanup();
}
}
}

View File

@@ -66,6 +66,7 @@ export class ZMQRelayClient {
relayNotificationEndpoint: config.relayNotificationEndpoint,
clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`,
requestTimeout: config.requestTimeout || 30000,
onMetadataUpdate: config.onMetadataUpdate || (async () => {}),
};
this.logger = logger;
this.notificationTopic = `RESPONSE:${this.config.clientId}`;

View File

@@ -101,9 +101,15 @@ See individual workflow READMEs for details.
YAML-based configuration:
- `models.yaml`: LLM providers, routing, rate limits
- `subagent-routing.yaml`: When to use which subagent
Model configuration is centralized in the main gateway config (`/config/config.yaml`), including:
- Default models and providers
- License tier model assignments (free, pro, enterprise)
- Model routing rules (complexity, cost-optimized)
Resource limits (token limits, rate limits) are stored in the database per user.
## User Context
Enhanced session context with channel awareness for multi-channel support:

View File

@@ -8,6 +8,7 @@ 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';
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
export interface AgentHarnessConfig {
userId: string;
@@ -15,6 +16,7 @@ export interface AgentHarnessConfig {
license: UserLicense;
providerConfig: ProviderConfig;
logger: FastifyBaseLogger;
workspaceManager?: WorkspaceManager;
}
/**
@@ -33,9 +35,13 @@ export class AgentHarness {
private modelFactory: LLMProviderFactory;
private modelRouter: ModelRouter;
private mcpClient: MCPClientConnector;
private workspaceManager?: WorkspaceManager;
private lastWorkspaceSeq: number = 0;
private isFirstMessage: boolean = true;
constructor(config: AgentHarnessConfig) {
this.config = config;
this.workspaceManager = config.workspaceManager;
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
@@ -102,18 +108,14 @@ export class AgentHarness {
// 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(),
});
// TODO: Save messages to Iceberg conversation table instead of MCP
// Should batch-insert periodically or on session end to avoid many small Parquet files
// await icebergConversationStore.appendMessages([...]);
// Mark first message as processed
if (this.isFirstMessage) {
this.isFirstMessage = false;
}
return {
messageId: `msg_${Date.now()}`,
@@ -157,17 +159,17 @@ export class AgentHarness {
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(),
});
// TODO: Save messages to Iceberg conversation table instead of MCP
// Should batch-insert periodically or on session end to avoid many small Parquet files
// await icebergConversationStore.appendMessages([
// { role: 'user', content: message.content, timestamp: message.timestamp },
// { role: 'assistant', content: fullResponse, timestamp: new Date() }
// ]);
// Mark first message as processed
if (this.isFirstMessage) {
this.isFirstMessage = false;
}
} catch (error) {
this.config.logger.error({ error }, 'Error streaming message');
throw error;
@@ -224,6 +226,15 @@ export class AgentHarness {
});
}
// Add workspace delta (for subsequent turns)
const workspaceDelta = this.buildWorkspaceDelta();
if (workspaceDelta) {
messages.push({
role: 'user',
content: workspaceDelta,
});
}
// Add current user message
messages.push({
role: 'user',
@@ -273,9 +284,18 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
prompt += `\n\n# User Profile\n${userProfile.text}`;
}
// Add workspace context
// Add workspace context from MCP resource (if available)
if (workspaceState?.text) {
prompt += `\n\n# Current Workspace\n${workspaceState.text}`;
prompt += `\n\n# Current Workspace (from MCP)\n${workspaceState.text}`;
}
// Add full workspace state from WorkspaceManager (first message only)
if (this.isFirstMessage && this.workspaceManager) {
const workspaceJSON = this.workspaceManager.serializeState();
prompt += `\n\n# Workspace State (JSON)\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
// Record current workspace sequence for delta tracking
this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq();
}
// Add user's custom instructions (highest priority)
@@ -286,6 +306,30 @@ Available features: ${JSON.stringify(this.config.license.features, null, 2)}`;
return prompt;
}
/**
* Build workspace delta message for subsequent turns.
* Returns null if no changes since last message.
*/
private buildWorkspaceDelta(): string | null {
if (!this.workspaceManager || this.isFirstMessage) {
return null;
}
const changes = this.workspaceManager.getChangesSince(this.lastWorkspaceSeq);
if (Object.keys(changes).length === 0) {
return null;
}
// Format changes as JSON
const deltaJSON = JSON.stringify(changes, null, 2);
// Update sequence marker
this.lastWorkspaceSeq = this.workspaceManager.getCurrentSeq();
return `[Workspace Changes Since Last Turn]\n\`\`\`json\n${deltaJSON}\n\`\`\``;
}
/**

View File

@@ -1,110 +0,0 @@
# Default LLM Model Configuration
# Default model for general agent tasks
default:
provider: anthropic
model: claude-3-5-sonnet-20241022
temperature: 0.7
maxTokens: 4096
# Model overrides for specific use cases
models:
# Fast model for simple tasks (routing, classification)
fast:
provider: anthropic
model: claude-3-haiku-20240307
temperature: 0.3
maxTokens: 1024
# Reasoning model for complex analysis
reasoning:
provider: anthropic
model: claude-3-5-sonnet-20241022
temperature: 0.5
maxTokens: 8192
# Precise model for code generation/review
code:
provider: anthropic
model: claude-3-5-sonnet-20241022
temperature: 0.2
maxTokens: 8192
# Creative model for strategy brainstorming
creative:
provider: anthropic
model: claude-3-5-sonnet-20241022
temperature: 0.9
maxTokens: 4096
# Embedding model configuration
embeddings:
provider: openai
model: text-embedding-3-small
dimensions: 1536
# Model routing rules (complexity-based)
routing:
# Simple queries → fast model
simple:
keywords:
- "what is"
- "define"
- "list"
- "show me"
maxTokens: 100
model: fast
# Code-related → code model
code:
keywords:
- "code"
- "function"
- "implement"
- "debug"
- "review"
model: code
# Analysis tasks → reasoning model
analysis:
keywords:
- "analyze"
- "compare"
- "evaluate"
- "assess"
model: reasoning
# Everything else → default
default:
model: default
# Cost optimization settings
costControl:
# Cache system prompts (Anthropic prompt caching)
cacheSystemPrompts: true
# Token limits per license type
tokenLimits:
free:
maxTokensPerMessage: 2048
maxTokensPerDay: 50000
pro:
maxTokensPerMessage: 8192
maxTokensPerDay: 500000
enterprise:
maxTokensPerMessage: 16384
maxTokensPerDay: -1 # unlimited
# Rate limiting
rateLimits:
# Requests per minute by license
requestsPerMinute:
free: 10
pro: 60
enterprise: 120
# Concurrent requests
concurrentRequests:
free: 1
pro: 3
enterprise: 10

View File

@@ -1,4 +1,5 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import type { FastifyBaseLogger } from 'fastify';
@@ -11,7 +12,7 @@ export interface MCPClientConfig {
/**
* MCP client connector for user's container
* Manages connection to user-specific MCP server
* Manages connection to user-specific MCP server via SSE transport
*/
export class MCPClientConnector {
private client: Client | null = null;
@@ -23,8 +24,7 @@ export class MCPClientConnector {
}
/**
* Connect to user's MCP server
* TODO: Implement HTTP/SSE transport instead of stdio for container communication
* Connect to user's MCP server via SSE transport
*/
async connect(): Promise<void> {
if (this.connected) {
@@ -34,7 +34,7 @@ export class MCPClientConnector {
try {
this.config.logger.info(
{ userId: this.config.userId, url: this.config.mcpServerUrl },
'Connecting to user MCP server'
'Connecting to user MCP server via SSE'
);
this.client = new Client(
@@ -46,24 +46,18 @@ export class MCPClientConnector {
capabilities: {
sampling: {},
},
} as any
}
);
// 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'
// Create SSE transport for HTTP connection to user container
const transport = new SSEClientTransport(
new URL(`${this.config.mcpServerUrl}/sse`)
);
await this.client.connect(transport);
this.connected = true;
this.config.logger.info('Connected to user MCP server');
this.config.logger.info('Connected to user MCP server via SSE');
} catch (error) {
this.config.logger.error(
{ error, userId: this.config.userId },
@@ -84,12 +78,8 @@ export class MCPClientConnector {
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' };
const result = await this.client.callTool({ name, arguments: args });
return result;
} catch (error) {
this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
throw error;
@@ -98,27 +88,35 @@ export class MCPClientConnector {
/**
* List available tools from user's MCP server
* Filters to only return tools marked as agent_accessible
*/
async listTools(): Promise<Array<{ name: string; description?: string }>> {
async listTools(): Promise<Array<{ name: string; description?: string; inputSchema?: any }>> {
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;
const response = await this.client.listTools();
// 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' },
];
// Filter tools to only include agent-accessible ones
const tools = response.tools
.filter((tool: any) => {
// Check if tool has agent_accessible annotation
const annotations = tool.annotations || {};
return annotations.agent_accessible === true;
})
.map((tool: any) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
this.config.logger.debug(
{ totalTools: response.tools.length, agentAccessibleTools: tools.length },
'Listed MCP tools with filtering'
);
return tools;
} catch (error) {
this.config.logger.error({ error }, 'Failed to list MCP tools');
throw error;
@@ -127,6 +125,7 @@ export class MCPClientConnector {
/**
* List available resources from user's MCP server
* Filters to only return resources marked as agent_accessible
*/
async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
if (!this.client || !this.connected) {
@@ -134,37 +133,28 @@ export class MCPClientConnector {
}
try {
// TODO: Implement when MCP client is connected
// const resources = await this.client.listResources();
// return resources;
const response = await this.client.listResources();
// 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',
},
];
// Filter resources to only include agent-accessible ones
const resources = response.resources
.filter((resource: any) => {
// Check if resource has agent_accessible annotation
const annotations = resource.annotations || {};
return annotations.agent_accessible === true;
})
.map((resource: any) => ({
uri: resource.uri,
name: resource.name,
description: resource.description,
mimeType: resource.mimeType,
}));
this.config.logger.debug(
{ totalResources: response.resources.length, agentAccessibleResources: resources.length },
'Listed MCP resources with filtering'
);
return resources;
} catch (error) {
this.config.logger.error({ error }, 'Failed to list MCP resources');
throw error;
@@ -182,55 +172,18 @@ export class MCPClientConnector {
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;
const response = await this.client.readResource({ uri });
// 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]
// Extract the first content item (MCP returns array of contents)
const content = response.contents[0];
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: '' };
// Handle union type: content is either TextContent or BlobContent
return {
uri: content.uri,
mimeType: content.mimeType,
text: 'text' in content ? content.text : undefined,
blob: 'blob' in content ? content.blob : undefined,
};
} catch (error) {
this.config.logger.error({ error, uri }, 'MCP resource read failed');
throw error;

View File

@@ -42,7 +42,7 @@ name: my-subagent
description: What it does
# Model override (optional)
model: claude-3-5-sonnet-20241022
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 4096

View File

@@ -4,7 +4,7 @@ name: code-reviewer
description: Reviews trading strategy code for bugs, performance issues, and best practices
# Model configuration (optional override)
model: claude-3-5-sonnet-20241022
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 4096

View File

@@ -334,7 +334,7 @@ timeout: 60000
maxRetries: 3
requiresApproval: false
model: claude-3-5-sonnet-20241022
model: claude-sonnet-4-6
```
### 6. Add Factory Function

View File

@@ -15,5 +15,5 @@ maxValidationRetries: 3 # Max times to retry fixing errors
minBacktestScore: 0.5 # Minimum Sharpe ratio to pass
# Model override (optional)
model: claude-3-5-sonnet-20241022
model: claude-sonnet-4-6
temperature: 0.3

View File

@@ -15,5 +15,5 @@ maxPositionPercent: 0.05 # 5% of portfolio max
minRiskRewardRatio: 2.0 # Minimum 2:1 risk/reward
# Model override (optional)
model: claude-3-5-sonnet-20241022
model: claude-sonnet-4-6
temperature: 0.2

View File

@@ -21,6 +21,7 @@ export interface DeploymentSpec {
agentImage: string;
sidecarImage: string;
storageClass: string;
imagePullPolicy?: string;
}
/**
@@ -132,7 +133,8 @@ export class KubernetesClient {
.replace(/\{\{pvcName\}\}/g, pvcName)
.replace(/\{\{agentImage\}\}/g, spec.agentImage)
.replace(/\{\{sidecarImage\}\}/g, spec.sidecarImage)
.replace(/\{\{storageClass\}\}/g, spec.storageClass);
.replace(/\{\{storageClass\}\}/g, spec.storageClass)
.replace(/\{\{imagePullPolicy\}\}/g, spec.imagePullPolicy || 'Always');
// Parse YAML documents (deployment, pvc, service)
const documents = yaml.loadAll(rendered) as any[];

View File

@@ -7,6 +7,7 @@ export interface ContainerManagerConfig {
agentImage: string;
sidecarImage: string;
storageClass: string;
imagePullPolicy?: string;
namespace: string;
logger: FastifyBaseLogger;
}
@@ -82,6 +83,7 @@ export class ContainerManager {
agentImage: this.config.agentImage,
sidecarImage: this.config.sidecarImage,
storageClass: this.config.storageClass,
imagePullPolicy: this.config.imagePullPolicy,
};
await this.config.k8sClient.createAgentDeployment(spec);

View File

@@ -40,7 +40,7 @@ spec:
containers:
- name: agent
image: {{agentImage}}
imagePullPolicy: Always
imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
@@ -72,6 +72,8 @@ spec:
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
- name: ZMQ_GATEWAY_ENDPOINT
value: "tcp://gateway.default.svc.cluster.local:5571"
ports:
- name: mcp
@@ -102,14 +104,14 @@ spec:
readinessProbe:
httpGet:
path: /ready
path: /health
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
imagePullPolicy: Always
imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false

View File

@@ -39,7 +39,7 @@ spec:
containers:
- name: agent
image: {{agentImage}}
imagePullPolicy: Always
imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
@@ -71,7 +71,9 @@ spec:
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
- name: ZMQ_GATEWAY_ENDPOINT
value: "tcp://gateway.default.svc.cluster.local:5571"
ports:
- name: mcp
containerPort: 3000
@@ -101,14 +103,14 @@ spec:
readinessProbe:
httpGet:
path: /ready
path: /health
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
imagePullPolicy: Always
imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false

View File

@@ -39,7 +39,7 @@ spec:
containers:
- name: agent
image: {{agentImage}}
imagePullPolicy: Always
imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false
@@ -71,6 +71,8 @@ spec:
value: "3000"
- name: ZMQ_CONTROL_PORT
value: "5555"
- name: ZMQ_GATEWAY_ENDPOINT
value: "tcp://gateway.default.svc.cluster.local:5571"
ports:
- name: mcp
@@ -101,14 +103,14 @@ spec:
readinessProbe:
httpGet:
path: /ready
path: /health
port: mcp
initialDelaySeconds: 5
periodSeconds: 10
- name: lifecycle-sidecar
image: {{sidecarImage}}
imagePullPolicy: Always
imagePullPolicy: {{imagePullPolicy}}
securityContext:
allowPrivilegeEscalation: false

View File

@@ -19,11 +19,33 @@ export interface ModelConfig {
maxTokens?: number;
}
/**
* License tier model configuration
*/
export interface LicenseTierModels {
default: string;
cost_optimized: string;
complex: string;
allowed_models?: string[];
blocked_models?: string[];
}
/**
* License models configuration
*/
export interface LicenseModelsConfig {
free: LicenseTierModels;
pro: LicenseTierModels;
enterprise: LicenseTierModels;
}
/**
* Provider configuration with API keys
*/
export interface ProviderConfig {
anthropicApiKey?: string;
defaultModel?: ModelConfig;
licenseModels?: LicenseModelsConfig;
}
/**
@@ -77,15 +99,26 @@ export class LLMProviderFactory {
* Get default model based on environment
*/
getDefaultModel(): ModelConfig {
if (this.config.defaultModel) {
return this.config.defaultModel;
}
if (!this.config.anthropicApiKey) {
throw new Error('Anthropic API key not configured');
}
return {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-5-sonnet-20241022',
model: 'claude-sonnet-4-6',
};
}
/**
* Get license models configuration
*/
getLicenseModelsConfig(): LicenseModelsConfig | undefined {
return this.config.licenseModels;
}
}
/**
@@ -94,14 +127,14 @@ export class LLMProviderFactory {
export const MODELS = {
CLAUDE_SONNET: {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-5-sonnet-20241022',
model: 'claude-sonnet-4-6',
},
CLAUDE_HAIKU: {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-5-haiku-20241022',
model: 'claude-haiku-4-5-20251001',
},
CLAUDE_OPUS: {
provider: LLMProvider.ANTHROPIC,
model: 'claude-3-opus-20240229',
model: 'claude-opus-4-6',
},
} as const satisfies Record<string, ModelConfig>;

View File

@@ -1,6 +1,6 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
import { LLMProviderFactory, type ModelConfig, LLMProvider } from './provider.js';
import { LLMProviderFactory, type ModelConfig, LLMProvider, type LicenseModelsConfig } from './provider.js';
import type { UserLicense } from '../types/user.js';
/**
@@ -25,11 +25,13 @@ export class ModelRouter {
private factory: LLMProviderFactory;
private logger: FastifyBaseLogger;
private defaultModel: ModelConfig;
private licenseModels?: LicenseModelsConfig;
constructor(factory: LLMProviderFactory, logger: FastifyBaseLogger) {
this.factory = factory;
this.logger = logger;
this.defaultModel = factory.getDefaultModel();
this.licenseModels = factory.getLicenseModelsConfig();
}
/**
@@ -97,37 +99,53 @@ export class ModelRouter {
private routeByComplexity(message: string, license: UserLicense): ModelConfig {
const isComplex = this.isComplexQuery(message);
// Use configuration if available
if (this.licenseModels) {
const tierConfig = this.licenseModels[license.licenseType];
if (tierConfig) {
const model = isComplex ? tierConfig.complex : tierConfig.default;
return { provider: this.defaultModel.provider as LLMProvider, model };
}
}
// Fallback to hardcoded defaults
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' };
? { provider: LLMProvider.ANTHROPIC, model: 'claude-opus-4-6' }
: { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
}
if (license.licenseType === 'pro') {
// Pro users get good models
return isComplex
? { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' }
: { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
? { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' }
: { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
}
// Free users get efficient models
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
}
/**
* Route based on license tier
*/
private routeByLicenseTier(license: UserLicense): ModelConfig {
// Use configuration if available
if (this.licenseModels) {
const tierConfig = this.licenseModels[license.licenseType];
if (tierConfig) {
return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.default };
}
}
// Fallback to hardcoded defaults
switch (license.licenseType) {
case 'enterprise':
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
case 'pro':
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-sonnet-20241022' };
return { provider: LLMProvider.ANTHROPIC, model: 'claude-sonnet-4-6' };
case 'free':
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
default:
return this.defaultModel;
@@ -137,24 +155,50 @@ export class ModelRouter {
/**
* Route to cheapest available model
*/
private routeByCost(_license: UserLicense): ModelConfig {
// All tiers: use Haiku for cost efficiency
return { provider: LLMProvider.ANTHROPIC, model: 'claude-3-5-haiku-20241022' };
private routeByCost(license: UserLicense): ModelConfig {
// Use configuration if available
if (this.licenseModels) {
const tierConfig = this.licenseModels[license.licenseType];
if (tierConfig) {
return { provider: this.defaultModel.provider as LLMProvider, model: tierConfig.cost_optimized };
}
}
// Fallback: use Haiku for cost efficiency
return { provider: LLMProvider.ANTHROPIC, model: 'claude-haiku-4-5-20251001' };
}
/**
* Check if model is allowed for user's license
*/
private isModelAllowed(model: ModelConfig, license: UserLicense): boolean {
// Free tier: only Haiku
// Use configuration if available
if (this.licenseModels) {
const tierConfig = this.licenseModels[license.licenseType];
if (tierConfig) {
// Check allowed_models list if defined
if (tierConfig.allowed_models && tierConfig.allowed_models.length > 0) {
return tierConfig.allowed_models.includes(model.model);
}
// Check blocked_models list if defined
if (tierConfig.blocked_models && tierConfig.blocked_models.length > 0) {
return !tierConfig.blocked_models.includes(model.model);
}
// No restrictions if neither list is defined
return true;
}
}
// Fallback to hardcoded defaults
if (license.licenseType === 'free') {
const allowedModels = ['claude-3-5-haiku-20241022'];
const allowedModels = ['claude-haiku-4-5-20251001'];
return allowedModels.includes(model.model);
}
// Pro: all except Opus
if (license.licenseType === 'pro') {
const blockedModels = ['claude-3-opus-20240229'];
const blockedModels = ['claude-opus-4-6'];
return !blockedModels.includes(model.model);
}

View File

@@ -85,12 +85,35 @@ function loadConfig() {
// Authentication configuration
authSecret: secretsData.auth?.secret || process.env.AUTH_SECRET || 'change-me-in-production',
// LLM provider API keys
// LLM provider API keys and model configuration
providerConfig: {
anthropicApiKey: secretsData.llm_providers?.anthropic_api_key || process.env.ANTHROPIC_API_KEY,
openaiApiKey: secretsData.llm_providers?.openai_api_key || process.env.OPENAI_API_KEY,
googleApiKey: secretsData.llm_providers?.google_api_key || process.env.GOOGLE_API_KEY,
openrouterApiKey: secretsData.llm_providers?.openrouter_api_key || process.env.OPENROUTER_API_KEY,
defaultModel: {
provider: configData.defaults?.model_provider || 'anthropic',
model: configData.defaults?.model || 'claude-sonnet-4-6',
},
licenseModels: {
free: {
default: configData.license_models?.free?.default || 'claude-haiku-4-5-20251001',
cost_optimized: configData.license_models?.free?.cost_optimized || 'claude-haiku-4-5-20251001',
complex: configData.license_models?.free?.complex || 'claude-haiku-4-5-20251001',
allowed_models: configData.license_models?.free?.allowed_models || ['claude-haiku-4-5-20251001'],
},
pro: {
default: configData.license_models?.pro?.default || 'claude-sonnet-4-6',
cost_optimized: configData.license_models?.pro?.cost_optimized || 'claude-haiku-4-5-20251001',
complex: configData.license_models?.pro?.complex || 'claude-sonnet-4-6',
blocked_models: configData.license_models?.pro?.blocked_models || ['claude-opus-4-6'],
},
enterprise: {
default: configData.license_models?.enterprise?.default || 'claude-sonnet-4-6',
cost_optimized: configData.license_models?.enterprise?.cost_optimized || 'claude-haiku-4-5-20251001',
complex: configData.license_models?.enterprise?.complex || 'claude-opus-4-6',
},
},
},
telegramBotToken: secretsData.telegram?.bot_token || process.env.TELEGRAM_BOT_TOKEN || '',
@@ -148,6 +171,7 @@ function loadConfig() {
agentImage: configData.kubernetes?.agent_image || process.env.AGENT_IMAGE || 'ghcr.io/dexorder/agent:latest',
sidecarImage: configData.kubernetes?.sidecar_image || process.env.SIDECAR_IMAGE || 'ghcr.io/dexorder/lifecycle-sidecar:latest',
storageClass: configData.kubernetes?.storage_class || process.env.AGENT_STORAGE_CLASS || 'standard',
imagePullPolicy: configData.kubernetes?.image_pull_policy || process.env.IMAGE_PULL_POLICY || 'Always',
},
};
}
@@ -265,6 +289,7 @@ const containerManager = new ContainerManager({
agentImage: config.kubernetes.agentImage,
sidecarImage: config.kubernetes.sidecarImage,
storageClass: config.kubernetes.storageClass,
imagePullPolicy: config.kubernetes.imagePullPolicy,
namespace: config.kubernetes.namespace,
logger: app.log,
});

View File

@@ -66,7 +66,8 @@ export type {
PathTriggerHandler,
PathTriggerContext,
ChartState,
ChartStore,
ShapesStore,
IndicatorsStore,
ChannelState,
ChannelInfo,
WorkspaceStores,

View File

@@ -404,4 +404,28 @@ export class SyncRegistry {
}
return states;
}
/**
* Get patches since a given sequence number for a store.
* Returns empty array if no patches available since that sequence.
*/
getPatchesSince(storeName: string, sinceSeq: number): JsonPatchOp[] | null {
const entry = this.entries.get(storeName);
if (!entry) {
return null;
}
const catchupPatches = entry.getCatchupPatches(sinceSeq);
if (catchupPatches === null) {
return null; // History not available
}
// Flatten all patches into a single array
const allPatches: JsonPatchOp[] = [];
for (const { patch } of catchupPatches) {
allPatches.push(...patch);
}
return allPatches;
}
}

View File

@@ -84,17 +84,19 @@ export const DEFAULT_STORES: StoreConfig[] = [
symbol: 'BINANCE:BTC/USDT',
start_time: null,
end_time: null,
interval: '15',
period: '15',
selected_shapes: [],
}),
},
{
name: 'chartStore',
name: 'shapes',
persistent: true,
initialState: () => ({
drawings: {},
templates: {},
}),
initialState: () => ({}),
},
{
name: 'indicators',
persistent: true,
initialState: () => ({}),
},
{
name: 'channelState',
@@ -198,22 +200,70 @@ export interface PathTrigger {
*/
export interface ChartState {
symbol: string;
start_time: number | null;
end_time: number | null;
interval: string;
selected_shapes: string[];
start_time: number | null; // unix timestamp
end_time: number | null; // unix timestamp
period: string; // OHLC duration (e.g., '15' for 15 minutes)
selected_shapes: string[]; // list of shape ID's
}
/**
* Chart store - persistent, stores drawings and templates.
* Control point for shapes (drawings/annotations).
*/
export interface ChartStore {
drawings: Record<string, unknown>;
templates: Record<string, unknown>;
export interface ControlPoint {
time: number; // unix timestamp
price: number;
channel?: string;
}
/**
* Shape (drawing/annotation) on TradingView chart.
*/
export interface Shape {
id: string;
type: string;
points: ControlPoint[];
color?: string;
line_width?: number;
line_style?: string;
properties?: Record<string, any>;
symbol?: string;
created_at?: number;
modified_at?: number;
original_id?: string;
}
/**
* Shapes store - persistent, stores TradingView drawings and annotations.
*/
export type ShapesStore = Record<string, Shape>;
/**
* Indicator instance on TradingView chart.
*/
export interface IndicatorInstance {
id: string;
talib_name: string;
instance_name: string;
parameters: Record<string, any>;
tv_study_id?: string;
tv_indicator_name?: string;
tv_inputs?: Record<string, any>;
visible: boolean;
pane: string;
symbol?: string;
created_at?: number;
modified_at?: number;
original_id?: string;
}
/**
* Indicators store - persistent, stores TradingView studies/indicators.
*/
export type IndicatorsStore = Record<string, IndicatorInstance>;
/**
* Channel state - transient, tracks connected channels.
* NOTE: This store is gateway-only and should NOT be synced to web clients.
*/
export interface ChannelState {
connected: Record<string, ChannelInfo>;
@@ -233,7 +283,8 @@ export interface ChannelInfo {
*/
export interface WorkspaceStores {
chartState: ChartState;
chartStore: ChartStore;
shapes: ShapesStore;
indicators: IndicatorsStore;
channelState: ChannelState;
[key: string]: unknown;
}

View File

@@ -271,6 +271,53 @@ export class WorkspaceManager {
return this.registry.getStoreNames();
}
/**
* Serialize entire workspace state as JSON.
*/
serializeState(): string {
const state: Record<string, unknown> = {};
for (const storeConfig of this.stores) {
const storeState = this.registry.getState(storeConfig.name);
if (storeState !== undefined) {
state[storeConfig.name] = storeState;
}
}
return JSON.stringify(state, null, 2);
}
/**
* Get the highest sequence number across all stores.
*/
getCurrentSeq(): number {
let maxSeq = 0;
for (const storeName of this.registry.getStoreNames()) {
const seq = this.registry.getSeq(storeName);
if (seq > maxSeq) {
maxSeq = seq;
}
}
return maxSeq;
}
/**
* Get all patches since a given sequence number across all stores.
* Returns patches grouped by store name.
*/
getChangesSince(sinceSeq: number): Record<string, JsonPatchOp[]> {
const changes: Record<string, JsonPatchOp[]> = {};
for (const storeConfig of this.stores) {
const patches = this.registry.getPatchesSince(storeConfig.name, sinceSeq);
if (patches && patches.length > 0) {
changes[storeConfig.name] = patches;
}
}
return changes;
}
// ===========================================================================
// Path Triggers
// ===========================================================================