tamfis-code 0.2.3__py3-none-any.whl

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.
tamfis_code/mcp.py ADDED
@@ -0,0 +1,538 @@
1
+ """Model Context Protocol (MCP) integration for tools"""
2
+
3
+ from typing import Dict, Any, List, Optional, Callable
4
+ from dataclasses import dataclass, field
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import asyncio
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def _import_monorepo_attr(module_path: str, attr: str):
14
+ """Import `attr` from `module_path`, only if a monorepo (tamgpt6) checkout
15
+ happens to be co-located next to this standalone package -- e.g. a dev
16
+ running an editable install from inside tamgpt6/tamfis_code, or with
17
+ tamgpt6 as the current working directory.
18
+
19
+ Returns None (never raises) when the monorepo isn't present. tamfis-code
20
+ is an independent package with no hard dependency on tamgpt6's backend
21
+ modules; callers of this helper must treat None as "unavailable outside
22
+ a monorepo checkout" and report that clearly rather than crash.
23
+ """
24
+ try:
25
+ module = __import__(module_path, fromlist=[attr])
26
+ return getattr(module, attr)
27
+ except ModuleNotFoundError:
28
+ pass
29
+ top_level_package = module_path.split(".", 1)[0]
30
+ candidates = [Path.cwd(), *Path(__file__).resolve().parents]
31
+ for root in candidates:
32
+ if (root / top_level_package).is_dir():
33
+ root_text = str(root)
34
+ if root_text not in sys.path:
35
+ sys.path.insert(0, root_text)
36
+ try:
37
+ module = __import__(module_path, fromlist=[attr])
38
+ return getattr(module, attr)
39
+ except ModuleNotFoundError:
40
+ continue
41
+ return None
42
+
43
+
44
+ def _get_shared_mcp_bridge():
45
+ """Load the monorepo MCP bridge, if a tamgpt6 checkout is co-located.
46
+
47
+ Returns None otherwise -- see _import_monorepo_attr's docstring.
48
+ """
49
+ get_mcp_bridge = _import_monorepo_attr("tier_viii_infrastructure.mcp.orchestrator_bridge", "get_mcp_bridge")
50
+ return get_mcp_bridge() if get_mcp_bridge is not None else None
51
+
52
+
53
+ def get_browser_tool_class():
54
+ """Load BrowserTool, if a tamgpt6 checkout is co-located, else None."""
55
+ return _import_monorepo_attr("tier_iv_orchestration.tools.browser_tool", "BrowserTool")
56
+
57
+ @dataclass
58
+ class ToolDefinition:
59
+ """Definition of a tool for MCP"""
60
+ name: str
61
+ description: str
62
+ parameters: Dict[str, Any] # JSON Schema
63
+ handler: Optional[Callable] = None
64
+
65
+ class MCPServer:
66
+ """MCP server for tool execution"""
67
+
68
+ def __init__(self, *, workspace_root: Optional[str] = None, session_id: Optional[int] = None):
69
+ # workspace_root/session_id are optional so existing callers that
70
+ # construct MCPServer() with no arguments (tests, the `tools`/
71
+ # `screenshot` debug commands) keep today's behaviour: no boundary
72
+ # enforcement on write_file/edit_file, no mutation-ledger recording.
73
+ # The standalone agent loop (runner_local.py) always supplies both.
74
+ self.workspace_root = workspace_root
75
+ self.session_id = session_id
76
+ self.tools: Dict[str, ToolDefinition] = {}
77
+ self._register_default_tools()
78
+
79
+ def _register_default_tools(self):
80
+ """Register default tools"""
81
+
82
+ self.register_tool(
83
+ name="read_file",
84
+ description="Read contents of a file",
85
+ parameters={
86
+ "type": "object",
87
+ "properties": {
88
+ "path": {"type": "string", "description": "File path"}
89
+ },
90
+ "required": ["path"]
91
+ },
92
+ handler=self._read_file
93
+ )
94
+
95
+ self.register_tool(
96
+ name="write_file",
97
+ description="Write content to a file",
98
+ parameters={
99
+ "type": "object",
100
+ "properties": {
101
+ "path": {"type": "string", "description": "File path"},
102
+ "content": {"type": "string", "description": "File content"}
103
+ },
104
+ "required": ["path", "content"]
105
+ },
106
+ handler=self._write_file
107
+ )
108
+
109
+ self.register_tool(
110
+ name="edit_file",
111
+ description=(
112
+ "Replace an exact, unique occurrence of old_string with new_string in a file. "
113
+ "Fails if old_string is not found, or is not unique -- include enough surrounding "
114
+ "context in old_string to make the match unambiguous. Use write_file instead for "
115
+ "creating a brand-new file or replacing one's entire contents."
116
+ ),
117
+ parameters={
118
+ "type": "object",
119
+ "properties": {
120
+ "path": {"type": "string", "description": "File path"},
121
+ "old_string": {"type": "string", "description": "Exact text to replace (must match exactly once)"},
122
+ "new_string": {"type": "string", "description": "Replacement text"},
123
+ },
124
+ "required": ["path", "old_string", "new_string"],
125
+ },
126
+ handler=self._edit_file,
127
+ )
128
+
129
+ self.register_tool(
130
+ name="list_directory",
131
+ description="List contents of a directory",
132
+ parameters={
133
+ "type": "object",
134
+ "properties": {
135
+ "path": {"type": "string", "description": "Directory path"}
136
+ },
137
+ "required": ["path"]
138
+ },
139
+ handler=self._list_directory
140
+ )
141
+
142
+ self.register_tool(
143
+ name="search_code",
144
+ description="Search code using ripgrep",
145
+ parameters={
146
+ "type": "object",
147
+ "properties": {
148
+ "query": {"type": "string", "description": "Search pattern"},
149
+ "path": {"type": "string", "description": "Search directory"},
150
+ "file_pattern": {"type": "string", "description": "File pattern to match"}
151
+ },
152
+ "required": ["query"]
153
+ },
154
+ handler=self._search_code
155
+ )
156
+
157
+ self.register_tool(
158
+ name="execute_command",
159
+ description="Execute a shell command",
160
+ parameters={
161
+ "type": "object",
162
+ "properties": {
163
+ "command": {"type": "string", "description": "Command to execute"},
164
+ "timeout": {"type": "integer", "description": "Timeout in seconds"}
165
+ },
166
+ "required": ["command"]
167
+ },
168
+ handler=self._execute_command
169
+ )
170
+
171
+ self.register_tool(
172
+ name="get_git_info",
173
+ description="Get git repository information",
174
+ parameters={
175
+ "type": "object",
176
+ "properties": {
177
+ "path": {"type": "string", "description": "Repository path"}
178
+ }
179
+ },
180
+ handler=self._get_git_info
181
+ )
182
+
183
+ self.register_tool(
184
+ name="browser",
185
+ description=(
186
+ "Use a clean headless Playwright session to navigate a public page, extract or interact "
187
+ "with elements, test mobile scrolling, and capture a real PNG screenshot"
188
+ ),
189
+ parameters={
190
+ "type": "object",
191
+ "properties": {
192
+ "url": {"type": "string", "description": "Absolute public http(s) URL"},
193
+ "action": {
194
+ "type": "string",
195
+ "enum": ["navigate", "extract", "click", "fill_form", "scroll", "screenshot"],
196
+ },
197
+ "selector": {"type": "string"},
198
+ "form_data": {"type": "object", "additionalProperties": {"type": "string"}},
199
+ "submit_selector": {"type": "string"},
200
+ "viewport_width": {"type": "integer", "minimum": 320, "maximum": 3840},
201
+ "viewport_height": {"type": "integer", "minimum": 480, "maximum": 2160},
202
+ "wait_for_selector": {"type": "string"},
203
+ "wait_after_load_ms": {"type": "integer", "minimum": 0, "maximum": 5000},
204
+ "scroll_y": {"type": "integer"},
205
+ "full_page": {"type": "boolean"},
206
+ "screenshot_selector": {"type": "string"},
207
+ "screenshot_name": {"type": "string"},
208
+ },
209
+ "required": ["url", "action"],
210
+ },
211
+ handler=self._browser,
212
+ )
213
+
214
+ def register_tool(self, name: str, description: str,
215
+ parameters: Dict[str, Any], handler: Callable):
216
+ """Register a tool"""
217
+ self.tools[name] = ToolDefinition(
218
+ name=name,
219
+ description=description,
220
+ parameters=parameters,
221
+ handler=handler
222
+ )
223
+
224
+ def list_tools(self) -> List[Dict[str, Any]]:
225
+ """List all registered tools"""
226
+ return [
227
+ {
228
+ "name": tool.name,
229
+ "description": tool.description,
230
+ "parameters": tool.parameters
231
+ }
232
+ for tool in self.tools.values()
233
+ ]
234
+
235
+ def tool_schemas_openai(self, names: Optional[List[str]] = None) -> List[Dict[str, Any]]:
236
+ """Wrap registered tools in the `{"type":"function","function":{...}}`
237
+ envelope a chat-completions `tools=[...]` payload needs. `names`
238
+ restricts to a subset (e.g. read-only tools for a lower-trust mode);
239
+ omit for the full registered set."""
240
+ selected = names if names is not None else list(self.tools.keys())
241
+ return [
242
+ {
243
+ "type": "function",
244
+ "function": {
245
+ "name": tool.name,
246
+ "description": tool.description,
247
+ "parameters": tool.parameters,
248
+ },
249
+ }
250
+ for tool in self.tools.values()
251
+ if tool.name in selected
252
+ ]
253
+
254
+ async def list_tools_async(self, include_shared: bool = True) -> List[Dict[str, Any]]:
255
+ """List native CLI tools and tools discovered by the shared MCP hub."""
256
+ tools = self.list_tools()
257
+ if not include_shared:
258
+ return tools
259
+ bridge = None
260
+ owns_bridge = False
261
+ try:
262
+ bridge = _get_shared_mcp_bridge()
263
+ if bridge is None:
264
+ raise RuntimeError("Shared MCP bridge unavailable outside a monorepo checkout")
265
+ if not bridge.available:
266
+ owns_bridge = True
267
+ await bridge.initialize(background=False)
268
+ shared = await bridge.list_tools()
269
+ tools.extend({**tool, "source": "shared_mcp"} for tool in shared)
270
+ except Exception as exc:
271
+ tools.append({
272
+ "name": "shared_mcp",
273
+ "description": f"Shared MCP registry unavailable: {exc}",
274
+ "parameters": {},
275
+ "available": False,
276
+ })
277
+ finally:
278
+ # ``tamfis-code tools list`` runs in a short asyncio.run() loop.
279
+ # Explicitly close any MCP processes this invocation opened; leaving
280
+ # their transports for loop finalization caused the CLI to hang.
281
+ if owns_bridge and bridge is not None:
282
+ await bridge.shutdown()
283
+ return tools
284
+
285
+ async def call_tool(self, name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
286
+ """Call a tool by name"""
287
+ if name not in self.tools:
288
+ bridge = None
289
+ owns_bridge = False
290
+ try:
291
+ bridge = _get_shared_mcp_bridge()
292
+ if bridge is None:
293
+ raise RuntimeError("Shared MCP bridge unavailable outside a monorepo checkout")
294
+ if not bridge.available:
295
+ owns_bridge = True
296
+ await bridge.initialize(background=False)
297
+ result = await bridge.call_tool(name, parameters)
298
+ success = bool(result.get("success")) and not result.get("is_error")
299
+ return {
300
+ "result": result,
301
+ "tool": name,
302
+ "source": "shared_mcp",
303
+ "success": success,
304
+ **({"error": result.get("error_message") or result.get("error")}
305
+ if not success else {}),
306
+ }
307
+ except Exception as exc:
308
+ return {
309
+ "error": f"Shared MCP tool unavailable: {exc}",
310
+ "tool": name,
311
+ "source": "shared_mcp",
312
+ "success": False,
313
+ }
314
+ finally:
315
+ if owns_bridge and bridge is not None:
316
+ await bridge.shutdown()
317
+
318
+ tool = self.tools[name]
319
+ try:
320
+ result = await tool.handler(**parameters)
321
+ return {"result": result, "tool": name, "success": True}
322
+ except Exception as e:
323
+ return {"error": str(e), "tool": name, "success": False}
324
+
325
+ async def _read_file(self, path: str) -> str:
326
+ # Resolve path relative to current working directory
327
+ p = Path(path)
328
+ if not p.is_absolute():
329
+ p = Path.cwd() / p
330
+ p = p.resolve()
331
+ if not p.exists():
332
+ return f"Error: File '{path}' not found"
333
+ if not p.is_file():
334
+ return f"Error: '{path}' is not a file"
335
+ return p.read_text(encoding='utf-8', errors='ignore')
336
+
337
+ def _resolve_in_workspace(self, path: str) -> Path:
338
+ """Resolve `path` against workspace_root (or cwd if none was given),
339
+ raising if it escapes the workspace boundary. Only enforced when
340
+ `self.workspace_root` is set -- see __init__'s docstring on why
341
+ legacy no-arg callers get today's unrestricted behaviour instead."""
342
+ base = Path(self.workspace_root) if self.workspace_root else Path.cwd()
343
+ p = Path(path)
344
+ if not p.is_absolute():
345
+ p = base / p
346
+ resolved = p.resolve()
347
+ if self.workspace_root:
348
+ root = base.resolve()
349
+ if resolved != root and root not in resolved.parents:
350
+ raise PermissionError(f"'{path}' resolves outside the workspace root ({root})")
351
+ return resolved
352
+
353
+ async def _write_file(self, path: str, content: str) -> str:
354
+ p = self._resolve_in_workspace(path)
355
+ original_content = p.read_text(encoding="utf-8", errors="ignore") if p.is_file() else None
356
+ p.parent.mkdir(parents=True, exist_ok=True)
357
+ p.write_text(content, encoding='utf-8')
358
+ if not p.exists():
359
+ return f"❌ Failed to write to '{path}'"
360
+ if self.session_id is not None:
361
+ from .safety import record_mutation
362
+ record_mutation(
363
+ self.session_id, path=str(p), operation="create" if original_content is None else "update",
364
+ original_content=original_content, new_content=content,
365
+ )
366
+ return f"✅ Successfully wrote {len(content)} bytes to '{path}'"
367
+
368
+ async def _edit_file(self, path: str, old_string: str, new_string: str) -> str:
369
+ p = self._resolve_in_workspace(path)
370
+ if not p.is_file():
371
+ return f"❌ Error: File '{path}' not found"
372
+ original_content = p.read_text(encoding="utf-8", errors="ignore")
373
+ occurrences = original_content.count(old_string)
374
+ if occurrences == 0:
375
+ return f"❌ Error: old_string not found in '{path}' -- no changes made"
376
+ if occurrences > 1:
377
+ return (
378
+ f"❌ Error: old_string matches {occurrences} times in '{path}' -- it must be unique. "
379
+ "Include more surrounding context to disambiguate."
380
+ )
381
+ new_content = original_content.replace(old_string, new_string, 1)
382
+ p.write_text(new_content, encoding="utf-8")
383
+ if self.session_id is not None:
384
+ from .safety import record_mutation
385
+ record_mutation(
386
+ self.session_id, path=str(p), operation="update",
387
+ original_content=original_content, new_content=new_content,
388
+ )
389
+ return f"✅ Edited '{path}'"
390
+
391
+ async def _list_directory(self, path: str = ".") -> List[Dict[str, Any]]:
392
+ p = Path(path)
393
+ if not p.exists():
394
+ return [{"error": f"Directory '{path}' not found"}]
395
+ if not p.is_dir():
396
+ return [{"error": f"'{path}' is not a directory"}]
397
+
398
+ results = []
399
+ for item in p.iterdir():
400
+ results.append({
401
+ "name": item.name,
402
+ "path": str(item),
403
+ "is_file": item.is_file(),
404
+ "is_dir": item.is_dir(),
405
+ "size": item.stat().st_size if item.exists() else 0,
406
+ "modified": item.stat().st_mtime if item.exists() else 0,
407
+ })
408
+ return sorted(results, key=lambda x: x['name'])
409
+
410
+ async def _search_code(self, query: str, path: str = ".", file_pattern: str = None) -> List[Dict[str, Any]]:
411
+ try:
412
+ cmd = ['rg', '--json', '--line-number', '--no-heading', query, path]
413
+ if file_pattern:
414
+ cmd.extend(['--glob', file_pattern])
415
+
416
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
417
+ matches = []
418
+
419
+ for line in result.stdout.split('\n'):
420
+ if line.strip():
421
+ try:
422
+ data = json.loads(line)
423
+ if data.get('type') == 'match':
424
+ matches.append({
425
+ 'file': data['data']['path']['text'],
426
+ 'line': data['data']['line_number'],
427
+ 'content': data['data']['lines']['text'].strip(),
428
+ })
429
+ except json.JSONDecodeError:
430
+ continue
431
+
432
+ return matches
433
+ except subprocess.TimeoutExpired:
434
+ return [{"error": "Search timed out"}]
435
+ except FileNotFoundError:
436
+ return [{"error": "ripgrep (rg) not installed"}]
437
+
438
+ async def _execute_command(self, command: str, timeout: int = 60) -> Dict[str, Any]:
439
+ try:
440
+ proc = await asyncio.create_subprocess_shell(
441
+ command,
442
+ stdout=asyncio.subprocess.PIPE,
443
+ stderr=asyncio.subprocess.PIPE
444
+ )
445
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
446
+ return {
447
+ "stdout": stdout.decode('utf-8', errors='ignore'),
448
+ "stderr": stderr.decode('utf-8', errors='ignore'),
449
+ "return_code": proc.returncode,
450
+ "success": proc.returncode == 0
451
+ }
452
+ except asyncio.TimeoutError:
453
+ return {"error": f"Command timed out after {timeout} seconds", "success": False}
454
+ except Exception as e:
455
+ return {"error": str(e), "success": False}
456
+
457
+ async def _get_git_info(self, path: str = ".") -> Dict[str, Any]:
458
+ p = Path(path)
459
+ if not p.exists():
460
+ return {"error": f"Path '{path}' not found"}
461
+
462
+ info = {"path": str(p)}
463
+
464
+ # Check if it's a git repo
465
+ git_dir = p / ".git"
466
+ if not git_dir.exists():
467
+ info["is_git_repo"] = False
468
+ return info
469
+
470
+ info["is_git_repo"] = True
471
+
472
+ try:
473
+ # Get current branch
474
+ result = subprocess.run(
475
+ ['git', '-C', str(p), 'rev-parse', '--abbrev-ref', 'HEAD'],
476
+ capture_output=True, text=True
477
+ )
478
+ if result.returncode == 0:
479
+ info["branch"] = result.stdout.strip()
480
+
481
+ # Get remote URL
482
+ result = subprocess.run(
483
+ ['git', '-C', str(p), 'config', '--get', 'remote.origin.url'],
484
+ capture_output=True, text=True
485
+ )
486
+ if result.returncode == 0:
487
+ info["remote_url"] = result.stdout.strip()
488
+
489
+ # Get latest commit
490
+ result = subprocess.run(
491
+ ['git', '-C', str(p), 'log', '-1', '--format=%H%n%s%n%an%n%ae%n%ad'],
492
+ capture_output=True, text=True
493
+ )
494
+ if result.returncode == 0:
495
+ lines = result.stdout.split('\n')
496
+ if len(lines) >= 5:
497
+ info["latest_commit"] = {
498
+ "hash": lines[0],
499
+ "message": lines[1],
500
+ "author": lines[2],
501
+ "email": lines[3],
502
+ "date": lines[4],
503
+ }
504
+
505
+ # Get status
506
+ result = subprocess.run(
507
+ ['git', '-C', str(p), 'status', '--porcelain'],
508
+ capture_output=True, text=True
509
+ )
510
+ info["has_changes"] = bool(result.stdout.strip())
511
+ info["changed_files"] = len([l for l in result.stdout.split('\n') if l.strip()])
512
+
513
+ except Exception as e:
514
+ info["git_error"] = str(e)
515
+
516
+ return info
517
+
518
+ async def _browser(self, **parameters: Any) -> Dict[str, Any]:
519
+ """Public-web browser facade for ``tamfis-code tools call``.
520
+
521
+ The agentic Remote path injects trusted task context separately and
522
+ can therefore test loopback development servers. This direct facade
523
+ intentionally receives no trusted fields, so BrowserTool keeps its
524
+ public-only SSRF boundary.
525
+ """
526
+ browser_tool = get_browser_tool_class()
527
+ if browser_tool is None:
528
+ raise RuntimeError("BrowserTool unavailable outside a monorepo checkout")
529
+ result = await browser_tool().execute_async(**parameters)
530
+ if not result.get("success"):
531
+ raise RuntimeError(str(result.get("error") or "Browser action failed"))
532
+ return result
533
+
534
+ # Convenience function for CLI use
535
+ async def call_tool(name: str, **kwargs):
536
+ """Call a tool with given parameters"""
537
+ server = MCPServer()
538
+ return await server.call_tool(name, kwargs)
tamfis_code/metrics.py ADDED
@@ -0,0 +1,105 @@
1
+ """Streaming metrics and performance monitoring"""
2
+
3
+ import time
4
+ import threading
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional, Dict, Any
7
+ from datetime import datetime, timedelta
8
+
9
+ @dataclass
10
+ class StreamMetrics:
11
+ """Real-time streaming metrics"""
12
+ tokens_used: int = 0
13
+ tokens_per_second: float = 0.0
14
+ estimated_cost: float = 0.0
15
+ model_name: str = "default"
16
+ context_used: int = 0
17
+ response_time_ms: float = 0.0
18
+ start_time: datetime = field(default_factory=datetime.now)
19
+ last_update: datetime = field(default_factory=datetime.now)
20
+
21
+ # Per-command tracking
22
+ command_tokens: Dict[str, int] = field(default_factory=dict)
23
+ command_times: Dict[str, float] = field(default_factory=dict)
24
+
25
+ def update(self, tokens: int, elapsed_ms: float) -> None:
26
+ """Update metrics with new data"""
27
+ self.tokens_used += tokens
28
+ self.response_time_ms = elapsed_ms
29
+ self.last_update = datetime.now()
30
+
31
+ delta = (self.last_update - self.start_time).total_seconds()
32
+ if delta > 0:
33
+ self.tokens_per_second = self.tokens_used / delta
34
+
35
+ def estimate_cost(self, model: str) -> float:
36
+ """Estimate cost based on model pricing"""
37
+ rates = {
38
+ 'claude-3': 0.015 / 1000,
39
+ 'claude-3.5': 0.020 / 1000,
40
+ 'gpt-4': 0.030 / 1000,
41
+ 'gpt-3.5': 0.002 / 1000,
42
+ 'deepseek': 0.001 / 1000,
43
+ 'default': 0.010 / 1000,
44
+ }
45
+ rate = rates.get(model, rates['default'])
46
+ return self.tokens_used * rate
47
+
48
+ def format_display(self) -> str:
49
+ """Format metrics for display"""
50
+ elapsed = (datetime.now() - self.start_time).total_seconds()
51
+ cost = self.estimate_cost(self.model_name)
52
+
53
+ return (
54
+ f"📊 {self.tokens_used} tokens | "
55
+ f"{self.tokens_per_second:.1f} t/s | "
56
+ f"${cost:.4f} | "
57
+ f"{elapsed:.1f}s"
58
+ )
59
+
60
+ class MetricsTracker:
61
+ """Track metrics across a session"""
62
+
63
+ def __init__(self):
64
+ self.metrics = StreamMetrics()
65
+ self._active = False
66
+ self._timer: Optional[threading.Timer] = None
67
+ self._display_callback = None
68
+
69
+ def start(self, display_callback=None):
70
+ """Start tracking metrics"""
71
+ self._active = True
72
+ self.metrics.start_time = datetime.now()
73
+ self._display_callback = display_callback
74
+ self._update_loop()
75
+
76
+ def stop(self):
77
+ """Stop tracking"""
78
+ self._active = False
79
+ if self._timer:
80
+ self._timer.cancel()
81
+
82
+ def _update_loop(self):
83
+ """Periodic update loop"""
84
+ if not self._active:
85
+ return
86
+ if self._display_callback:
87
+ self._display_callback(self.metrics.format_display())
88
+ self._timer = threading.Timer(0.5, self._update_loop)
89
+ self._timer.daemon = True
90
+ self._timer.start()
91
+
92
+ def record(self, tokens: int, elapsed_ms: float, model: str = "default"):
93
+ """Record a response"""
94
+ self.metrics.update(tokens, elapsed_ms)
95
+ self.metrics.model_name = model
96
+
97
+ def get_summary(self) -> Dict[str, Any]:
98
+ """Get metrics summary"""
99
+ return {
100
+ "tokens_used": self.metrics.tokens_used,
101
+ "tokens_per_second": self.metrics.tokens_per_second,
102
+ "estimated_cost": self.metrics.estimated_cost,
103
+ "model": self.metrics.model_name,
104
+ "elapsed_seconds": (datetime.now() - self.metrics.start_time).total_seconds(),
105
+ }