nexo-brain 5.8.2 → 5.9.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.
@@ -0,0 +1,295 @@
1
+ """Central resonance map — single source of truth for (backend, model, effort)
2
+ decisions across every automation caller.
3
+
4
+ Motivation
5
+ ----------
6
+ Before v5.9.0 each caller that wanted to invoke Claude or Codex had to either
7
+ pass explicit model + reasoning_effort arguments or rely on the global defaults
8
+ in ``model_defaults.json``. Global defaults meant every background cron shared
9
+ the same max-effort configuration as the interactive ``nexo chat``, so batch
10
+ jobs (daily synthesis, postmortem consolidation, gbp posts) burned maximum
11
+ reasoning effort on tasks that didn't need it, while downgrading any single
12
+ default changed behaviour everywhere at once.
13
+
14
+ This module introduces four **resonance tiers** (``MAXIMO`` / ``ALTO`` /
15
+ ``MEDIO`` / ``BAJO``) and maps each tier to a concrete ``(model, effort)`` pair
16
+ per backend. Every caller is labelled in one of two ways:
17
+
18
+ - **User-facing callers** (``nexo chat``, Desktop new session, interactive
19
+ ``nexo update``) use the user's configured default resonance. When the
20
+ user changes the default via ``nexo preferences --resonance`` or through
21
+ the Desktop preferences pane, those three entry points adjust.
22
+
23
+ - **System-owned callers** (every cron, every background script, every
24
+ MCP-tool-triggered automation) use a fixed tier we pick per caller based
25
+ on what the task needs. A quarterly evolution pass that synthesizes
26
+ ten thousand lines into a new self-improvement plan is ``MAXIMO``. A
27
+ daily GBP post that needs to produce 200 characters of marketing copy
28
+ is ``BAJO``. That decision stays in this file and NEVER reads the user
29
+ default.
30
+
31
+ If a backend does not offer all four effort settings (e.g. a hypothetical
32
+ model with only ``max`` and ``low``), we collapse adjacent tiers — ``MAXIMO``
33
+ and ``ALTO`` both map to the backend's highest available effort, ``MEDIO``
34
+ and ``BAJO`` to the lowest. If a backend has no effort knob at all, the tier
35
+ still resolves to the same model with an empty effort string; the resonance
36
+ label is then informational only.
37
+
38
+ Contract
39
+ --------
40
+ Every call into ``run_automation_prompt`` and ``run_automation_interactive``
41
+ MUST pass a ``caller=`` string that is registered here. Callers not in the
42
+ registry raise ``UnregisteredCallerError`` — there is no silent default. This
43
+ forces the resonance decision to be explicit and auditable, and prevents
44
+ future scripts from silently inheriting the wrong tier.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ from typing import Tuple
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Tier → (claude_model, claude_effort, codex_model, codex_effort)
53
+ # ---------------------------------------------------------------------------
54
+ # Keep this table in ONE place. When we promote a new Claude or Codex model,
55
+ # update only this dict — every caller rebalances automatically.
56
+ #
57
+ # If a future backend offers fewer tiers (e.g. only max + low), collapse
58
+ # adjacent tiers onto the closest available effort. MAXIMO + ALTO → highest,
59
+ # MEDIO + BAJO → lowest. If a backend has no effort setting at all, leave
60
+ # the effort string empty.
61
+
62
+ TIERS = ("maximo", "alto", "medio", "bajo")
63
+
64
+ _RESONANCE_TABLE: dict[str, dict[str, tuple[str, str]]] = {
65
+ "maximo": {
66
+ "claude_code": ("claude-opus-4-7[1m]", "max"),
67
+ "codex": ("gpt-5.4", "xhigh"),
68
+ },
69
+ "alto": {
70
+ "claude_code": ("claude-opus-4-7[1m]", "xhigh"),
71
+ "codex": ("gpt-5.4", "high"),
72
+ },
73
+ "medio": {
74
+ "claude_code": ("claude-opus-4-7[1m]", "high"),
75
+ "codex": ("gpt-5.4", "medium"),
76
+ },
77
+ "bajo": {
78
+ "claude_code": ("claude-opus-4-7[1m]", "medium"),
79
+ "codex": ("gpt-5.4", "low"),
80
+ },
81
+ }
82
+
83
+ DEFAULT_RESONANCE = "alto"
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Caller registry
88
+ # ---------------------------------------------------------------------------
89
+ # Every script that calls the automation backend is registered here. Two
90
+ # categories, in two separate dicts so a reviewer can see at a glance which
91
+ # callers follow the user's preference and which are locked by us.
92
+ #
93
+ # USE_USER_DEFAULT → the caller reads the user's configured resonance at
94
+ # runtime. Only three callers should ever be in this list:
95
+ # the two interactive entry points (terminal chat, Desktop
96
+ # new conversation) and the interactive nexo update flow.
97
+ #
98
+ # SYSTEM_OWNED → the caller runs at whatever tier we deem appropriate
99
+ # for its workload, ignoring the user's default. Tier is
100
+ # picked for quality of output, not cost: batch jobs that
101
+ # synthesize across a lot of data lean ALTO/MAXIMO, jobs
102
+ # that apply a fixed transform or produce short copy lean
103
+ # MEDIO/BAJO.
104
+
105
+ USE_USER_DEFAULT_SENTINEL = "__USE_USER_DEFAULT__"
106
+
107
+ USER_FACING_CALLERS: dict[str, str] = {
108
+ "nexo_chat": USE_USER_DEFAULT_SENTINEL,
109
+ "desktop_new_session": USE_USER_DEFAULT_SENTINEL,
110
+ "nexo_update_interactive": USE_USER_DEFAULT_SENTINEL,
111
+ }
112
+
113
+ # System-owned callers. Grouped thematically for readability.
114
+ SYSTEM_OWNED_CALLERS: dict[str, str] = {
115
+ # ---- Evolution and introspection: highest-quality reasoning needed ----
116
+ "evolution/run": "maximo",
117
+ "reflection": "maximo",
118
+
119
+ # ---- Deep sleep: extraction and synthesis benefit from quality --------
120
+ "deep-sleep/extract": "alto",
121
+ "deep-sleep/synthesize": "maximo",
122
+ "deep-sleep/apply_findings": "alto",
123
+ "sleep/nightly": "alto",
124
+ "synthesis/daily": "alto",
125
+
126
+ # ---- User-facing outputs where quality is visible ---------------------
127
+ "catchup/morning": "alto",
128
+ "daily_self_audit": "alto",
129
+ "postmortem_consolidator": "alto",
130
+ "proactive_dashboard": "alto",
131
+ "followup_runner": "alto",
132
+
133
+ # ---- Defensive / consistency tasks ------------------------------------
134
+ "immune/scan": "medio",
135
+ "learning_validator": "medio",
136
+ "outcome_checker": "medio",
137
+ "check_context": "medio",
138
+
139
+ # ---- Agent orchestration ----------------------------------------------
140
+ "agent_run/generic": "alto",
141
+
142
+ # ---- Tooling helpers (short, structured outputs) ----------------------
143
+ "tools/drive_search": "medio",
144
+
145
+ # ---- Marketing automation ---------------------------------------------
146
+ # These produce short copy; we could run them at BAJO for speed, but the
147
+ # output is user-visible on a public surface, so we lean MEDIO for safety
148
+ # against embarrassing outputs.
149
+ "gbp/daily_post": "medio",
150
+ "gbp/post_wazion": "medio",
151
+ "gbp/post_psicologa": "medio",
152
+ "gbp/monthly_audit": "medio",
153
+ "gbp/reviews_watch": "medio",
154
+ }
155
+
156
+ ALL_REGISTERED_CALLERS: frozenset[str] = frozenset(
157
+ list(USER_FACING_CALLERS.keys()) + list(SYSTEM_OWNED_CALLERS.keys())
158
+ )
159
+
160
+
161
+ class UnregisteredCallerError(ValueError):
162
+ """Raised when a caller string is not in the resonance registry.
163
+
164
+ Every caller that dispatches an automation subprocess MUST register here.
165
+ We do not fall back to a default tier silently — that would re-introduce
166
+ the pre-v5.9.0 problem where the wrong script could inherit the wrong
167
+ reasoning budget without anyone noticing. The fix for this error is:
168
+ add an entry to SYSTEM_OWNED_CALLERS (or USER_FACING_CALLERS if it is a
169
+ genuine interactive entry point) and pick the tier deliberately.
170
+ """
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Resolution
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def _load_user_default_resonance() -> str:
178
+ """Resolve the user's ``default_resonance`` preference.
179
+
180
+ Reads ``calibration.json`` first (``preferences.default_resonance``, the
181
+ location NEXO Desktop's preferences UI writes to) and falls back to
182
+ ``schedule.json`` (``default_resonance``, the location the CLI used to
183
+ write to in v5.9.0). Returns an empty string if neither source has a
184
+ valid tier — callers should treat empty as "no preference".
185
+ """
186
+ import json as _json
187
+ import os as _os
188
+ from pathlib import Path as _Path
189
+
190
+ home = _Path(_os.environ.get("NEXO_HOME", str(_Path.home() / ".nexo")))
191
+
192
+ # calibration.json (Desktop UI writes here)
193
+ cal_path = home / "brain" / "calibration.json"
194
+ try:
195
+ if cal_path.exists():
196
+ cal = _json.loads(cal_path.read_text())
197
+ prefs = cal.get("preferences") if isinstance(cal, dict) else None
198
+ if isinstance(prefs, dict):
199
+ tier = str(prefs.get("default_resonance") or "").strip().lower()
200
+ if tier in TIERS:
201
+ return tier
202
+ except (OSError, _json.JSONDecodeError):
203
+ pass
204
+
205
+ # schedule.json (CLI legacy)
206
+ sched_path = home / "config" / "schedule.json"
207
+ try:
208
+ if sched_path.exists():
209
+ sched = _json.loads(sched_path.read_text())
210
+ tier = str((sched or {}).get("default_resonance") or "").strip().lower()
211
+ if tier in TIERS:
212
+ return tier
213
+ except (OSError, _json.JSONDecodeError):
214
+ pass
215
+
216
+ return ""
217
+
218
+
219
+ def resolve_tier_for_caller(caller: str, user_default: str | None = None) -> str:
220
+ """Return the resonance tier that should apply to ``caller``.
221
+
222
+ - User-facing callers resolve to ``user_default`` (or ``DEFAULT_RESONANCE``
223
+ if the user has no preference recorded).
224
+ - System-owned callers resolve to their fixed tier.
225
+ - Unknown callers raise ``UnregisteredCallerError``.
226
+
227
+ When ``user_default`` is not passed, the function looks it up from the
228
+ calibration.json preferences first and schedule.json second.
229
+ """
230
+ if not caller:
231
+ raise UnregisteredCallerError(
232
+ "caller= is required. Every automation subprocess must be registered "
233
+ "in src/resonance_map.py so its reasoning budget is deliberate."
234
+ )
235
+ if caller in USER_FACING_CALLERS:
236
+ resolved_default = user_default
237
+ if resolved_default is None:
238
+ resolved_default = _load_user_default_resonance()
239
+ tier = (resolved_default or DEFAULT_RESONANCE).strip().lower()
240
+ if tier not in TIERS:
241
+ tier = DEFAULT_RESONANCE
242
+ return tier
243
+ if caller in SYSTEM_OWNED_CALLERS:
244
+ return SYSTEM_OWNED_CALLERS[caller]
245
+ raise UnregisteredCallerError(
246
+ f"caller {caller!r} is not registered in resonance_map.py. "
247
+ "Add it to SYSTEM_OWNED_CALLERS (or USER_FACING_CALLERS if it is an "
248
+ "interactive entry point) with a deliberate tier."
249
+ )
250
+
251
+
252
+ def resolve_model_and_effort(
253
+ caller: str,
254
+ backend: str,
255
+ user_default: str | None = None,
256
+ ) -> Tuple[str, str]:
257
+ """Return ``(model, reasoning_effort)`` for ``caller`` on ``backend``.
258
+
259
+ The ``backend`` key must match the entries in ``_RESONANCE_TABLE`` tier
260
+ dicts (``claude_code`` or ``codex``). Unknown backends fall back to an
261
+ empty pair; the caller is expected to handle that by raising or by
262
+ passing its own explicit model/effort arguments.
263
+ """
264
+ tier = resolve_tier_for_caller(caller, user_default=user_default)
265
+ backend_entry = _RESONANCE_TABLE.get(tier, {}).get(backend)
266
+ if backend_entry is None:
267
+ return "", ""
268
+ return backend_entry
269
+
270
+
271
+ def register_system_caller(caller: str, tier: str) -> None:
272
+ """Test/debug helper: register a caller at runtime.
273
+
274
+ Production code must add callers statically to ``SYSTEM_OWNED_CALLERS``
275
+ at module level so the registry is reviewable. This helper exists so
276
+ unit tests can exercise ``resolve_*`` against synthetic caller names
277
+ without mutating the shipped table.
278
+ """
279
+ if tier not in TIERS:
280
+ raise ValueError(f"tier {tier!r} not in {TIERS}")
281
+ SYSTEM_OWNED_CALLERS[caller] = tier
282
+ # Rebuild the frozen view so the guard below sees the new caller.
283
+ global ALL_REGISTERED_CALLERS
284
+ ALL_REGISTERED_CALLERS = frozenset(
285
+ list(USER_FACING_CALLERS.keys()) + list(SYSTEM_OWNED_CALLERS.keys())
286
+ )
287
+
288
+
289
+ def unregister_system_caller(caller: str) -> None:
290
+ """Mirror helper for tests that need to remove what they registered."""
291
+ SYSTEM_OWNED_CALLERS.pop(caller, None)
292
+ global ALL_REGISTERED_CALLERS
293
+ ALL_REGISTERED_CALLERS = frozenset(
294
+ list(USER_FACING_CALLERS.keys()) + list(SYSTEM_OWNED_CALLERS.keys())
295
+ )
@@ -198,7 +198,7 @@ Rules:
198
198
  try:
199
199
  result = run_automation_prompt(
200
200
  prompt,
201
- model=_USER_MODEL,
201
+ caller="check_context",
202
202
  timeout=300,
203
203
  output_format="text",
204
204
  append_system_prompt="Return exactly one valid JSON object.",
@@ -193,7 +193,7 @@ def analyze_session(
193
193
 
194
194
  result = run_automation_prompt(
195
195
  prompt,
196
- model=_USER_MODEL,
196
+ caller="deep-sleep/extract",
197
197
  timeout=CLAUDE_TIMEOUT,
198
198
  output_format="text",
199
199
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -224,7 +224,7 @@ def analyze_session(
224
224
  )
225
225
  convert_result = run_automation_prompt(
226
226
  convert_prompt,
227
- model=_USER_MODEL,
227
+ caller="deep-sleep/extract",
228
228
  timeout=120,
229
229
  output_format="text",
230
230
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -241,7 +241,7 @@ def main():
241
241
  try:
242
242
  result = run_automation_prompt(
243
243
  prompt,
244
- model=_USER_MODEL,
244
+ caller="deep-sleep/synthesize",
245
245
  timeout=CLAUDE_TIMEOUT,
246
246
  output_format="text",
247
247
  allowed_tools="Read,Grep,Bash",
@@ -52,6 +52,7 @@ def main(argv: list[str] | None = None) -> int:
52
52
  try:
53
53
  result = run_automation_prompt(
54
54
  prompt,
55
+ caller=getattr(args, "caller", "") or "agent_run/generic",
55
56
  cwd=args.cwd or None,
56
57
  task_profile=args.task_profile,
57
58
  model=args.model,
@@ -280,7 +280,7 @@ Format:
280
280
  try:
281
281
  result = run_automation_prompt(
282
282
  prompt,
283
- model=_USER_MODEL,
283
+ caller="catchup/morning",
284
284
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
285
285
  output_format="text",
286
286
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -2050,7 +2050,7 @@ Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
2050
2050
  try:
2051
2051
  result = run_automation_prompt(
2052
2052
  prompt,
2053
- model=_USER_MODEL,
2053
+ caller="daily_self_audit",
2054
2054
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
2055
2055
  output_format="text",
2056
2056
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -227,7 +227,7 @@ def call_claude_cli(prompt: str) -> str:
227
227
  """Call the configured automation backend for the managed evolution prompt."""
228
228
  result = run_automation_prompt(
229
229
  prompt,
230
- model=_USER_MODEL,
230
+ caller="evolution/run",
231
231
  timeout=CLI_TIMEOUT,
232
232
  output_format="text",
233
233
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -241,9 +241,9 @@ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
241
241
  """Run the configured automation backend in an isolated public repo checkout."""
242
242
  result = run_automation_prompt(
243
243
  prompt,
244
+ caller="evolution/run",
244
245
  cwd=cwd,
245
246
  env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
246
- model=_USER_MODEL,
247
247
  timeout=CLI_TIMEOUT,
248
248
  output_format="text",
249
249
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
@@ -916,7 +916,7 @@ Write the report. Be concise — max 40 lines."""
916
916
  try:
917
917
  result = run_automation_prompt(
918
918
  prompt,
919
- model=_USER_MODEL,
919
+ caller="immune/scan",
920
920
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
921
921
  output_format="text",
922
922
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -158,7 +158,7 @@ Rules:
158
158
  try:
159
159
  result = run_automation_prompt(
160
160
  prompt,
161
- model=_USER_MODEL,
161
+ caller="learning_validator",
162
162
  timeout=60,
163
163
  output_format="text",
164
164
  append_system_prompt=JSON_ONLY_SYSTEM_PROMPT,
@@ -255,7 +255,7 @@ Execute without asking."""
255
255
  try:
256
256
  result = run_automation_prompt(
257
257
  prompt,
258
- model=_USER_MODEL,
258
+ caller="postmortem_consolidator",
259
259
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
260
260
  output_format="text",
261
261
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -446,7 +446,7 @@ Execute without asking."""
446
446
  try:
447
447
  result = run_automation_prompt(
448
448
  prompt,
449
- model=_USER_MODEL,
449
+ caller="sleep/nightly",
450
450
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
451
451
  output_format="text",
452
452
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -348,7 +348,7 @@ Execute without asking."""
348
348
  try:
349
349
  result = run_automation_prompt(
350
350
  prompt,
351
- model=_USER_MODEL,
351
+ caller="synthesis/daily",
352
352
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
353
353
  output_format="text",
354
354
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
package/src/server.py CHANGED
@@ -63,6 +63,10 @@ from tools_credentials import (
63
63
  from tools_task_history import (
64
64
  handle_task_log, handle_task_list, handle_task_frequency,
65
65
  )
66
+ from tools_automation_sessions import (
67
+ handle_session_log_create,
68
+ handle_session_log_close,
69
+ )
66
70
  from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
67
71
 
68
72
 
@@ -1375,6 +1379,104 @@ def nexo_drive_dismiss(signal_id: int, reason: str) -> str:
1375
1379
  return handle_drive_dismiss(signal_id, reason)
1376
1380
 
1377
1381
 
1382
+ @mcp.tool
1383
+ def nexo_session_log_create(
1384
+ caller: str,
1385
+ backend: str,
1386
+ session_type: str = "interactive_desktop",
1387
+ model: str = "",
1388
+ reasoning_effort: str = "",
1389
+ resonance_tier: str = "",
1390
+ cwd: str = "",
1391
+ pid: str = "",
1392
+ context_excerpt: str = "",
1393
+ ) -> str:
1394
+ """Open an automation_runs row for an interactive Claude/Codex session.
1395
+
1396
+ Designed for clients that spawn Claude/Codex directly (notably NEXO
1397
+ Desktop, which runs a TypeScript process that shells out to the CLI
1398
+ without going through run_automation_prompt). Call this BEFORE
1399
+ spawning the child, store the returned session_id, then call
1400
+ nexo_session_log_close when the session ends.
1401
+
1402
+ Args:
1403
+ caller: Registered caller id (see src/resonance_map.py). For
1404
+ Desktop's "new conversation" button, use
1405
+ "desktop_new_session".
1406
+ backend: "claude_code" or "codex".
1407
+ session_type: "interactive_chat" | "interactive_desktop" — how
1408
+ the session is shaped. Default "interactive_desktop".
1409
+ model: Concrete model the client resolved, e.g. "claude-opus-4-7[1m]".
1410
+ reasoning_effort: Concrete effort string, e.g. "xhigh".
1411
+ resonance_tier: Tier label ("maximo"/"alto"/"medio"/"bajo"). If
1412
+ left empty the Brain resolves it from caller.
1413
+ cwd: Working directory the session is anchored to.
1414
+ pid: Child process PID if available.
1415
+ context_excerpt: Optional first-prompt preview (used to size
1416
+ prompt_chars in telemetry).
1417
+ """
1418
+ import json as _json
1419
+ result = handle_session_log_create(
1420
+ caller=caller,
1421
+ backend=backend,
1422
+ session_type=session_type,
1423
+ model=model,
1424
+ reasoning_effort=reasoning_effort,
1425
+ resonance_tier=resonance_tier,
1426
+ cwd=cwd,
1427
+ pid=pid,
1428
+ context_excerpt=context_excerpt,
1429
+ )
1430
+ return _json.dumps(result, ensure_ascii=False)
1431
+
1432
+
1433
+ @mcp.tool
1434
+ def nexo_session_log_close(
1435
+ session_id: int,
1436
+ returncode: int = 0,
1437
+ duration_ms: int = 0,
1438
+ input_tokens: int = 0,
1439
+ cached_input_tokens: int = 0,
1440
+ output_tokens: int = 0,
1441
+ total_cost_usd: str = "",
1442
+ telemetry_source: str = "",
1443
+ cost_source: str = "",
1444
+ error: str = "",
1445
+ ) -> str:
1446
+ """Close an automation_runs row opened by nexo_session_log_create.
1447
+
1448
+ Args:
1449
+ session_id: id returned by the create call.
1450
+ returncode: child exit code (0 = ok).
1451
+ duration_ms: wall-clock duration in milliseconds.
1452
+ input_tokens / cached_input_tokens / output_tokens: client-side
1453
+ usage counters.
1454
+ total_cost_usd: cost in USD as a string (parsed to float).
1455
+ telemetry_source: short label identifying where the counts came
1456
+ from ("desktop_stream", "codex_json", ...).
1457
+ cost_source: short label for cost provenance.
1458
+ error: short error message if the session failed.
1459
+ """
1460
+ import json as _json
1461
+ try:
1462
+ cost = float(total_cost_usd) if total_cost_usd else None
1463
+ except ValueError:
1464
+ cost = None
1465
+ result = handle_session_log_close(
1466
+ session_id=session_id,
1467
+ returncode=returncode,
1468
+ duration_ms=duration_ms,
1469
+ input_tokens=input_tokens,
1470
+ cached_input_tokens=cached_input_tokens,
1471
+ output_tokens=output_tokens,
1472
+ total_cost_usd=cost,
1473
+ telemetry_source=telemetry_source,
1474
+ cost_source=cost_source,
1475
+ error=error,
1476
+ )
1477
+ return _json.dumps(result, ensure_ascii=False)
1478
+
1479
+
1378
1480
  if __name__ == "__main__":
1379
1481
  _server_init()
1380
1482
  mcp.run(**_run_kwargs_from_env())