morpheus-cli 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http/api.js CHANGED
@@ -213,6 +213,82 @@ export function createApiRouter(oracle) {
213
213
  res.status(500).json({ error: error.message });
214
214
  }
215
215
  });
216
+ // --- Model Pricing ---
217
+ const ModelPricingSchema = z.object({
218
+ provider: z.string().min(1),
219
+ model: z.string().min(1),
220
+ input_price_per_1m: z.number().nonnegative(),
221
+ output_price_per_1m: z.number().nonnegative()
222
+ });
223
+ router.get('/model-pricing', (req, res) => {
224
+ try {
225
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
226
+ const entries = h.listModelPricing();
227
+ h.close();
228
+ res.json(entries);
229
+ }
230
+ catch (error) {
231
+ res.status(500).json({ error: error.message });
232
+ }
233
+ });
234
+ router.post('/model-pricing', (req, res) => {
235
+ const parsed = ModelPricingSchema.safeParse(req.body);
236
+ if (!parsed.success) {
237
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
238
+ }
239
+ try {
240
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
241
+ h.upsertModelPricing(parsed.data);
242
+ h.close();
243
+ res.json({ success: true });
244
+ }
245
+ catch (error) {
246
+ res.status(500).json({ error: error.message });
247
+ }
248
+ });
249
+ router.put('/model-pricing/:provider/:model', (req, res) => {
250
+ const { provider, model } = req.params;
251
+ const partial = z.object({
252
+ input_price_per_1m: z.number().nonnegative().optional(),
253
+ output_price_per_1m: z.number().nonnegative().optional()
254
+ }).safeParse(req.body);
255
+ if (!partial.success) {
256
+ return res.status(400).json({ error: 'Invalid payload', details: partial.error.issues });
257
+ }
258
+ try {
259
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
260
+ const existing = h.listModelPricing().find(e => e.provider === provider && e.model === model);
261
+ if (!existing) {
262
+ h.close();
263
+ return res.status(404).json({ error: 'Pricing entry not found' });
264
+ }
265
+ h.upsertModelPricing({
266
+ provider,
267
+ model,
268
+ input_price_per_1m: partial.data.input_price_per_1m ?? existing.input_price_per_1m,
269
+ output_price_per_1m: partial.data.output_price_per_1m ?? existing.output_price_per_1m
270
+ });
271
+ h.close();
272
+ res.json({ success: true });
273
+ }
274
+ catch (error) {
275
+ res.status(500).json({ error: error.message });
276
+ }
277
+ });
278
+ router.delete('/model-pricing/:provider/:model', (req, res) => {
279
+ const { provider, model } = req.params;
280
+ try {
281
+ const h = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
282
+ const changes = h.deleteModelPricing(provider, model);
283
+ h.close();
284
+ if (changes === 0)
285
+ return res.status(404).json({ error: 'Pricing entry not found' });
286
+ res.json({ success: true });
287
+ }
288
+ catch (error) {
289
+ res.status(500).json({ error: error.message });
290
+ }
291
+ });
216
292
  // Calculate diff between two objects
217
293
  const getDiff = (obj1, obj2, prefix = '') => {
218
294
  const changes = [];
@@ -41,7 +41,7 @@ export class SatiMemoryMiddleware {
41
41
  return null;
42
42
  }
43
43
  }
44
- async afterAgent(generatedResponse, history) {
44
+ async afterAgent(generatedResponse, history, userSessionId) {
45
45
  try {
46
46
  await this.service.evaluateAndPersist([
47
47
  ...history.slice(-5).map(m => ({
@@ -49,7 +49,7 @@ export class SatiMemoryMiddleware {
49
49
  content: m.content.toString()
50
50
  })),
51
51
  { role: 'assistant', content: generatedResponse }
52
- ]);
52
+ ], userSessionId);
53
53
  }
54
54
  catch (error) {
55
55
  display.log(`Error in afterAgent: ${error}`, { source: 'Sati' });
@@ -50,7 +50,7 @@ export class SatiService {
50
50
  }))
51
51
  };
52
52
  }
53
- async evaluateAndPersist(conversation) {
53
+ async evaluateAndPersist(conversation, userSessionId) {
54
54
  try {
55
55
  const satiConfig = ConfigManager.getInstance().getSatiConfig();
56
56
  if (!satiConfig)
@@ -74,7 +74,8 @@ export class SatiService {
74
74
  new SystemMessage(SATI_EVALUATION_PROMPT),
75
75
  new HumanMessage(JSON.stringify(inputPayload, null, 2))
76
76
  ];
77
- const history = new SQLiteChatMessageHistory({ sessionId: 'sati-evaluation' });
77
+ const satiSessionId = userSessionId ? `sati-evaluation-${userSessionId}` : 'sati-evaluation';
78
+ const history = new SQLiteChatMessageHistory({ sessionId: satiSessionId });
78
79
  try {
79
80
  const inputMsg = new ToolMessage({
80
81
  content: JSON.stringify(inputPayload, null, 2),
@@ -14,6 +14,9 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
14
14
  sessionId;
15
15
  limit;
16
16
  titleSet = false; // cache: skip setSessionTitleIfNeeded after title is set
17
+ get currentSessionId() {
18
+ return this.sessionId;
19
+ }
17
20
  constructor(fields) {
18
21
  super();
19
22
  this.sessionId = fields.sessionId && fields.sessionId !== '' ? fields.sessionId : '';
@@ -108,9 +111,10 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
108
111
  total_tokens INTEGER,
109
112
  cache_read_tokens INTEGER,
110
113
  provider TEXT,
111
- model TEXT
114
+ model TEXT,
115
+ audio_duration_seconds REAL
112
116
  );
113
-
117
+
114
118
  CREATE INDEX IF NOT EXISTS idx_messages_session_id
115
119
  ON messages(session_id);
116
120
 
@@ -130,6 +134,33 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
130
134
  embedding_status TEXT CHECK (embedding_status IN ('none', 'pending', 'embedded', 'failed')) NOT NULL DEFAULT 'none'
131
135
  );
132
136
 
137
+ CREATE TABLE IF NOT EXISTS model_pricing (
138
+ provider TEXT NOT NULL,
139
+ model TEXT NOT NULL,
140
+ input_price_per_1m REAL NOT NULL DEFAULT 0,
141
+ output_price_per_1m REAL NOT NULL DEFAULT 0,
142
+ PRIMARY KEY (provider, model)
143
+ );
144
+
145
+ INSERT OR IGNORE INTO model_pricing (provider, model, input_price_per_1m, output_price_per_1m) VALUES
146
+ ('anthropic', 'claude-opus-4-6', 15.0, 75.0),
147
+ ('anthropic', 'claude-sonnet-4-5-20250929', 3.0, 15.0),
148
+ ('anthropic', 'claude-haiku-4-5-20251001', 0.8, 4.0),
149
+ ('anthropic', 'claude-3-5-sonnet-20241022', 3.0, 15.0),
150
+ ('anthropic', 'claude-3-5-haiku-20241022', 0.8, 4.0),
151
+ ('anthropic', 'claude-3-opus-20240229', 15.0, 75.0),
152
+ ('openai', 'gpt-4o', 2.5, 10.0),
153
+ ('openai', 'gpt-4o-mini', 0.15, 0.6),
154
+ ('openai', 'gpt-4-turbo', 10.0, 30.0),
155
+ ('openai', 'gpt-3.5-turbo', 0.5, 1.5),
156
+ ('openai', 'o1', 15.0, 60.0),
157
+ ('openai', 'o1-mini', 3.0, 12.0),
158
+ ('google', 'gemini-2.5-flash', 0.15, 0.6),
159
+ ('google', 'gemini-2.5-flash-lite', 0.075, 0.3),
160
+ ('google', 'gemini-2.0-flash', 0.1, 0.4),
161
+ ('google', 'gemini-1.5-pro', 1.25, 5.0),
162
+ ('google', 'gemini-1.5-flash', 0.075, 0.3);
163
+
133
164
  `);
134
165
  this.migrateTable();
135
166
  }
@@ -151,13 +182,15 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
151
182
  'total_tokens',
152
183
  'cache_read_tokens',
153
184
  'provider',
154
- 'model'
185
+ 'model',
186
+ 'audio_duration_seconds'
155
187
  ];
156
188
  const integerColumns = new Set(['input_tokens', 'output_tokens', 'total_tokens', 'cache_read_tokens']);
189
+ const realColumns = new Set(['audio_duration_seconds']);
157
190
  for (const col of newColumns) {
158
191
  if (!columns.has(col)) {
159
192
  try {
160
- const type = integerColumns.has(col) ? 'INTEGER' : 'TEXT';
193
+ const type = integerColumns.has(col) ? 'INTEGER' : realColumns.has(col) ? 'REAL' : 'TEXT';
161
194
  this.db.exec(`ALTER TABLE messages ADD COLUMN ${col} ${type}`);
162
195
  }
163
196
  catch (e) {
@@ -296,6 +329,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
296
329
  // Extract provider metadata
297
330
  const provider = anyMsg.provider_metadata?.provider ?? null;
298
331
  const model = anyMsg.provider_metadata?.model ?? null;
332
+ const audioDurationSeconds = usage?.audio_duration_seconds ?? null;
299
333
  // Handle special content serialization for Tools
300
334
  let finalContent = "";
301
335
  if (type === 'ai' && (message.tool_calls?.length ?? 0) > 0) {
@@ -318,8 +352,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
318
352
  ? message.content
319
353
  : JSON.stringify(message.content);
320
354
  }
321
- const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
322
- stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model);
355
+ const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model, audio_duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
356
+ stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model, audioDurationSeconds);
323
357
  // Verificar se a sessão tem título e definir automaticamente se necessário
324
358
  await this.setSessionTitleIfNeeded();
325
359
  }
@@ -346,7 +380,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
346
380
  async addMessages(messages) {
347
381
  if (messages.length === 0)
348
382
  return;
349
- const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
383
+ const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model, audio_duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
350
384
  const insertAll = this.db.transaction((msgs) => {
351
385
  for (const message of msgs) {
352
386
  let type;
@@ -373,7 +407,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
373
407
  else {
374
408
  finalContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
375
409
  }
376
- stmt.run(this.sessionId, type, finalContent, Date.now(), usage?.input_tokens ?? null, usage?.output_tokens ?? null, usage?.total_tokens ?? null, usage?.input_token_details?.cache_read ?? usage?.cache_read_tokens ?? null, anyMsg.provider_metadata?.provider ?? null, anyMsg.provider_metadata?.model ?? null);
410
+ stmt.run(this.sessionId, type, finalContent, Date.now(), usage?.input_tokens ?? null, usage?.output_tokens ?? null, usage?.total_tokens ?? null, usage?.input_token_details?.cache_read ?? usage?.cache_read_tokens ?? null, anyMsg.provider_metadata?.provider ?? null, anyMsg.provider_metadata?.model ?? null, usage?.audio_duration_seconds ?? null);
377
411
  }
378
412
  });
379
413
  try {
@@ -440,9 +474,18 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
440
474
  try {
441
475
  const stmt = this.db.prepare("SELECT SUM(input_tokens) as totalInput, SUM(output_tokens) as totalOutput FROM messages");
442
476
  const row = stmt.get();
477
+ // Calculate total estimated cost by summing per-model costs
478
+ const costStmt = this.db.prepare(`SELECT
479
+ SUM((COALESCE(m.input_tokens, 0) / 1000000.0) * p.input_price_per_1m
480
+ + (COALESCE(m.output_tokens, 0) / 1000000.0) * p.output_price_per_1m) as totalCost
481
+ FROM messages m
482
+ INNER JOIN model_pricing p ON p.provider = m.provider AND p.model = COALESCE(m.model, 'unknown')
483
+ WHERE m.provider IS NOT NULL`);
484
+ const costRow = costStmt.get();
443
485
  return {
444
486
  totalInputTokens: row.totalInput || 0,
445
- totalOutputTokens: row.totalOutput || 0
487
+ totalOutputTokens: row.totalOutput || 0,
488
+ totalEstimatedCostUsd: costRow.totalCost ?? null
446
489
  };
447
490
  }
448
491
  catch (error) {
@@ -474,31 +517,58 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
474
517
  */
475
518
  async getUsageStatsByProviderAndModel() {
476
519
  try {
477
- const stmt = this.db.prepare(`SELECT
478
- provider,
479
- COALESCE(model, 'unknown') as model,
480
- SUM(input_tokens) as totalInputTokens,
481
- SUM(output_tokens) as totalOutputTokens,
482
- SUM(total_tokens) as totalTokens,
483
- COUNT(*) as messageCount
484
- FROM messages
485
- WHERE provider IS NOT NULL
486
- GROUP BY provider, COALESCE(model, 'unknown')
487
- ORDER BY provider, model`);
520
+ const stmt = this.db.prepare(`SELECT
521
+ m.provider,
522
+ COALESCE(m.model, 'unknown') as model,
523
+ SUM(m.input_tokens) as totalInputTokens,
524
+ SUM(m.output_tokens) as totalOutputTokens,
525
+ SUM(m.total_tokens) as totalTokens,
526
+ COUNT(*) as messageCount,
527
+ COALESCE(SUM(m.audio_duration_seconds), 0) as totalAudioSeconds,
528
+ p.input_price_per_1m,
529
+ p.output_price_per_1m
530
+ FROM messages m
531
+ LEFT JOIN model_pricing p ON p.provider = m.provider AND p.model = COALESCE(m.model, 'unknown')
532
+ WHERE m.provider IS NOT NULL
533
+ GROUP BY m.provider, COALESCE(m.model, 'unknown')
534
+ ORDER BY m.provider, m.model`);
488
535
  const rows = stmt.all();
489
- return rows.map((row) => ({
490
- provider: row.provider,
491
- model: row.model,
492
- totalInputTokens: row.totalInputTokens || 0,
493
- totalOutputTokens: row.totalOutputTokens || 0,
494
- totalTokens: row.totalTokens || 0,
495
- messageCount: row.messageCount || 0
496
- }));
536
+ return rows.map((row) => {
537
+ const inputTokens = row.totalInputTokens || 0;
538
+ const outputTokens = row.totalOutputTokens || 0;
539
+ let estimatedCostUsd = null;
540
+ if (row.input_price_per_1m != null && row.output_price_per_1m != null) {
541
+ estimatedCostUsd = (inputTokens / 1_000_000) * row.input_price_per_1m
542
+ + (outputTokens / 1_000_000) * row.output_price_per_1m;
543
+ }
544
+ return {
545
+ provider: row.provider,
546
+ model: row.model,
547
+ totalInputTokens: inputTokens,
548
+ totalOutputTokens: outputTokens,
549
+ totalTokens: row.totalTokens || 0,
550
+ messageCount: row.messageCount || 0,
551
+ totalAudioSeconds: row.totalAudioSeconds || 0,
552
+ estimatedCostUsd
553
+ };
554
+ });
497
555
  }
498
556
  catch (error) {
499
557
  throw new Error(`Failed to get grouped usage stats: ${error}`);
500
558
  }
501
559
  }
560
+ // --- Model Pricing CRUD ---
561
+ listModelPricing() {
562
+ const rows = this.db.prepare('SELECT provider, model, input_price_per_1m, output_price_per_1m FROM model_pricing ORDER BY provider, model').all();
563
+ return rows;
564
+ }
565
+ upsertModelPricing(entry) {
566
+ this.db.prepare('INSERT INTO model_pricing (provider, model, input_price_per_1m, output_price_per_1m) VALUES (?, ?, ?, ?) ON CONFLICT(provider, model) DO UPDATE SET input_price_per_1m = excluded.input_price_per_1m, output_price_per_1m = excluded.output_price_per_1m').run(entry.provider, entry.model, entry.input_price_per_1m, entry.output_price_per_1m);
567
+ }
568
+ deleteModelPricing(provider, model) {
569
+ const result = this.db.prepare('DELETE FROM model_pricing WHERE provider = ? AND model = ?').run(provider, model);
570
+ return result.changes;
571
+ }
502
572
  /**
503
573
  * Clears all messages for the current session from the database.
504
574
  */
@@ -229,7 +229,10 @@ You maintain intent until resolution.
229
229
  const lastMessage = response.messages[response.messages.length - 1];
230
230
  const responseContent = (typeof lastMessage.content === 'string') ? lastMessage.content : JSON.stringify(lastMessage.content);
231
231
  // Sati Middleware: Evaluation (Fire and forget)
232
- this.satiMiddleware.afterAgent(responseContent, [...previousMessages, userMessage])
232
+ const currentSessionId = (this.history instanceof SQLiteChatMessageHistory)
233
+ ? this.history.currentSessionId
234
+ : undefined;
235
+ this.satiMiddleware.afterAgent(responseContent, [...previousMessages, userMessage], currentSessionId)
233
236
  .catch((e) => this.display.log(`Sati memory evaluation failed: ${e.message}`, { source: 'Sati' }));
234
237
  return responseContent;
235
238
  }
@@ -2,6 +2,21 @@ import { GoogleGenAI } from '@google/genai';
2
2
  import OpenAI from 'openai';
3
3
  import { OpenRouter } from '@openrouter/sdk';
4
4
  import fs from 'fs';
5
+ /**
6
+ * Estimates audio duration in seconds based on file size and a typical bitrate.
7
+ * Uses 32 kbps (4000 bytes/sec) as a conservative baseline for compressed audio (OGG, MP3, etc.).
8
+ * This is an approximation — actual duration depends on encoding settings.
9
+ */
10
+ function estimateAudioDurationSeconds(filePath) {
11
+ try {
12
+ const stats = fs.statSync(filePath);
13
+ const bytesPerSecond = 4000; // ~32 kbps
14
+ return Math.round(stats.size / bytesPerSecond);
15
+ }
16
+ catch {
17
+ return 0;
18
+ }
19
+ }
5
20
  class GeminiTelephonist {
6
21
  model;
7
22
  constructor(model) {
@@ -41,7 +56,8 @@ class GeminiTelephonist {
41
56
  total_tokens: usage?.totalTokenCount ?? 0,
42
57
  input_token_details: {
43
58
  cache_read: usage?.cachedContentTokenCount ?? 0
44
- }
59
+ },
60
+ audio_duration_seconds: estimateAudioDurationSeconds(filePath)
45
61
  };
46
62
  return { text, usage: usageMetadata };
47
63
  }
@@ -75,6 +91,7 @@ class WhisperTelephonist {
75
91
  input_tokens: 0,
76
92
  output_tokens: 0,
77
93
  total_tokens: 0,
94
+ audio_duration_seconds: estimateAudioDurationSeconds(filePath)
78
95
  };
79
96
  return { text, usage: usageMetadata };
80
97
  }
@@ -131,6 +148,7 @@ class OpenRouterTelephonist {
131
148
  input_tokens: usage?.prompt_tokens ?? 0,
132
149
  output_tokens: usage?.completion_tokens ?? 0,
133
150
  total_tokens: usage?.total_tokens ?? 0,
151
+ audio_duration_seconds: estimateAudioDurationSeconds(filePath)
134
152
  };
135
153
  return { text, usage: usageMetadata };
136
154
  }