secondshift 0.1.0b12__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 (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,171 @@
1
+ """Token usage read from provider responses, normalized across providers."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from typing import Any
6
+
7
+ from secondshift.config.constants import ModelFamily
8
+ from secondshift.gateway.dao import aadd_used_tokens, acreate_model_usage
9
+ from secondshift.gateway.models import RunToken
10
+ from secondshift.gateway.pricing import request_cost
11
+ from secondshift.runner.agent_image.outcomes import budget_count
12
+
13
+ MAX_SSE_LINE_BYTES = 1024 * 1024
14
+
15
+
16
+ @dataclass
17
+ class Usage:
18
+ input_tokens: int = 0
19
+ output_tokens: int = 0
20
+ cache_read_tokens: int = 0
21
+ cache_write_tokens: int = 0
22
+
23
+ @property
24
+ def total(self) -> int:
25
+ return (
26
+ self.input_tokens
27
+ + self.output_tokens
28
+ + self.cache_read_tokens
29
+ + self.cache_write_tokens
30
+ )
31
+
32
+ @property
33
+ def budget_tokens(self) -> int:
34
+ """What counts toward the run's token budget."""
35
+ return budget_count(
36
+ self.input_tokens + self.output_tokens + self.cache_write_tokens,
37
+ self.cache_read_tokens,
38
+ )
39
+
40
+
41
+ def token_count(value: Any) -> int | None:
42
+ return value if isinstance(value, int) and value >= 0 else None
43
+
44
+
45
+ def apply_anthropic_usage(usage: Usage, data: Any) -> None:
46
+ # message_delta repeats cumulative counts, so later values replace earlier.
47
+ if not isinstance(data, dict):
48
+ return
49
+ fields = {
50
+ "input_tokens": "input_tokens",
51
+ "output_tokens": "output_tokens",
52
+ "cache_read_input_tokens": "cache_read_tokens",
53
+ "cache_creation_input_tokens": "cache_write_tokens",
54
+ }
55
+ for source, target in fields.items():
56
+ if (value := token_count(data.get(source))) is not None:
57
+ setattr(usage, target, value)
58
+
59
+
60
+ def apply_openai_usage(usage: Usage, data: Any) -> None:
61
+ if not isinstance(data, dict):
62
+ return
63
+ prompt = token_count(data.get("prompt_tokens"))
64
+ if prompt is None:
65
+ prompt = token_count(data.get("input_tokens"))
66
+ completion = token_count(data.get("completion_tokens"))
67
+ if completion is None:
68
+ completion = token_count(data.get("output_tokens"))
69
+ details = data.get("prompt_tokens_details") or data.get("input_tokens_details")
70
+ cached = (
71
+ token_count(details.get("cached_tokens")) if isinstance(details, dict) else None
72
+ )
73
+ cached = cached or 0
74
+ # OpenAI counts cached tokens inside the prompt. Store them separately so
75
+ # totals match Anthropic's accounting.
76
+ if prompt is not None:
77
+ usage.input_tokens = max(prompt - cached, 0)
78
+ usage.cache_read_tokens = cached
79
+ if completion is not None:
80
+ usage.output_tokens = completion
81
+
82
+
83
+ def usage_from_json(family: ModelFamily, body: Any) -> Usage:
84
+ usage = Usage()
85
+ if not isinstance(body, dict):
86
+ return usage
87
+ if family == ModelFamily.ANTHROPIC:
88
+ apply_anthropic_usage(usage, body.get("usage"))
89
+ else:
90
+ apply_openai_usage(usage, body.get("usage"))
91
+ return usage
92
+
93
+
94
+ @dataclass
95
+ class StreamMeter:
96
+ """Reads usage out of a server-sent event stream without altering it."""
97
+
98
+ family: ModelFamily
99
+ usage: Usage = field(default_factory=Usage)
100
+ # The partial line waiting for its newline. A bytearray grows in place, so
101
+ # a long line read in small chunks costs time linear in its length.
102
+ buffer: bytearray = field(default_factory=bytearray, init=False, repr=False)
103
+
104
+ def feed(self, chunk: bytes) -> None:
105
+ self.buffer += chunk
106
+ # Only the new chunk can hold the newline that ends the partial line.
107
+ if b"\n" in chunk:
108
+ *lines, rest = self.buffer.split(b"\n")
109
+ self.buffer = rest
110
+ for line in lines:
111
+ self.read_line(bytes(line).strip())
112
+ if len(self.buffer) > MAX_SSE_LINE_BYTES:
113
+ self.buffer.clear()
114
+
115
+ def read_line(self, line: bytes) -> None:
116
+ event = sse_event(line)
117
+ if event is None:
118
+ return
119
+ if self.family == ModelFamily.ANTHROPIC:
120
+ apply_anthropic_event(self.usage, event)
121
+ else:
122
+ apply_openai_event(self.usage, event)
123
+
124
+
125
+ def sse_event(line: bytes) -> dict[str, Any] | None:
126
+ """The JSON object in a server-sent `data:` line, or None for anything else."""
127
+ if not line.startswith(b"data:"):
128
+ return None
129
+ data = line[5:].strip()
130
+ if not data or data == b"[DONE]":
131
+ return None
132
+ try:
133
+ event = json.loads(data)
134
+ except ValueError:
135
+ return None
136
+ return event if isinstance(event, dict) else None
137
+
138
+
139
+ def apply_anthropic_event(usage: Usage, event: dict[str, Any]) -> None:
140
+ """Usage arrives at the message's start and in its final delta."""
141
+ if event.get("type") == "message_start":
142
+ message = event.get("message")
143
+ if isinstance(message, dict):
144
+ apply_anthropic_usage(usage, message.get("usage"))
145
+ elif event.get("type") == "message_delta":
146
+ apply_anthropic_usage(usage, event.get("usage"))
147
+
148
+
149
+ def apply_openai_event(usage: Usage, event: dict[str, Any]) -> None:
150
+ """Responses report usage when complete. Chat completions in a last chunk."""
151
+ if event.get("type") == "response.completed":
152
+ response = event.get("response")
153
+ if isinstance(response, dict):
154
+ apply_openai_usage(usage, response.get("usage"))
155
+ elif event.get("usage"):
156
+ apply_openai_usage(usage, event["usage"])
157
+
158
+
159
+ async def record_usage(
160
+ run_token: RunToken, family: ModelFamily, model: str, status_code: int, usage: Usage
161
+ ) -> None:
162
+ await acreate_model_usage(
163
+ run_token,
164
+ family,
165
+ model[:200],
166
+ status_code,
167
+ asdict(usage),
168
+ request_cost(run_token.connection, model, usage),
169
+ )
170
+ if usage.total:
171
+ await aadd_used_tokens(run_token, usage.budget_tokens)
@@ -0,0 +1,54 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ('connections', '0001_initial'),
13
+ ('runs', '0001_initial'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='ModelUsage',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('provider', models.CharField(max_length=24)),
22
+ ('model', models.CharField(max_length=200)),
23
+ ('status_code', models.PositiveSmallIntegerField()),
24
+ ('input_tokens', models.PositiveBigIntegerField(default=0)),
25
+ ('output_tokens', models.PositiveBigIntegerField(default=0)),
26
+ ('cache_read_tokens', models.PositiveBigIntegerField(default=0)),
27
+ ('cache_write_tokens', models.PositiveBigIntegerField(default=0)),
28
+ ('cost_usd', models.DecimalField(blank=True, decimal_places=8, max_digits=14, null=True)),
29
+ ('created_at', models.DateTimeField(auto_now_add=True)),
30
+ ('connection', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_usage', to='connections.connection')),
31
+ ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='model_usage', to='runs.run')),
32
+ ],
33
+ options={
34
+ 'ordering': ['created_at'],
35
+ },
36
+ ),
37
+ migrations.CreateModel(
38
+ name='RunToken',
39
+ fields=[
40
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
41
+ ('token_hash', models.CharField(max_length=64, unique=True)),
42
+ ('expires_at', models.DateTimeField()),
43
+ ('revoked_at', models.DateTimeField(blank=True, null=True)),
44
+ ('budget_tokens', models.PositiveBigIntegerField()),
45
+ ('used_tokens', models.PositiveBigIntegerField(default=0)),
46
+ ('created_at', models.DateTimeField(auto_now_add=True)),
47
+ ('connection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='run_tokens', to='connections.connection')),
48
+ ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='runs.run')),
49
+ ],
50
+ options={
51
+ 'ordering': ['-created_at'],
52
+ },
53
+ ),
54
+ ]
@@ -0,0 +1,19 @@
1
+ # Generated by Django 6.0.9 on 2026-11-07 23:22
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0008_agentcredentialcleanup'),
10
+ ('gateway', '0001_initial'),
11
+ ('runs', '0012_alter_rundelivery_status'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddIndex(
16
+ model_name='modelusage',
17
+ index=models.Index(fields=['created_at'], name='gateway_usage_created_at_idx'),
18
+ ),
19
+ ]
File without changes
@@ -0,0 +1,61 @@
1
+ from django.db import models
2
+
3
+ from secondshift.connections.models import Connection
4
+
5
+
6
+ class RunToken(models.Model):
7
+ """A per-run credential for the model gateway. Only its SHA-256 is stored."""
8
+
9
+ run = models.ForeignKey("runs.Run", on_delete=models.CASCADE, related_name="tokens")
10
+ connection = models.ForeignKey(
11
+ Connection,
12
+ on_delete=models.CASCADE,
13
+ related_name="run_tokens",
14
+ )
15
+ token_hash = models.CharField(max_length=64, unique=True)
16
+ expires_at = models.DateTimeField()
17
+ revoked_at = models.DateTimeField(null=True, blank=True)
18
+ budget_tokens = models.PositiveBigIntegerField()
19
+ used_tokens = models.PositiveBigIntegerField(default=0)
20
+ created_at = models.DateTimeField(auto_now_add=True)
21
+
22
+ class Meta:
23
+ ordering = ["-created_at"]
24
+
25
+ def __str__(self) -> str:
26
+ return f"Run {self.run_id} gateway token"
27
+
28
+
29
+ class ModelUsage(models.Model):
30
+ """One gateway request: what was called and what it cost in tokens."""
31
+
32
+ run = models.ForeignKey(
33
+ "runs.Run", on_delete=models.CASCADE, related_name="model_usage"
34
+ )
35
+ connection = models.ForeignKey(
36
+ Connection,
37
+ on_delete=models.SET_NULL,
38
+ related_name="model_usage",
39
+ null=True,
40
+ )
41
+ provider = models.CharField(max_length=24)
42
+ model = models.CharField(max_length=200)
43
+ status_code = models.PositiveSmallIntegerField()
44
+ input_tokens = models.PositiveBigIntegerField(default=0)
45
+ output_tokens = models.PositiveBigIntegerField(default=0)
46
+ cache_read_tokens = models.PositiveBigIntegerField(default=0)
47
+ cache_write_tokens = models.PositiveBigIntegerField(default=0)
48
+ # USD at list price. Null when the model has no known price.
49
+ cost_usd = models.DecimalField(
50
+ max_digits=14, decimal_places=8, null=True, blank=True
51
+ )
52
+ created_at = models.DateTimeField(auto_now_add=True)
53
+
54
+ class Meta:
55
+ ordering = ["created_at"]
56
+ indexes = [
57
+ models.Index(fields=["created_at"], name="gateway_usage_created_at_idx")
58
+ ]
59
+
60
+ def __str__(self) -> str:
61
+ return f"Run {self.run_id}: {self.model} ({self.status_code})"
@@ -0,0 +1,39 @@
1
+ """Request cost from genai-prices' bundled price tables (no network lookups)."""
2
+
3
+ import logging
4
+ from decimal import Decimal
5
+ from typing import TYPE_CHECKING
6
+
7
+ import genai_prices
8
+
9
+ from secondshift.connections.model_providers import base_url, model_provider
10
+ from secondshift.connections.models import Connection
11
+
12
+ if TYPE_CHECKING:
13
+ from secondshift.gateway.metering import Usage
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def request_cost(connection: Connection, model: str, usage: "Usage") -> Decimal | None:
19
+ """Price one request in USD, or None when the model has no known price."""
20
+ if not usage.total:
21
+ return Decimal(0)
22
+ # genai-prices counts cache reads and writes inside input_tokens.
23
+ priced = genai_prices.Usage(
24
+ input_tokens=usage.input_tokens
25
+ + usage.cache_read_tokens
26
+ + usage.cache_write_tokens,
27
+ output_tokens=usage.output_tokens,
28
+ cache_read_tokens=usage.cache_read_tokens,
29
+ cache_write_tokens=usage.cache_write_tokens,
30
+ )
31
+ if model_provider(connection.provider).router:
32
+ lookup: dict[str, str] = {"provider_api_url": base_url(connection)}
33
+ else:
34
+ lookup = {"provider_id": connection.provider}
35
+ try:
36
+ return genai_prices.calc_price(priced, model, **lookup).total_price
37
+ except (LookupError, ValueError):
38
+ logger.info("no price for model %s on connection %s", model, connection.pk)
39
+ return None