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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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))
|