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,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}"
|