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,441 @@
1
+ import difflib
2
+ import logging
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import anthropic
8
+ import httpx
9
+ from django.conf import settings
10
+ from django.db import transaction
11
+
12
+ from secondshift.config.constants import ModelFamily
13
+ from secondshift.connections.checks import record_check
14
+ from secondshift.connections.models import Connection
15
+ from secondshift.connections.outbound import (
16
+ OutboundUrlError,
17
+ host_label,
18
+ outbound_sync_client,
19
+ validate_outbound_url,
20
+ )
21
+ from secondshift.workspaces.models import Workspace
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ModelProvider:
28
+ family: ModelFamily
29
+ # Empty when the owner gives the URL. Such providers may also skip the key.
30
+ # Anthropic base URLs exclude /v1, as its SDK expects. OpenAI ones include it.
31
+ default_base_url: str = ""
32
+ # Serves other vendors' models. Runs use Chat Completions, since not every
33
+ # router offers Responses, and prices are looked up by the router's URL.
34
+ router: bool = False
35
+
36
+ @property
37
+ def custom_endpoint(self) -> bool:
38
+ return not self.default_base_url
39
+
40
+
41
+ MODEL_PROVIDERS = {
42
+ Connection.Provider.ANTHROPIC: ModelProvider(
43
+ ModelFamily.ANTHROPIC, "https://api.anthropic.com"
44
+ ),
45
+ Connection.Provider.OPENAI: ModelProvider(
46
+ ModelFamily.OPENAI, "https://api.openai.com/v1"
47
+ ),
48
+ Connection.Provider.OPENROUTER: ModelProvider(
49
+ ModelFamily.OPENAI, "https://openrouter.ai/api/v1", router=True
50
+ ),
51
+ Connection.Provider.OPENAI_COMPATIBLE: ModelProvider(
52
+ ModelFamily.OPENAI, router=True
53
+ ),
54
+ }
55
+
56
+
57
+ class ModelProviderError(ValueError):
58
+ pass
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ModelProviderCheck:
63
+ ok: bool
64
+ message: str
65
+ models: list[str]
66
+
67
+
68
+ def model_provider(provider: str) -> ModelProvider:
69
+ try:
70
+ return MODEL_PROVIDERS[Connection.Provider(provider)]
71
+ except (KeyError, ValueError) as error:
72
+ raise ModelProviderError(
73
+ "Choose Anthropic, OpenAI, OpenRouter or an OpenAI-compatible provider."
74
+ ) from error
75
+
76
+
77
+ def provider_family(connection: Connection) -> ModelFamily:
78
+ return model_provider(connection.provider).family
79
+
80
+
81
+ def base_url(connection: Connection) -> str:
82
+ default = model_provider(connection.provider).default_base_url
83
+ return (connection.endpoint_url or default).rstrip("/")
84
+
85
+
86
+ def api_root(connection: Connection) -> str:
87
+ """The URL that model API paths such as `messages` are relative to."""
88
+ if provider_family(connection) == ModelFamily.ANTHROPIC:
89
+ return f"{base_url(connection)}/v1"
90
+ return base_url(connection)
91
+
92
+
93
+ def checked_api_key(provider: str, api_key: str) -> str:
94
+ api_key = api_key.strip()
95
+ if not api_key and not model_provider(provider).custom_endpoint:
96
+ raise ModelProviderError("Enter an API key.")
97
+ return api_key
98
+
99
+
100
+ def clean_models(values: list[str]) -> list[str]:
101
+ cleaned = sorted({value.strip() for value in values if value.strip()})
102
+ if any(len(value) > 200 for value in cleaned):
103
+ raise ModelProviderError("Model names are at most 200 characters.")
104
+ return cleaned
105
+
106
+
107
+ def validated_endpoint(provider: str, endpoint_url: str) -> str:
108
+ if not model_provider(provider).custom_endpoint:
109
+ # First-party providers always use their official endpoint.
110
+ return ""
111
+ if not endpoint_url.strip():
112
+ raise ModelProviderError("OpenAI-compatible providers need a base URL.")
113
+ try:
114
+ return validate_outbound_url(endpoint_url, "base URL").rstrip("/")
115
+ except OutboundUrlError as error:
116
+ raise ModelProviderError(str(error)) from error
117
+
118
+
119
+ @transaction.atomic
120
+ def create_model_connection(
121
+ workspace: Workspace,
122
+ provider: str,
123
+ display_name: str,
124
+ api_key: str,
125
+ endpoint_url: str = "",
126
+ allowed_models: list[str] | None = None,
127
+ default_model: str = "",
128
+ ) -> Connection:
129
+ api_key = checked_api_key(provider, api_key)
130
+ connection = Connection(
131
+ workspace=workspace,
132
+ provider=provider,
133
+ kind=Connection.Kind.MODEL,
134
+ display_name=display_name.strip() or Connection.Provider(provider).label,
135
+ auth_strategy=(
136
+ Connection.AuthStrategy.API_KEY if api_key else Connection.AuthStrategy.NONE
137
+ ),
138
+ endpoint_url=validated_endpoint(provider, endpoint_url),
139
+ access_token=api_key,
140
+ allowed_models=clean_models(allowed_models or []),
141
+ default_model=default_model.strip(),
142
+ )
143
+ connection.metadata = {"target": host_label(base_url(connection))}
144
+ check_default_model(connection)
145
+ connection.full_clean(validate_constraints=False)
146
+ connection.save()
147
+ return connection
148
+
149
+
150
+ @transaction.atomic
151
+ def update_model_connection(
152
+ connection: Connection,
153
+ display_name: str | None = None,
154
+ api_key: str | None = None,
155
+ endpoint_url: str | None = None,
156
+ allowed_models: list[str] | None = None,
157
+ default_model: str | None = None,
158
+ ) -> Connection:
159
+ if connection.kind != Connection.Kind.MODEL:
160
+ raise ModelProviderError("This is not a model provider connection.")
161
+ if display_name is not None:
162
+ connection.display_name = (
163
+ display_name.strip() or connection.get_provider_display()
164
+ )
165
+ if endpoint_url is not None:
166
+ connection.endpoint_url = validated_endpoint(connection.provider, endpoint_url)
167
+ connection.metadata = {
168
+ **connection.metadata,
169
+ "target": host_label(base_url(connection)),
170
+ }
171
+ if allowed_models is not None:
172
+ connection.allowed_models = clean_models(allowed_models)
173
+ if default_model is not None:
174
+ connection.default_model = default_model.strip()
175
+ if api_key and api_key.strip():
176
+ connection.access_token = api_key.strip()
177
+ connection.auth_strategy = Connection.AuthStrategy.API_KEY
178
+ connection.status = Connection.Status.CONNECTED
179
+ elif connection.status == Connection.Status.REVOKED:
180
+ raise ModelProviderError("Enter a new API key to reconnect this provider.")
181
+ check_default_model(connection)
182
+ # A saved check result described the old settings, so drop it.
183
+ connection.metadata = {
184
+ key: value for key, value in connection.metadata.items() if key != "last_check"
185
+ }
186
+ connection.full_clean(validate_constraints=False)
187
+ connection.save()
188
+ return connection
189
+
190
+
191
+ def check_default_model(connection: Connection) -> None:
192
+ if (
193
+ connection.default_model
194
+ and connection.allowed_models
195
+ and connection.default_model not in connection.allowed_models
196
+ ):
197
+ raise ModelProviderError("The default model must be one of the allowed models.")
198
+
199
+
200
+ def list_anthropic_models(connection: Connection) -> list[str]:
201
+ client = anthropic.Anthropic(
202
+ api_key=connection.access_token,
203
+ base_url=base_url(connection),
204
+ max_retries=0,
205
+ timeout=settings.OUTBOUND_TIMEOUT_SECONDS,
206
+ http_client=outbound_sync_client(),
207
+ )
208
+ return [model.id for model in client.models.list()]
209
+
210
+
211
+ def list_openai_models(connection: Connection) -> list[str]:
212
+ url = validate_outbound_url(f"{base_url(connection)}/models", "base URL")
213
+ headers = {"Accept": "application/json"}
214
+ if connection.access_token:
215
+ headers["Authorization"] = f"Bearer {connection.access_token}"
216
+ with outbound_sync_client() as client:
217
+ response = client.get(url, headers=headers)
218
+ if response.status_code in {401, 403}:
219
+ raise ModelProviderError("The provider rejected the API key.")
220
+ if response.status_code != 200:
221
+ raise ModelProviderError(f"The provider answered HTTP {response.status_code}.")
222
+ try:
223
+ return [str(item["id"]) for item in response.json().get("data", [])]
224
+ except (ValueError, KeyError, TypeError, AttributeError) as error:
225
+ raise ModelProviderError(
226
+ "The provider returned an unexpected model list."
227
+ ) from error
228
+
229
+
230
+ # One short request is enough to prove the key can run the model.
231
+ TEST_PROMPT = "Reply with OK."
232
+ TEST_MAX_TOKENS = 16
233
+ MAX_PROVIDER_MESSAGE = 200
234
+
235
+
236
+ def shorten(text: str) -> str:
237
+ line = " ".join(text.split())
238
+ return (
239
+ line
240
+ if len(line) <= MAX_PROVIDER_MESSAGE
241
+ else line[: MAX_PROVIDER_MESSAGE - 1] + "…"
242
+ )
243
+
244
+
245
+ def provider_message(response: httpx.Response) -> str:
246
+ try:
247
+ body: Any = response.json()
248
+ except ValueError:
249
+ return ""
250
+ if not isinstance(body, dict):
251
+ return ""
252
+ nested = body.get("error")
253
+ if isinstance(nested, dict) and isinstance(nested.get("message"), str):
254
+ return shorten(nested["message"])
255
+ return shorten(body["message"]) if isinstance(body.get("message"), str) else ""
256
+
257
+
258
+ def call_failure(status_code: int, model: str, detail: str) -> str:
259
+ if status_code in {401, 403}:
260
+ reason = "The provider rejected the API key for model calls"
261
+ elif status_code == 402:
262
+ reason = "The account has no credit for model calls"
263
+ elif status_code == 404:
264
+ reason = f"The provider does not offer {model}"
265
+ else:
266
+ reason = f"A test call to {model} failed with HTTP {status_code}"
267
+ return f"{reason}: {detail}" if detail else f"{reason}."
268
+
269
+
270
+ def call_anthropic(connection: Connection, model: str) -> str | None:
271
+ client = anthropic.Anthropic(
272
+ api_key=connection.access_token,
273
+ base_url=base_url(connection),
274
+ max_retries=0,
275
+ timeout=settings.OUTBOUND_TIMEOUT_SECONDS,
276
+ http_client=outbound_sync_client(),
277
+ )
278
+ try:
279
+ client.messages.create(
280
+ model=model,
281
+ max_tokens=TEST_MAX_TOKENS,
282
+ messages=[{"role": "user", "content": TEST_PROMPT}],
283
+ )
284
+ except anthropic.APIStatusError as error:
285
+ return call_failure(error.status_code, model, shorten(error.message))
286
+ return None
287
+
288
+
289
+ def call_openai_family(connection: Connection, model: str) -> str | None:
290
+ if not model_provider(connection.provider).router:
291
+ # Runs use the Responses API for OpenAI itself.
292
+ path = "responses"
293
+ body: dict[str, Any] = {
294
+ "model": model,
295
+ "input": TEST_PROMPT,
296
+ "max_output_tokens": TEST_MAX_TOKENS,
297
+ }
298
+ else:
299
+ path = "chat/completions"
300
+ body = {
301
+ "model": model,
302
+ "messages": [{"role": "user", "content": TEST_PROMPT}],
303
+ "max_tokens": TEST_MAX_TOKENS,
304
+ }
305
+ url = validate_outbound_url(f"{base_url(connection)}/{path}", "base URL")
306
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
307
+ if connection.access_token:
308
+ headers["Authorization"] = f"Bearer {connection.access_token}"
309
+ with outbound_sync_client() as client:
310
+ response = client.post(url, json=body, headers=headers)
311
+ if response.status_code == 200:
312
+ return None
313
+ return call_failure(response.status_code, model, provider_message(response))
314
+
315
+
316
+ def try_model_call(connection: Connection) -> str | None:
317
+ """Send one tiny request to the default model; return why it failed, if it did.
318
+
319
+ Listing models can succeed with a key that cannot run any of them (an
320
+ account without access, say), so a real call is the only reliable check.
321
+ """
322
+ model = connection.default_model
323
+ if provider_family(connection) == ModelFamily.ANTHROPIC:
324
+ return call_anthropic(connection, model)
325
+ return call_openai_family(connection, model)
326
+
327
+
328
+ def comparable_model(name: str) -> str:
329
+ """Drop a vendor prefix and punctuation, so close IDs compare equal."""
330
+ return re.sub(r"[^a-z0-9]+", "-", name.lower().rsplit("/", 1)[-1]).strip("-")
331
+
332
+
333
+ def closest_model(model: str, models: list[str]) -> str | None:
334
+ """The listed model the owner most likely meant, if one is close."""
335
+ by_key: dict[str, str] = {}
336
+ for item in models:
337
+ by_key.setdefault(comparable_model(item), item)
338
+ matches = difflib.get_close_matches(
339
+ comparable_model(model), list(by_key), n=1, cutoff=0.8
340
+ )
341
+ return by_key[matches[0]] if matches else None
342
+
343
+
344
+ def check_model_connection(connection: Connection) -> ModelProviderCheck:
345
+ """Check the key lists models and can run the default one. Never echoes the key."""
346
+ check = run_model_check(connection)
347
+ record_check(connection, check.ok, check.message)
348
+ return check
349
+
350
+
351
+ class CheckFailed(Exception):
352
+ """A provider problem, worded for the person checking the connection."""
353
+
354
+
355
+ def run_model_check(connection: Connection) -> ModelProviderCheck:
356
+ try:
357
+ models = fetch_models(connection)
358
+ except CheckFailed as failure:
359
+ return ModelProviderCheck(False, str(failure), [])
360
+ unavailable = sorted(set(connection.allowed_models) - set(models))
361
+ if unavailable:
362
+ return ModelProviderCheck(
363
+ False,
364
+ "Connected, but these allowed models are not offered: "
365
+ + ", ".join(unavailable),
366
+ models,
367
+ )
368
+ if not connection.default_model:
369
+ return ModelProviderCheck(
370
+ True,
371
+ f"Connected. {len(models)} models available. "
372
+ "Set a default model to test a model call.",
373
+ models,
374
+ )
375
+ if failure := model_call_failure(connection):
376
+ return ModelProviderCheck(
377
+ False, failure + model_hint(connection.default_model, models), models
378
+ )
379
+ return ModelProviderCheck(
380
+ True,
381
+ f"Connected. {connection.default_model} answered a test request.",
382
+ models,
383
+ )
384
+
385
+
386
+ def fetch_models(connection: Connection) -> list[str]:
387
+ """The provider's models, saved on the connection."""
388
+ try:
389
+ if provider_family(connection) == ModelFamily.ANTHROPIC:
390
+ models = list_anthropic_models(connection)
391
+ else:
392
+ models = list_openai_models(connection)
393
+ except anthropic.AuthenticationError as error:
394
+ raise CheckFailed("The provider rejected the API key.") from error
395
+ except anthropic.PermissionDeniedError as error:
396
+ raise CheckFailed("The API key cannot list models.") from error
397
+ except anthropic.APIStatusError as error:
398
+ raise CheckFailed(f"The provider answered HTTP {error.status_code}.") from error
399
+ except anthropic.APIConnectionError as error:
400
+ raise CheckFailed("The provider could not be reached.") from error
401
+ except (ModelProviderError, OutboundUrlError) as error:
402
+ raise CheckFailed(str(error)) from error
403
+ except httpx.HTTPError as error:
404
+ logger.warning(
405
+ "model provider check for connection %s failed: %s",
406
+ connection.pk,
407
+ type(error).__name__,
408
+ )
409
+ raise CheckFailed("The provider could not be reached.") from error
410
+ models = sorted(set(models))
411
+ connection.metadata = {**connection.metadata, "available_models": models}
412
+ if connection.pk:
413
+ connection.save(update_fields=["metadata", "updated_at"])
414
+ return models
415
+
416
+
417
+ def model_call_failure(connection: Connection) -> str | None:
418
+ """Why a small call to the default model failed, or None when it worked."""
419
+ try:
420
+ return try_model_call(connection)
421
+ except anthropic.APIConnectionError:
422
+ return "The provider could not be reached for a test call."
423
+ except OutboundUrlError as error:
424
+ return str(error)
425
+ except httpx.HTTPError as error:
426
+ logger.warning(
427
+ "model call check for connection %s failed: %s",
428
+ connection.pk,
429
+ type(error).__name__,
430
+ )
431
+ return "The provider could not be reached for a test call."
432
+
433
+
434
+ def model_hint(model: str, models: list[str]) -> str:
435
+ """When the default model is not listed, the listed name it probably means."""
436
+ if model in models:
437
+ return ""
438
+ suggestion = closest_model(model, models)
439
+ if suggestion:
440
+ return f" The provider lists it as {suggestion}."
441
+ return " It is not in the provider's model list."
@@ -0,0 +1,246 @@
1
+ import uuid
2
+ from typing import Any
3
+
4
+ from django.conf import settings
5
+ from django.core.exceptions import ValidationError
6
+ from django.core.validators import MinValueValidator
7
+ from django.db import models
8
+ from django.utils import timezone
9
+ from encrypted_model_fields.fields import EncryptedTextField
10
+
11
+ from secondshift.workspaces.models import Workspace
12
+
13
+
14
+ class Connection(models.Model):
15
+ class Provider(models.TextChoices):
16
+ GITHUB = "github", "GitHub"
17
+ SENTRY = "sentry", "Sentry"
18
+ MCP = "mcp", "MCP server"
19
+ API = "api", "HTTP API"
20
+ WEBHOOK = "webhook", "Webhook"
21
+ ANTHROPIC = "anthropic", "Anthropic"
22
+ OPENAI = "openai", "OpenAI"
23
+ OPENROUTER = "openrouter", "OpenRouter"
24
+ OPENAI_COMPATIBLE = "openai_compatible", "OpenAI-compatible"
25
+
26
+ class Kind(models.TextChoices):
27
+ SOURCE = "source", "Repository source"
28
+ MCP = "mcp", "MCP"
29
+ API = "api", "API"
30
+ NOTIFICATION = "notification", "Notification"
31
+ MODEL = "model", "Model provider"
32
+
33
+ class AuthStrategy(models.TextChoices):
34
+ OAUTH = "oauth", "OAuth"
35
+ API_KEY = "api_key", "Encrypted API key"
36
+ MANAGED = "managed", "Managed by service"
37
+ NONE = "none", "None"
38
+
39
+ class Status(models.TextChoices):
40
+ CONNECTED = "connected", "Connected"
41
+ EXPIRED = "expired", "Expired"
42
+ REVOKED = "revoked", "Revoked"
43
+
44
+ workspace = models.ForeignKey(
45
+ Workspace,
46
+ on_delete=models.CASCADE,
47
+ related_name="connections",
48
+ )
49
+ provider = models.CharField(max_length=24, choices=Provider.choices)
50
+ kind = models.CharField(max_length=24, choices=Kind.choices)
51
+ binding_key = models.SlugField(
52
+ max_length=120,
53
+ blank=True,
54
+ help_text="Logical name used by repository manifests, for example observability.",
55
+ )
56
+ display_name = models.CharField(max_length=200)
57
+ endpoint_url = models.URLField(blank=True)
58
+ external_account_id = models.CharField(max_length=255, blank=True)
59
+ status = models.CharField(
60
+ max_length=16,
61
+ choices=Status.choices,
62
+ default=Status.CONNECTED,
63
+ )
64
+ scopes = models.JSONField(default=list, blank=True)
65
+ available_tools = models.JSONField(default=list, blank=True)
66
+ # Model providers only. Runs may call just these models. Empty allows any.
67
+ allowed_models = models.JSONField(default=list, blank=True)
68
+ default_model = models.CharField(max_length=200, blank=True)
69
+ auth_strategy = models.CharField(
70
+ max_length=24,
71
+ choices=AuthStrategy.choices,
72
+ default=AuthStrategy.OAUTH,
73
+ )
74
+ # Webhook URLs usually embed their own token, so they are stored encrypted
75
+ # and only a redacted target label is ever returned.
76
+ delivery_url = EncryptedTextField(blank=True, default="")
77
+ access_token = EncryptedTextField(blank=True, default="")
78
+ refresh_token = EncryptedTextField(blank=True, default="")
79
+ expires_at = models.DateTimeField(null=True, blank=True)
80
+ metadata = models.JSONField(default=dict, blank=True)
81
+ # Capability connections only. When off, a job that asks only for allowed
82
+ # tools is approved without an owner, so merging the manifest is the review.
83
+ require_approval = models.BooleanField(default=False)
84
+ # GitHub sources only. Whether pull requests, issues and commits say
85
+ # SecondShift made them. None keeps the default for the connection's kind.
86
+ attribution = models.BooleanField(null=True, blank=True)
87
+ created_at = models.DateTimeField(auto_now_add=True)
88
+ updated_at = models.DateTimeField(auto_now=True)
89
+
90
+ class Meta:
91
+ constraints = [
92
+ models.UniqueConstraint(
93
+ fields=["workspace", "provider", "external_account_id"],
94
+ condition=~models.Q(external_account_id=""),
95
+ name="unique_workspace_provider_account",
96
+ ),
97
+ models.UniqueConstraint(
98
+ fields=["workspace", "binding_key"],
99
+ condition=~models.Q(binding_key=""),
100
+ name="unique_workspace_connection_binding",
101
+ ),
102
+ ]
103
+ ordering = ["provider", "display_name"]
104
+
105
+ def __str__(self) -> str:
106
+ binding = f" [{self.binding_key}]" if self.binding_key else ""
107
+ return f"{self.get_provider_display()}: {self.display_name}{binding}"
108
+
109
+ @property
110
+ def tool_definitions(self) -> dict[str, Any]:
111
+ """Each offered tool's definition, by provider tool name."""
112
+ return self.metadata.get("tools") or {}
113
+
114
+ @property
115
+ def settings(self) -> dict[str, str]:
116
+ """Owner-chosen settings, such as an MCP scope. Never a credential."""
117
+ return dict(self.metadata.get("settings", {}))
118
+
119
+ @property
120
+ def offered_tools(self) -> dict[str, Any]:
121
+ """Every tool the server offers, when the owner chooses among them."""
122
+ return self.metadata.get("offered_tools") or {}
123
+
124
+ @property
125
+ def last_check(self) -> dict[str, Any] | None:
126
+ """The latest check or delivery result. See checks.record_check."""
127
+ return self.metadata.get("last_check")
128
+
129
+ @property
130
+ def failed_last_check(self) -> bool:
131
+ return (self.last_check or {}).get("ok") is False
132
+
133
+ @property
134
+ def token_expired(self) -> bool:
135
+ """The saved token is past its expiry. It is never sent."""
136
+ return bool(self.expires_at and self.expires_at <= timezone.now())
137
+
138
+ @property
139
+ def current_status(self) -> str:
140
+ """The saved status, or expired once a token that cannot refresh runs out.
141
+
142
+ An OAuth token past its expiry still counts as connected. It refreshes
143
+ when used, and a refused refresh saves the expired status.
144
+ """
145
+ if (
146
+ self.status == self.Status.CONNECTED
147
+ and self.auth_strategy != self.AuthStrategy.OAUTH
148
+ and self.token_expired
149
+ ):
150
+ return self.Status.EXPIRED
151
+ return self.status
152
+
153
+ @property
154
+ def is_connected(self) -> bool:
155
+ return self.current_status == self.Status.CONNECTED
156
+
157
+ def clean(self) -> None:
158
+ # Repositories never name model providers, so they need no binding.
159
+ if self.kind != self.Kind.MODEL and not self.binding_key:
160
+ raise ValidationError(
161
+ {"binding_key": "Capability connections require a logical binding."}
162
+ )
163
+
164
+
165
+ class OAuthAuthorization(models.Model):
166
+ """One browser sign-in in progress. Single use, and short-lived."""
167
+
168
+ workspace = models.ForeignKey(
169
+ Workspace,
170
+ on_delete=models.CASCADE,
171
+ related_name="oauth_authorizations",
172
+ )
173
+ user = models.ForeignKey(
174
+ settings.AUTH_USER_MODEL,
175
+ on_delete=models.CASCADE,
176
+ related_name="oauth_authorizations",
177
+ )
178
+ # Set when reconnecting an existing connection.
179
+ connection = models.ForeignKey(
180
+ Connection,
181
+ on_delete=models.CASCADE,
182
+ null=True,
183
+ blank=True,
184
+ related_name="oauth_authorizations",
185
+ )
186
+ state = models.CharField(max_length=64, unique=True)
187
+ code_verifier = EncryptedTextField()
188
+ provider = models.CharField(max_length=24, choices=Connection.Provider.choices)
189
+ binding_key = models.SlugField(max_length=120, blank=True)
190
+ display_name = models.CharField(max_length=200, blank=True)
191
+ connection_settings = models.JSONField(default=dict, blank=True)
192
+ scopes = models.JSONField(default=list, blank=True)
193
+ # The new connection's setting. A reconnect keeps the connection's own.
194
+ require_approval = models.BooleanField(default=False)
195
+ # Issuer, token endpoint, client ID, and resource. None of these are secret.
196
+ oauth = models.JSONField(default=dict, blank=True)
197
+ created_at = models.DateTimeField(auto_now_add=True)
198
+ used_at = models.DateTimeField(null=True, blank=True)
199
+
200
+ def __str__(self) -> str:
201
+ return f"OAuth sign-in for {self.binding_key or self.provider}"
202
+
203
+
204
+ class AgentConnection(models.Model):
205
+ """A person's agent login. Authentication is never part of a run plan."""
206
+
207
+ workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
208
+ owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
209
+ backend = models.CharField(max_length=24)
210
+ display_name = models.CharField(max_length=200)
211
+ auth_data = EncryptedTextField()
212
+ credential_key = models.UUIDField(default=uuid.uuid4, editable=False)
213
+ connected = models.BooleanField(default=True)
214
+ # Empty means no cap. Runs past it in a rolling 24 hours are refused.
215
+ max_runs_per_day = models.PositiveIntegerField(
216
+ null=True, blank=True, validators=[MinValueValidator(1)]
217
+ )
218
+ # The last login check: ok, reason, message and checked_at. Never CLI output.
219
+ last_check = models.JSONField(null=True, blank=True)
220
+ created_at = models.DateTimeField(auto_now_add=True)
221
+ updated_at = models.DateTimeField(auto_now=True)
222
+
223
+
224
+ class AgentCredentialCleanup(models.Model):
225
+ """A runner's cached copy of a login that is still to be removed.
226
+
227
+ The row is saved with the disconnect or reconnect that replaced the login.
228
+ It is deleted once the runner confirms the copy is gone. The worker
229
+ retries the rows that remain.
230
+ """
231
+
232
+ credential_key = models.UUIDField(unique=True)
233
+ # Empty once the login itself is gone. The copy is still removed.
234
+ connection = models.ForeignKey(
235
+ AgentConnection,
236
+ on_delete=models.SET_NULL,
237
+ null=True,
238
+ blank=True,
239
+ related_name="credential_cleanups",
240
+ )
241
+ attempts = models.PositiveIntegerField(default=0)
242
+ created_at = models.DateTimeField(auto_now_add=True)
243
+ updated_at = models.DateTimeField(auto_now=True)
244
+
245
+ def __str__(self) -> str:
246
+ return f"Runner cleanup for {self.credential_key.hex}"