multi-forge 0.2.0__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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,313 @@
1
+ """CLI command: forge proxy costs.
2
+
3
+ Shows cost breakdowns from persistent JSONL cost logs. Reads both
4
+ per-request logs (model/tier analysis) and per-verb logs (functional
5
+ attribution). "Interactive" cost is computed as the residual.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ import click
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ console = Console(stderr=True)
18
+
19
+
20
+ def _local_period_bounds(period: str) -> tuple[datetime, datetime]:
21
+ """Compute UTC start/end for a named period using local timezone."""
22
+ now_local = datetime.now().astimezone()
23
+ now_utc = datetime.now(timezone.utc)
24
+
25
+ if period == "today":
26
+ local_midnight = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
27
+ start = local_midnight.astimezone(timezone.utc)
28
+ return start, now_utc
29
+ elif period == "week":
30
+ local_midnight = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
31
+ week_start = local_midnight - timedelta(days=local_midnight.weekday())
32
+ start = week_start.astimezone(timezone.utc)
33
+ return start, now_utc
34
+ elif period == "month":
35
+ local_month_start = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
36
+ start = local_month_start.astimezone(timezone.utc)
37
+ return start, now_utc
38
+ else:
39
+ return datetime.min.replace(tzinfo=timezone.utc), now_utc
40
+
41
+
42
+ def _format_usd(micros: int) -> str:
43
+ usd = micros / 1_000_000
44
+ if usd >= 1.0:
45
+ return f"${usd:,.2f}"
46
+ if usd >= 0.01:
47
+ return f"${usd:.2f}"
48
+ if usd >= 0.0001:
49
+ return f"${usd:.4f}"
50
+ if usd > 0:
51
+ return f"${usd:.6f}"
52
+ return "$0.00"
53
+
54
+
55
+ def _format_tokens(n: int) -> str:
56
+ if n >= 1_000_000:
57
+ return f"{n / 1_000_000:.1f}M"
58
+ if n >= 1_000:
59
+ return f"{n / 1_000:.1f}K"
60
+ return str(n)
61
+
62
+
63
+ @click.command("costs")
64
+ @click.argument("proxy_id", required=False, default=None)
65
+ @click.option(
66
+ "--period",
67
+ type=click.Choice(["today", "week", "month", "all"]),
68
+ default="today",
69
+ help="Time period to show (default: today)",
70
+ )
71
+ @click.option("--by-model", is_flag=True, help="Breakdown by model")
72
+ @click.option("--by-verb", is_flag=True, help="Breakdown by verb (default view)")
73
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
74
+ def costs_cmd(
75
+ proxy_id: str | None,
76
+ period: str,
77
+ by_model: bool,
78
+ by_verb: bool,
79
+ as_json: bool,
80
+ ) -> None:
81
+ """Show cost summary from proxy cost logs.
82
+
83
+ \b
84
+ Examples:
85
+ forge proxy costs # Today's costs, by verb
86
+ forge proxy costs --by-model # Today's costs, by model
87
+ forge proxy costs --period week # This week
88
+ forge proxy costs --period all # All time
89
+ forge proxy costs openrouter # Filter by proxy
90
+
91
+ \b
92
+ Tip: pair with 'forge proxy set <id> costs.caps.per_month=<amount>' to keep
93
+ metered provider usage within a monthly budget.
94
+ """
95
+ from forge.core.reactive.cost_tracking import read_verb_logs
96
+ from forge.proxy.cost_logger import read_cost_logs
97
+
98
+ start, end = _local_period_bounds(period)
99
+ if period == "all":
100
+ request_records = read_cost_logs()
101
+ verb_records = read_verb_logs()
102
+ else:
103
+ request_records = read_cost_logs(period_start=start, period_end=end)
104
+ verb_records = read_verb_logs(period_start=start, period_end=end)
105
+
106
+ if proxy_id:
107
+ request_records = [r for r in request_records if r.get("proxy_id") == proxy_id]
108
+ verb_records = _scope_verb_records_to_proxy(verb_records, _lookup_proxy_base_url(proxy_id))
109
+
110
+ if as_json:
111
+ _output_json(request_records, verb_records, period, proxy_id)
112
+ return
113
+
114
+ if by_model:
115
+ _display_by_model(request_records, period, proxy_id)
116
+ else:
117
+ _display_by_verb(request_records, verb_records, period, proxy_id)
118
+
119
+
120
+ def _lookup_proxy_base_url(proxy_id: str) -> str | None:
121
+ """Resolve a proxy id for filtering verb cost records."""
122
+ try:
123
+ from forge.core.reactive.proxy import lookup_proxy_base_url
124
+
125
+ return lookup_proxy_base_url(proxy_id)
126
+ except Exception:
127
+ return None
128
+
129
+
130
+ def _normalize_base_url(base_url: str | None) -> str | None:
131
+ if not base_url:
132
+ return None
133
+ normalized = base_url.strip()
134
+ if not normalized:
135
+ return None
136
+ if "://" not in normalized:
137
+ normalized = f"http://{normalized}"
138
+ return normalized.rstrip("/")
139
+
140
+
141
+ def _sum_proxy_field(proxies: list[dict], field: str) -> int:
142
+ return sum(int(p.get(field, 0) or 0) for p in proxies)
143
+
144
+
145
+ def _scope_verb_records_to_proxy(verb_records: list[dict], proxy_base_url: str | None) -> list[dict]:
146
+ """Keep only the per-proxy deltas matching the requested proxy base URL."""
147
+ target = _normalize_base_url(proxy_base_url)
148
+ if not target:
149
+ return []
150
+
151
+ scoped: list[dict] = []
152
+ for record in verb_records:
153
+ per_proxy = record.get("per_proxy", [])
154
+ if not isinstance(per_proxy, list):
155
+ continue
156
+
157
+ matching = [
158
+ proxy_delta
159
+ for proxy_delta in per_proxy
160
+ if isinstance(proxy_delta, dict) and _normalize_base_url(proxy_delta.get("base_url")) == target
161
+ ]
162
+ if not matching:
163
+ continue
164
+
165
+ scoped_record = dict(record)
166
+ scoped_record["per_proxy"] = matching
167
+ scoped_record["total_cost_micros"] = _sum_proxy_field(matching, "cost_micros")
168
+ scoped_record["input_tokens"] = _sum_proxy_field(matching, "input_tokens")
169
+ scoped_record["output_tokens"] = _sum_proxy_field(matching, "output_tokens")
170
+ scoped_record["cached_tokens"] = _sum_proxy_field(matching, "cached_tokens")
171
+ scoped_record["request_count"] = _sum_proxy_field(matching, "request_count")
172
+ scoped.append(scoped_record)
173
+
174
+ return scoped
175
+
176
+
177
+ def _display_by_verb(
178
+ request_records: list[dict],
179
+ verb_records: list[dict],
180
+ period: str,
181
+ proxy_id: str | None,
182
+ ) -> None:
183
+ total_cost = sum(r.get("cost_micros", 0) for r in request_records)
184
+ total_requests = len(request_records)
185
+
186
+ verb_costs: dict[str, dict] = {}
187
+ for v in verb_records:
188
+ verb = v.get("verb", "unknown")
189
+ if verb not in verb_costs:
190
+ verb_costs[verb] = {"cost_micros": 0, "request_count": 0, "invocations": 0}
191
+ verb_costs[verb]["cost_micros"] += v.get("total_cost_micros", 0)
192
+ verb_costs[verb]["request_count"] += v.get("request_count", 0)
193
+ verb_costs[verb]["invocations"] += 1
194
+
195
+ verb_total = sum(v["cost_micros"] for v in verb_costs.values())
196
+ interactive_cost = max(0, total_cost - verb_total)
197
+
198
+ if total_cost == 0 and not verb_costs:
199
+ scope = f" ({proxy_id})" if proxy_id else ""
200
+ console.print(f"[dim]No cost data for {period}{scope}.[/dim]")
201
+ return
202
+
203
+ scope = f" ({proxy_id})" if proxy_id else ""
204
+ console.print(f"\n[bold]Cost Summary ({period}{scope}):[/bold]")
205
+
206
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
207
+ table.add_column("Source", style="cyan")
208
+ table.add_column("Cost", justify="right")
209
+ table.add_column("Detail", style="dim")
210
+ table.add_column("", style="dim")
211
+
212
+ table.add_row("Total", _format_usd(total_cost), f"{total_requests} requests", "")
213
+ table.add_row(
214
+ "Interactive",
215
+ _format_usd(interactive_cost),
216
+ "unattributed",
217
+ "~",
218
+ )
219
+
220
+ for verb in sorted(verb_costs):
221
+ info = verb_costs[verb]
222
+ detail = f"{info['invocations']} run{'s' if info['invocations'] != 1 else ''}"
223
+ if info["request_count"]:
224
+ detail += f", {info['request_count']} reqs"
225
+ table.add_row(verb, _format_usd(info["cost_micros"]), detail, "~")
226
+
227
+ console.print(table)
228
+ console.print()
229
+
230
+
231
+ def _display_by_model(
232
+ request_records: list[dict],
233
+ period: str,
234
+ proxy_id: str | None,
235
+ ) -> None:
236
+ model_costs: dict[str, dict] = {}
237
+ for r in request_records:
238
+ model = r.get("model", "unknown")
239
+ if model not in model_costs:
240
+ model_costs[model] = {
241
+ "cost_micros": 0,
242
+ "input_tokens": 0,
243
+ "output_tokens": 0,
244
+ "requests": 0,
245
+ }
246
+ model_costs[model]["cost_micros"] += r.get("cost_micros", 0)
247
+ model_costs[model]["input_tokens"] += r.get("input_tokens", 0)
248
+ model_costs[model]["output_tokens"] += r.get("output_tokens", 0)
249
+ model_costs[model]["requests"] += 1
250
+
251
+ if not model_costs:
252
+ scope = f" ({proxy_id})" if proxy_id else ""
253
+ console.print(f"[dim]No cost data for {period}{scope}.[/dim]")
254
+ return
255
+
256
+ scope = f" ({proxy_id})" if proxy_id else ""
257
+ console.print(f"\n[bold]By Model ({period}{scope}):[/bold]")
258
+
259
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
260
+ table.add_column("Model", style="cyan")
261
+ table.add_column("Cost", justify="right")
262
+ table.add_column("Tokens", style="dim")
263
+
264
+ for model in sorted(model_costs, key=lambda m: model_costs[m]["cost_micros"], reverse=True):
265
+ info = model_costs[model]
266
+ tokens = f"{_format_tokens(info['input_tokens'])} in, {_format_tokens(info['output_tokens'])} out"
267
+ table.add_row(model, _format_usd(info["cost_micros"]), tokens)
268
+
269
+ console.print(table)
270
+ console.print()
271
+
272
+
273
+ def _output_json(
274
+ request_records: list[dict],
275
+ verb_records: list[dict],
276
+ period: str,
277
+ proxy_id: str | None,
278
+ ) -> None:
279
+ total_cost = sum(r.get("cost_micros", 0) for r in request_records)
280
+
281
+ verb_summary: dict[str, dict] = {}
282
+ for v in verb_records:
283
+ verb = v.get("verb", "unknown")
284
+ if verb not in verb_summary:
285
+ verb_summary[verb] = {"cost_micros": 0, "request_count": 0, "invocations": 0}
286
+ verb_summary[verb]["cost_micros"] += v.get("total_cost_micros", 0)
287
+ verb_summary[verb]["request_count"] += v.get("request_count", 0)
288
+ verb_summary[verb]["invocations"] += 1
289
+
290
+ model_summary: dict[str, dict] = {}
291
+ for r in request_records:
292
+ model = r.get("model", "unknown")
293
+ if model not in model_summary:
294
+ model_summary[model] = {"cost_micros": 0, "input_tokens": 0, "output_tokens": 0, "requests": 0}
295
+ model_summary[model]["cost_micros"] += r.get("cost_micros", 0)
296
+ model_summary[model]["input_tokens"] += r.get("input_tokens", 0)
297
+ model_summary[model]["output_tokens"] += r.get("output_tokens", 0)
298
+ model_summary[model]["requests"] += 1
299
+
300
+ verb_total = sum(v["cost_micros"] for v in verb_summary.values())
301
+
302
+ output = {
303
+ "period": period,
304
+ "proxy_id": proxy_id,
305
+ "total_cost_micros": total_cost,
306
+ "total_cost_usd": round(total_cost / 1_000_000, 6),
307
+ "total_requests": len(request_records),
308
+ "interactive_cost_micros": max(0, total_cost - verb_total),
309
+ "by_verb": verb_summary,
310
+ "by_model": model_summary,
311
+ "estimated": True,
312
+ }
313
+ click.echo(json.dumps(output, indent=2))