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,369 @@
1
+ """Model gateway: the only way a run reaches a model provider.
2
+
3
+ A run authenticates with a short-lived token bound to that run. The gateway
4
+ checks the path, model and token budget, injects the workspace's API key and
5
+ streams the provider's answer back, recording token usage. The run never sees
6
+ the provider key.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import weakref
13
+ from collections.abc import AsyncIterator
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import httpx
18
+ from asgiref.sync import sync_to_async
19
+ from django.conf import settings
20
+ from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
21
+
22
+ from secondshift.config.constants import TOKEN_BUDGET_MESSAGE, ModelFamily
23
+ from secondshift.connections.model_providers import api_root, provider_family
24
+ from secondshift.connections.models import Connection
25
+ from secondshift.connections.outbound import (
26
+ OutboundUrlError,
27
+ PinnedTransport,
28
+ validate_outbound_url,
29
+ )
30
+ from secondshift.gateway.metering import StreamMeter, record_usage, usage_from_json
31
+ from secondshift.gateway.models import RunToken
32
+ from secondshift.gateway.tokens import aauthenticate, request_token
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ ANTHROPIC_VERSION = "2023-06-01"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FamilyRoutes:
41
+ # Paths a run may call, relative to the provider's API root. Only the
42
+ # billed ones are metered.
43
+ allowed: frozenset[str]
44
+ metered: frozenset[str]
45
+ forwarded_headers: tuple[str, ...] = ()
46
+
47
+
48
+ OPENAI_PATHS = frozenset({"chat/completions", "responses"})
49
+ ROUTES = {
50
+ ModelFamily.ANTHROPIC: FamilyRoutes(
51
+ allowed=frozenset({"messages", "messages/count_tokens"}),
52
+ metered=frozenset({"messages"}),
53
+ forwarded_headers=("anthropic-version", "anthropic-beta"),
54
+ ),
55
+ ModelFamily.OPENAI: FamilyRoutes(allowed=OPENAI_PATHS, metered=OPENAI_PATHS),
56
+ }
57
+ FORWARDED_RESPONSE_HEADERS = (
58
+ "request-id",
59
+ "x-request-id",
60
+ "retry-after",
61
+ )
62
+
63
+
64
+ # One pooled client per event loop: connections to providers are reused across
65
+ # requests, and a client is never shared between loops.
66
+ clients: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, httpx.AsyncClient]" = (
67
+ weakref.WeakKeyDictionary()
68
+ )
69
+
70
+
71
+ def upstream_client() -> httpx.AsyncClient:
72
+ loop = asyncio.get_running_loop()
73
+ client = clients.get(loop)
74
+ if client is None:
75
+ client = httpx.AsyncClient(
76
+ transport=PinnedTransport(),
77
+ follow_redirects=False,
78
+ timeout=httpx.Timeout(
79
+ settings.OUTBOUND_TIMEOUT_SECONDS,
80
+ read=settings.GATEWAY_READ_TIMEOUT_SECONDS,
81
+ ),
82
+ )
83
+ clients[loop] = client
84
+ return client
85
+
86
+
87
+ def error_response(
88
+ family: ModelFamily, status: int, kind: str, message: str
89
+ ) -> HttpResponse:
90
+ """An error in the provider's own shape, so agent SDKs surface it cleanly."""
91
+ if family == ModelFamily.ANTHROPIC:
92
+ body: dict[str, Any] = {
93
+ "type": "error",
94
+ "error": {"type": kind, "message": message},
95
+ }
96
+ else:
97
+ body = {"error": {"message": message, "type": kind, "code": kind}}
98
+ return HttpResponse(
99
+ json.dumps(body), status=status, content_type="application/json"
100
+ )
101
+
102
+
103
+ def upstream_headers(
104
+ request: HttpRequest, family: ModelFamily, connection: Connection
105
+ ) -> dict[str, str]:
106
+ headers = {
107
+ "Content-Type": "application/json",
108
+ "Accept": request.headers.get("accept", "application/json"),
109
+ "User-Agent": "SecondShift-Gateway/1",
110
+ }
111
+ for name in ROUTES[family].forwarded_headers:
112
+ if value := request.headers.get(name):
113
+ headers[name] = value
114
+ if family == ModelFamily.ANTHROPIC:
115
+ headers.setdefault("anthropic-version", ANTHROPIC_VERSION)
116
+ headers["x-api-key"] = connection.access_token
117
+ elif connection.access_token:
118
+ headers["Authorization"] = f"Bearer {connection.access_token}"
119
+ return headers
120
+
121
+
122
+ def copy_headers(upstream: httpx.Response, response: HttpResponse) -> None:
123
+ for name in FORWARDED_RESPONSE_HEADERS:
124
+ if value := upstream.headers.get(name):
125
+ response[name] = value
126
+
127
+
128
+ class GatewayRefusal(Exception):
129
+ """A request the gateway answers itself, without calling the provider."""
130
+
131
+ def __init__(
132
+ self, family: ModelFamily, status: int, kind: str, message: str
133
+ ) -> None:
134
+ super().__init__(message)
135
+ self.response = error_response(family, status, kind, message)
136
+
137
+
138
+ async def forward(request: HttpRequest, family_name: str, path: str) -> HttpResponse:
139
+ if family_name not in ROUTES:
140
+ return error_response(
141
+ ModelFamily.OPENAI, 404, "not_found_error", "Unknown API family."
142
+ )
143
+ family = ModelFamily(family_name)
144
+ try:
145
+ run_token = await authorize(request, family, path)
146
+ raw, payload = await read_payload(request, family)
147
+ model, metered = check_request(run_token, family, path, payload)
148
+ raw = with_stream_usage(family, path, payload, raw)
149
+ upstream = await send_upstream(request, run_token, family, path, raw)
150
+ response = await relay(upstream, run_token, family, model, metered)
151
+ except GatewayRefusal as refusal:
152
+ return refusal.response
153
+ copy_headers(upstream, response)
154
+ return response
155
+
156
+
157
+ async def authorize(request: HttpRequest, family: ModelFamily, path: str) -> RunToken:
158
+ """The run token, if it may call this provider's endpoint."""
159
+ run_token = await aauthenticate(request_token(request))
160
+ if run_token is None:
161
+ raise GatewayRefusal(
162
+ family, 401, "authentication_error", "Invalid or expired run token."
163
+ )
164
+ if provider_family(run_token.connection) != family:
165
+ raise GatewayRefusal(
166
+ family,
167
+ 404,
168
+ "not_found_error",
169
+ "This run's model provider does not serve this API.",
170
+ )
171
+ if request.method != "POST" or path not in ROUTES[family].allowed:
172
+ raise GatewayRefusal(
173
+ family, 404, "not_found_error", "This endpoint is not available."
174
+ )
175
+ return run_token
176
+
177
+
178
+ async def read_payload(
179
+ request: HttpRequest, family: ModelFamily
180
+ ) -> tuple[bytes, dict[str, Any]]:
181
+ """The raw body and its JSON, refused when too large or without a model."""
182
+ limit = settings.GATEWAY_MAX_BODY_BYTES
183
+ try:
184
+ declared = int(request.headers.get("content-length") or 0)
185
+ except ValueError:
186
+ declared = 0
187
+ # The ASGI handler has already spooled the body. Read it off the event loop.
188
+ raw = (
189
+ b""
190
+ if declared > limit
191
+ else await sync_to_async(request.read, thread_sensitive=False)(limit + 1)
192
+ )
193
+ if declared > limit or len(raw) > limit:
194
+ raise GatewayRefusal(
195
+ family, 413, "request_too_large", "The request body is too large."
196
+ )
197
+ try:
198
+ payload = json.loads(raw)
199
+ except ValueError as error:
200
+ raise GatewayRefusal(
201
+ family, 400, "invalid_request_error", "The body must be JSON."
202
+ ) from error
203
+ if not isinstance(payload, dict) or not isinstance(payload.get("model"), str):
204
+ raise GatewayRefusal(
205
+ family, 400, "invalid_request_error", "The request must name a model."
206
+ )
207
+ return raw, payload
208
+
209
+
210
+ def check_request(
211
+ run_token: RunToken, family: ModelFamily, path: str, payload: dict[str, Any]
212
+ ) -> tuple[str, bool]:
213
+ """The model, and whether the call counts against the token budget."""
214
+ model = payload["model"]
215
+ allowed = run_token.connection.allowed_models
216
+ if allowed and model not in allowed:
217
+ raise GatewayRefusal(
218
+ family,
219
+ 403,
220
+ "permission_error",
221
+ f"Model {model} is not allowed for this workspace.",
222
+ )
223
+ metered = path in ROUTES[family].metered
224
+ if metered and run_token.used_tokens >= run_token.budget_tokens:
225
+ refusal = GatewayRefusal(family, 429, "rate_limit_error", TOKEN_BUDGET_MESSAGE)
226
+ # Provider SDKs retry a 429 unless told not to. Waiting never helps here.
227
+ refusal.response["x-should-retry"] = "false"
228
+ raise refusal
229
+ return model, metered
230
+
231
+
232
+ def with_stream_usage(
233
+ family: ModelFamily, path: str, payload: dict[str, Any], raw: bytes
234
+ ) -> bytes:
235
+ """Without this, streamed OpenAI chat completions never report usage."""
236
+ if not (
237
+ family == ModelFamily.OPENAI
238
+ and path == "chat/completions"
239
+ and payload.get("stream")
240
+ ):
241
+ return raw
242
+ options = payload.get("stream_options")
243
+ payload["stream_options"] = {
244
+ **(options if isinstance(options, dict) else {}),
245
+ "include_usage": True,
246
+ }
247
+ return json.dumps(payload).encode()
248
+
249
+
250
+ async def send_upstream(
251
+ request: HttpRequest,
252
+ run_token: RunToken,
253
+ family: ModelFamily,
254
+ path: str,
255
+ raw: bytes,
256
+ ) -> httpx.Response:
257
+ connection = run_token.connection
258
+ root = api_root(connection)
259
+ try:
260
+ # Scheme and userinfo rules. The transport re-checks addresses on connect.
261
+ validate_outbound_url(root, "provider URL", resolve=False)
262
+ except OutboundUrlError as error:
263
+ raise GatewayRefusal(family, 502, "api_error", str(error)) from error
264
+ url = f"{root}/{path}"
265
+ client = upstream_client()
266
+ try:
267
+ return await client.send(
268
+ client.build_request(
269
+ "POST",
270
+ url,
271
+ content=raw,
272
+ headers=upstream_headers(request, family, connection),
273
+ ),
274
+ stream=True,
275
+ )
276
+ except httpx.TimeoutException as error:
277
+ raise GatewayRefusal(
278
+ family, 504, "api_error", "The provider timed out."
279
+ ) from error
280
+ except httpx.HTTPError as error:
281
+ logger.warning(
282
+ "gateway request for run %s failed: %s",
283
+ run_token.run_id,
284
+ type(error).__name__,
285
+ )
286
+ raise GatewayRefusal(
287
+ family, 502, "api_error", "The provider could not be reached."
288
+ ) from error
289
+
290
+
291
+ async def relay(
292
+ upstream: httpx.Response,
293
+ run_token: RunToken,
294
+ family: ModelFamily,
295
+ model: str,
296
+ metered: bool,
297
+ ) -> HttpResponse:
298
+ """Pass the provider's answer on, metering its token use."""
299
+ content_type = upstream.headers.get("content-type", "application/json")
300
+ if content_type.startswith("text/event-stream"):
301
+ response = StreamingHttpResponse(
302
+ relay_stream(upstream, run_token, family, model, metered),
303
+ status=upstream.status_code,
304
+ content_type=content_type,
305
+ )
306
+ response["Cache-Control"] = "no-cache"
307
+ response["X-Accel-Buffering"] = "no"
308
+ return response
309
+ try:
310
+ content = await upstream.aread()
311
+ except httpx.HTTPError as error:
312
+ raise GatewayRefusal(
313
+ family, 502, "api_error", "The provider response was interrupted."
314
+ ) from error
315
+ finally:
316
+ await upstream.aclose()
317
+ if metered:
318
+ try:
319
+ body = json.loads(content)
320
+ except ValueError:
321
+ body = None
322
+ await record_usage(
323
+ run_token,
324
+ family,
325
+ model,
326
+ upstream.status_code,
327
+ usage_from_json(family, body),
328
+ )
329
+ return HttpResponse(content, status=upstream.status_code, content_type=content_type)
330
+
331
+
332
+ async def relay_stream(
333
+ upstream: httpx.Response,
334
+ run_token: RunToken,
335
+ family: ModelFamily,
336
+ model: str,
337
+ metered: bool,
338
+ ) -> AsyncIterator[bytes]:
339
+ meter = StreamMeter(family)
340
+ try:
341
+ # aiter_bytes yields each chunk as it arrives, decompressed.
342
+ async for chunk in upstream.aiter_bytes():
343
+ meter.feed(chunk)
344
+ yield chunk
345
+ except httpx.HTTPError as error:
346
+ logger.warning(
347
+ "gateway stream for run %s ended early: %s",
348
+ run_token.run_id,
349
+ type(error).__name__,
350
+ )
351
+ finally:
352
+ # Runs on completion and when the client disconnects (Django closes the
353
+ # generator). Shielded so a second cancellation cannot lose the usage.
354
+ await asyncio.shield(
355
+ finish_stream(upstream, run_token, family, model, metered, meter)
356
+ )
357
+
358
+
359
+ async def finish_stream(
360
+ upstream: httpx.Response,
361
+ run_token: RunToken,
362
+ family: ModelFamily,
363
+ model: str,
364
+ metered: bool,
365
+ meter: StreamMeter,
366
+ ) -> None:
367
+ await upstream.aclose()
368
+ if metered:
369
+ await record_usage(run_token, family, model, upstream.status_code, meter.usage)
@@ -0,0 +1,68 @@
1
+ """Per-run gateway credentials. Only a token's SHA-256 is ever stored."""
2
+
3
+ import hashlib
4
+ import secrets
5
+ from datetime import datetime
6
+
7
+ from django.http import HttpRequest
8
+
9
+ from secondshift.connections.models import Connection
10
+ from secondshift.gateway.dao import aactive_token, active_token, create_run_token
11
+ from secondshift.gateway.models import RunToken
12
+
13
+ TOKEN_PREFIX = "ssrt_"
14
+
15
+
16
+ class GatewayUnavailable(Exception):
17
+ pass
18
+
19
+
20
+ def token_digest(token: str) -> str:
21
+ return hashlib.sha256(token.encode()).hexdigest()
22
+
23
+
24
+ def issue_token(
25
+ run_id: int,
26
+ workspace_id: int,
27
+ connection: Connection,
28
+ expires_at: datetime,
29
+ budget_tokens: int,
30
+ ) -> str:
31
+ """Create a gateway token for one run. The plaintext is returned once."""
32
+ if (
33
+ connection.kind != Connection.Kind.MODEL
34
+ or connection.workspace_id != workspace_id
35
+ ):
36
+ raise GatewayUnavailable("The model provider belongs to another workspace.")
37
+ token = TOKEN_PREFIX + secrets.token_urlsafe(32)
38
+ create_run_token(run_id, connection, token_digest(token), expires_at, budget_tokens)
39
+ return token
40
+
41
+
42
+ def same_workspace(run_token: RunToken | None) -> RunToken | None:
43
+ if run_token is None:
44
+ return None
45
+ if run_token.connection.workspace_id != run_token.run.workspace_id:
46
+ return None
47
+ return run_token
48
+
49
+
50
+ def authenticate(token: str) -> RunToken | None:
51
+ if not token.startswith(TOKEN_PREFIX):
52
+ return None
53
+ return same_workspace(active_token(token_digest(token)))
54
+
55
+
56
+ async def aauthenticate(token: str) -> RunToken | None:
57
+ if not token.startswith(TOKEN_PREFIX):
58
+ return None
59
+ return same_workspace(await aactive_token(token_digest(token)))
60
+
61
+
62
+ def request_token(request: HttpRequest) -> str:
63
+ # Anthropic SDKs send x-api-key. OpenAI SDKs send a bearer token.
64
+ if api_key := request.headers.get("x-api-key"):
65
+ return api_key.strip()
66
+ authorization = request.headers.get("authorization", "")
67
+ scheme, _, value = authorization.partition(" ")
68
+ return value.strip() if scheme.lower() == "bearer" else ""
@@ -0,0 +1,17 @@
1
+ """Internal gateway routes: model traffic, run-scoped agent tools, and health."""
2
+
3
+ from django.urls import path
4
+ from health_check.views import HealthCheckView
5
+
6
+ from secondshift.config.constants import AGENT_BROKER_PATH
7
+ from secondshift.gateway.views import model_gateway
8
+ from secondshift.runs.execution.agent_broker import agent_mcp
9
+
10
+ urlpatterns = [
11
+ path(AGENT_BROKER_PATH, agent_mcp),
12
+ path("gateway/<str:family>/v1/<path:path>", model_gateway),
13
+ path(
14
+ "-/health/",
15
+ HealthCheckView.as_view(checks=["health_check.checks.Database"]),
16
+ ),
17
+ ]
@@ -0,0 +1,11 @@
1
+ from django.http import HttpRequest, HttpResponse
2
+ from django.views.decorators.csrf import csrf_exempt
3
+
4
+ from secondshift.gateway.proxy import forward
5
+
6
+
7
+ @csrf_exempt
8
+ async def model_gateway(request: HttpRequest, family: str, path: str) -> HttpResponse:
9
+ # Authenticated by a per-run token, never a browser session, so CSRF does
10
+ # not apply.
11
+ return await forward(request, family, path)
@@ -0,0 +1,6 @@
1
+ """GitHub access: App and token credentials, the REST API, and git transport.
2
+
3
+ This package knows GitHub, not SecondShift's models. Callers pass plain
4
+ values such as an installation ID or a repository ID. The Django apps turn
5
+ their own rows into those values.
6
+ """
@@ -0,0 +1,109 @@
1
+ """Server-owned GitHub App credentials and installation-scoped API access."""
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import padding
10
+ from django.conf import settings
11
+
12
+ from secondshift.config.constants import GITHUB_API_URL, GITHUB_BASE_URL
13
+ from secondshift.github.errors import GitHubError
14
+ from secondshift.github.rest import paginated, request_json
15
+
16
+
17
+ def configured() -> bool:
18
+ return all(
19
+ (
20
+ settings.GITHUB_APP_ID,
21
+ settings.GITHUB_APP_SLUG,
22
+ settings.GITHUB_APP_PRIVATE_KEY,
23
+ settings.GITHUB_APP_CLIENT_ID,
24
+ settings.GITHUB_APP_CLIENT_SECRET,
25
+ )
26
+ )
27
+
28
+
29
+ def encoded(value: bytes) -> str:
30
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
31
+
32
+
33
+ def app_token() -> str:
34
+ now = int(time.time())
35
+ header = encoded(b'{"alg":"RS256","typ":"JWT"}')
36
+ payload = encoded(
37
+ json.dumps(
38
+ {"iat": now - 60, "exp": now + 540, "iss": settings.GITHUB_APP_ID}
39
+ ).encode()
40
+ )
41
+ message = f"{header}.{payload}"
42
+ try:
43
+ key = serialization.load_pem_private_key(
44
+ settings.GITHUB_APP_PRIVATE_KEY.encode(), password=None
45
+ )
46
+ signature = key.sign(message.encode(), padding.PKCS1v15(), hashes.SHA256())
47
+ except (ValueError, TypeError, AttributeError) as error:
48
+ raise GitHubError("The server's GitHub App key is invalid.") from error
49
+ return f"{message}.{encoded(signature)}"
50
+
51
+
52
+ def installation_token(
53
+ installation_id: str,
54
+ repository_id: int | None = None,
55
+ permissions: dict[str, str] | None = None,
56
+ ) -> str:
57
+ """A token that expires within an hour. Read-only unless permissions say more."""
58
+ if not installation_id.isdecimal():
59
+ raise GitHubError(
60
+ "The saved GitHub App installation is not valid. Reconnect the GitHub App."
61
+ )
62
+ payload: dict[str, object] = {"permissions": permissions or {"contents": "read"}}
63
+ if repository_id is not None:
64
+ payload["repository_ids"] = [repository_id]
65
+ data = request_json(
66
+ "POST",
67
+ f"{GITHUB_API_URL}/app/installations/{installation_id}/access_tokens",
68
+ app_token(),
69
+ json=payload,
70
+ )
71
+ return data["token"]
72
+
73
+
74
+ def installation_repositories(installation_id: str) -> list[dict]:
75
+ return paginated(
76
+ f"{GITHUB_API_URL}/installation/repositories",
77
+ installation_token(installation_id),
78
+ "repositories",
79
+ "Installation has too many repositories to list.",
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class InstallationDetails:
85
+ """What the installation's owner granted, and where they change it."""
86
+
87
+ permissions: dict[str, str]
88
+ settings_url: str
89
+
90
+
91
+ def installation(installation_id: str) -> InstallationDetails:
92
+ if not installation_id.isdecimal():
93
+ raise GitHubError(
94
+ "The saved GitHub App installation is not valid. Reconnect the GitHub App."
95
+ )
96
+ data = request_json(
97
+ "GET", f"{GITHUB_API_URL}/app/installations/{installation_id}", app_token()
98
+ )
99
+ permissions = data.get("permissions")
100
+ url = data.get("html_url")
101
+ return InstallationDetails(
102
+ permissions if isinstance(permissions, dict) else {},
103
+ url if isinstance(url, str) and url.startswith(GITHUB_BASE_URL) else "",
104
+ )
105
+
106
+
107
+ def installation_permissions(installation_id: str) -> dict[str, str]:
108
+ """What the installation's owner granted the App, such as {"actions": "read"}."""
109
+ return installation(installation_id).permissions
@@ -0,0 +1,56 @@
1
+ """Who SecondShift's commits say wrote them.
2
+
3
+ A commit names the same GitHub account that pushes it and opens the pull
4
+ request. GitHub links a commit to an account by its noreply address, which
5
+ holds the account's ID and login. An address SecondShift made up could link
6
+ the commit to a stranger.
7
+
8
+ The name is the login, which only changes when the account is renamed. A
9
+ retry then builds the same commit as the attempt before it.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from urllib.parse import quote
14
+
15
+ from secondshift.config.constants import GITHUB_API_URL
16
+ from secondshift.github import app
17
+ from secondshift.github.errors import GitHubError
18
+ from secondshift.github.rest import request_json
19
+
20
+ NOREPLY_DOMAIN = "users.noreply.github.com"
21
+ BOT_SUFFIX = "[bot]"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class GitAuthor:
26
+ name: str
27
+ email: str
28
+
29
+
30
+ def noreply_email(account_id: int, login: str) -> str:
31
+ return f"{account_id}+{login}@{NOREPLY_DOMAIN}"
32
+
33
+
34
+ def account_author(data: dict) -> GitAuthor:
35
+ account_id = data.get("id")
36
+ login = data.get("login")
37
+ if not isinstance(account_id, int) or not isinstance(login, str) or not login:
38
+ raise GitHubError("GitHub did not say which account publishes the changes.")
39
+ return GitAuthor(login, noreply_email(account_id, login))
40
+
41
+
42
+ def app_author(token: str) -> GitAuthor:
43
+ """The App's bot account, such as secondshift-acme[bot].
44
+
45
+ GitHub names the App, so renaming it never leaves a stale slug here.
46
+ """
47
+ slug = request_json("GET", f"{GITHUB_API_URL}/app", app.app_token()).get("slug")
48
+ if not isinstance(slug, str) or not slug:
49
+ raise GitHubError("GitHub did not say which account publishes the changes.")
50
+ login = quote(f"{slug}{BOT_SUFFIX}")
51
+ return account_author(request_json("GET", f"{GITHUB_API_URL}/users/{login}", token))
52
+
53
+
54
+ def token_author(token: str) -> GitAuthor:
55
+ """The person who owns a personal access token."""
56
+ return account_author(request_json("GET", f"{GITHUB_API_URL}/user", token))