@openhands/extensions 0.9.0 → 0.11.0
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.
- package/.github/workflows/deprecation-check.yml +25 -0
- package/.release-please-manifest.json +1 -1
- package/README.md +3 -2
- package/integrations/README.md +25 -0
- package/integrations/catalog/atlassian-rovo.json +44 -0
- package/integrations/catalog/atlassian.json +13 -0
- package/integrations/catalog/github.json +21 -1
- package/integrations/catalog/linear.json +1 -1
- package/integrations/catalog/microsoft-outlook.json +13 -0
- package/integrations/catalog/microsoft-teams.json +13 -0
- package/integrations/catalog/notion.json +13 -0
- package/integrations/catalog/onedrive.json +13 -0
- package/integrations/catalog/sharepoint.json +13 -0
- package/integrations/catalog/slack.json +16 -2
- package/integrations/catalog/vanta.json +55 -0
- package/integrations/catalog-index.js +71 -67
- package/integrations/catalog.schema.json +70 -3
- package/integrations/index.d.ts +49 -3
- package/marketplaces/openhands-extensions.json +9 -0
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/python/openhands_extensions/__init__.py +38 -0
- package/python/openhands_extensions/_version.py +2 -2
- package/python/openhands_extensions/deprecation.py +159 -0
- package/python/openhands_extensions/integration_models.py +233 -0
- package/python/openhands_extensions/integrations.py +59 -4
- package/scripts/check_deprecations.py +488 -0
- package/skills/index.js +7 -1
- package/skills/iterate/README.md +4 -1
- package/skills/iterate/SKILL.md +22 -2
- package/skills/technical-writing/.plugin/plugin.json +15 -0
- package/skills/technical-writing/README.md +14 -0
- package/skills/technical-writing/SKILL.md +106 -0
- package/tests/test_catalog_schema.py +58 -0
- package/tests/test_catalogs.py +67 -0
- package/tests/test_deprecations.py +136 -0
- package/tests/test_integration_catalog_in_sync.py +118 -7
- package/tests/test_live_integration_smoke.py +366 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"description": "Strict schema for integrations/catalog/*.json. Mirrors integrations/index.d.ts and encodes the cross-field connectability rules the catalog contract depends on.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
|
-
"required": ["id", "name", "description", "connectionOptions"],
|
|
8
|
+
"required": ["id", "name", "description", "docsUrl", "connectionOptions"],
|
|
9
9
|
"properties": {
|
|
10
10
|
"id": { "type": "string", "minLength": 1 },
|
|
11
11
|
"name": { "type": "string", "minLength": 1 },
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"marketplaceField": {
|
|
40
40
|
"type": "object",
|
|
41
41
|
"additionalProperties": false,
|
|
42
|
-
"required": ["key", "label"],
|
|
42
|
+
"required": ["key", "label", "type", "required"],
|
|
43
43
|
"properties": {
|
|
44
44
|
"key": { "type": "string", "minLength": 1 },
|
|
45
45
|
"label": { "type": "string", "minLength": 1 },
|
|
@@ -91,6 +91,72 @@
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
|
+
"identityPath": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"pattern": "^[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*$",
|
|
97
|
+
"minLength": 1
|
|
98
|
+
},
|
|
99
|
+
"identityMapping": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"additionalProperties": false,
|
|
102
|
+
"required": ["source", "externalPrincipalIdPath"],
|
|
103
|
+
"properties": {
|
|
104
|
+
"source": {
|
|
105
|
+
"enum": ["oauth_token_response", "access_token_claims", "identity_api"]
|
|
106
|
+
},
|
|
107
|
+
"endpoint": { "type": "string", "pattern": "^https://", "minLength": 8 },
|
|
108
|
+
"externalPrincipalIdPath": { "$ref": "#/$defs/identityPath" },
|
|
109
|
+
"externalTenantIdPath": { "$ref": "#/$defs/identityPath" },
|
|
110
|
+
"externalResourceIdPath": { "$ref": "#/$defs/identityPath" },
|
|
111
|
+
"resourceNamePath": { "$ref": "#/$defs/identityPath" },
|
|
112
|
+
"resourceUrlPath": { "$ref": "#/$defs/identityPath" }
|
|
113
|
+
},
|
|
114
|
+
"allOf": [
|
|
115
|
+
{
|
|
116
|
+
"if": { "properties": { "source": { "const": "identity_api" } } },
|
|
117
|
+
"then": { "required": ["endpoint"] },
|
|
118
|
+
"else": { "not": { "required": ["endpoint"] } }
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
"resourceDiscovery": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"additionalProperties": false,
|
|
125
|
+
"required": ["endpoint", "externalResourceIdPath"],
|
|
126
|
+
"properties": {
|
|
127
|
+
"endpoint": { "type": "string", "pattern": "^https://", "minLength": 8 },
|
|
128
|
+
"itemsPath": { "$ref": "#/$defs/identityPath" },
|
|
129
|
+
"externalResourceIdPath": { "$ref": "#/$defs/identityPath" },
|
|
130
|
+
"resourceNamePath": { "$ref": "#/$defs/identityPath" },
|
|
131
|
+
"resourceUrlPath": { "$ref": "#/$defs/identityPath" }
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"connectionModel": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"additionalProperties": false,
|
|
137
|
+
"required": [
|
|
138
|
+
"principalType",
|
|
139
|
+
"credentialScope",
|
|
140
|
+
"resourceType",
|
|
141
|
+
"resourceCardinality",
|
|
142
|
+
"selectionMode"
|
|
143
|
+
],
|
|
144
|
+
"properties": {
|
|
145
|
+
"principalType": { "enum": ["user", "bot", "service_account", "application"] },
|
|
146
|
+
"credentialScope": { "enum": ["account", "tenant", "organization", "resource"] },
|
|
147
|
+
"resourceType": { "type": "string", "minLength": 1 },
|
|
148
|
+
"resourceCardinality": { "enum": ["one", "many"] },
|
|
149
|
+
"selectionMode": { "enum": ["automatic", "post_auth", "runtime"] },
|
|
150
|
+
"identityMapping": { "$ref": "#/$defs/identityMapping" },
|
|
151
|
+
"resourceDiscovery": { "$ref": "#/$defs/resourceDiscovery" }
|
|
152
|
+
},
|
|
153
|
+
"allOf": [
|
|
154
|
+
{
|
|
155
|
+
"if": { "properties": { "selectionMode": { "const": "post_auth" } } },
|
|
156
|
+
"then": { "required": ["resourceDiscovery"] }
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
},
|
|
94
160
|
"authConfig": {
|
|
95
161
|
"type": "object",
|
|
96
162
|
"additionalProperties": false,
|
|
@@ -186,7 +252,8 @@
|
|
|
186
252
|
"provider": { "enum": ["mcp", "http"] },
|
|
187
253
|
"transport": { "$ref": "#/$defs/transport" },
|
|
188
254
|
"http": { "$ref": "#/$defs/httpConfig" },
|
|
189
|
-
"auth": { "$ref": "#/$defs/authConfig" }
|
|
255
|
+
"auth": { "$ref": "#/$defs/authConfig" },
|
|
256
|
+
"connectionModel": { "$ref": "#/$defs/connectionModel" }
|
|
190
257
|
},
|
|
191
258
|
"allOf": [
|
|
192
259
|
{
|
package/integrations/index.d.ts
CHANGED
|
@@ -3,11 +3,11 @@ export type MarketplaceFieldType = "text" | "password";
|
|
|
3
3
|
export interface MarketplaceField {
|
|
4
4
|
key: string;
|
|
5
5
|
label: string;
|
|
6
|
-
type
|
|
6
|
+
type: MarketplaceFieldType;
|
|
7
7
|
placeholder?: string;
|
|
8
8
|
helperText?: string;
|
|
9
9
|
helperLink?: string;
|
|
10
|
-
required
|
|
10
|
+
required: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export type IntegrationTransport =
|
|
@@ -95,12 +95,58 @@ export interface IntegrationHttpConfig {
|
|
|
95
95
|
openApiUrl?: string;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export type IntegrationPrincipalType =
|
|
99
|
+
| "user"
|
|
100
|
+
| "bot"
|
|
101
|
+
| "service_account"
|
|
102
|
+
| "application";
|
|
103
|
+
|
|
104
|
+
export type IntegrationCredentialScope =
|
|
105
|
+
| "account"
|
|
106
|
+
| "tenant"
|
|
107
|
+
| "organization"
|
|
108
|
+
| "resource";
|
|
109
|
+
|
|
110
|
+
export type IntegrationIdentitySource =
|
|
111
|
+
| "oauth_token_response"
|
|
112
|
+
| "access_token_claims"
|
|
113
|
+
| "identity_api";
|
|
114
|
+
|
|
115
|
+
export interface IntegrationIdentityMapping {
|
|
116
|
+
source: IntegrationIdentitySource;
|
|
117
|
+
endpoint?: string;
|
|
118
|
+
externalPrincipalIdPath: string;
|
|
119
|
+
externalTenantIdPath?: string;
|
|
120
|
+
externalResourceIdPath?: string;
|
|
121
|
+
resourceNamePath?: string;
|
|
122
|
+
resourceUrlPath?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface IntegrationResourceDiscovery {
|
|
126
|
+
endpoint: string;
|
|
127
|
+
itemsPath?: string;
|
|
128
|
+
externalResourceIdPath: string;
|
|
129
|
+
resourceNamePath?: string;
|
|
130
|
+
resourceUrlPath?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface IntegrationConnectionModel {
|
|
134
|
+
principalType: IntegrationPrincipalType;
|
|
135
|
+
credentialScope: IntegrationCredentialScope;
|
|
136
|
+
resourceType: string;
|
|
137
|
+
resourceCardinality: "one" | "many";
|
|
138
|
+
selectionMode: "automatic" | "post_auth" | "runtime";
|
|
139
|
+
identityMapping?: IntegrationIdentityMapping;
|
|
140
|
+
resourceDiscovery?: IntegrationResourceDiscovery;
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
export interface IntegrationConnectionOption {
|
|
99
144
|
id: "oauth" | "api" | "none" | string;
|
|
100
145
|
provider: IntegrationProvider;
|
|
101
146
|
transport?: IntegrationTransport;
|
|
102
147
|
http?: IntegrationHttpConfig;
|
|
103
148
|
auth: IntegrationAuthConfig;
|
|
149
|
+
connectionModel?: IntegrationConnectionModel;
|
|
104
150
|
}
|
|
105
151
|
|
|
106
152
|
|
|
@@ -110,7 +156,7 @@ export interface IntegrationCatalogEntry {
|
|
|
110
156
|
description: string;
|
|
111
157
|
categories?: string[];
|
|
112
158
|
appUrl?: string;
|
|
113
|
-
docsUrl
|
|
159
|
+
docsUrl: string;
|
|
114
160
|
notes?: string;
|
|
115
161
|
iconBg?: string;
|
|
116
162
|
iconColor?: string;
|
|
@@ -729,6 +729,15 @@
|
|
|
729
729
|
"automation"
|
|
730
730
|
]
|
|
731
731
|
},
|
|
732
|
+
{
|
|
733
|
+
"name": "technical-writing",
|
|
734
|
+
"source": "./skills/technical-writing",
|
|
735
|
+
"description": "Write and revise technical explanations in flowing, direct, conversational prose that stays concise without becoming fragmented or vague.",
|
|
736
|
+
"category": "productivity",
|
|
737
|
+
"keywords": [
|
|
738
|
+
"technical-writing"
|
|
739
|
+
]
|
|
740
|
+
},
|
|
732
741
|
{
|
|
733
742
|
"name": "plain-english-content",
|
|
734
743
|
"source": "./skills/plain-english-content",
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openhands-extensions"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"deprecation>=2.1,<3",
|
|
8
|
+
"packaging>=24,<26",
|
|
9
|
+
"pydantic>=2.10,<3",
|
|
10
|
+
]
|
|
6
11
|
|
|
7
12
|
[build-system]
|
|
8
13
|
requires = ["hatchling>=1.21"]
|
|
@@ -13,12 +13,50 @@ from ._version import __version__
|
|
|
13
13
|
from .integrations import (
|
|
14
14
|
INTEGRATION_CATALOG_SNAPSHOT,
|
|
15
15
|
get_integration_catalog_entry,
|
|
16
|
+
get_integration_catalog_entry_model,
|
|
16
17
|
list_integration_catalog,
|
|
18
|
+
list_integration_catalog_models,
|
|
19
|
+
)
|
|
20
|
+
from .integration_models import (
|
|
21
|
+
HttpConnectionOption,
|
|
22
|
+
IntegrationAuthConfig,
|
|
23
|
+
IntegrationCatalogEntry,
|
|
24
|
+
IntegrationCatalogSnapshot,
|
|
25
|
+
IntegrationConnectionOption,
|
|
26
|
+
IntegrationConnectionModel,
|
|
27
|
+
IntegrationHttpConfig,
|
|
28
|
+
IntegrationIdentityMapping,
|
|
29
|
+
IntegrationOAuthConfig,
|
|
30
|
+
IntegrationResourceDiscovery,
|
|
31
|
+
IntegrationTransport,
|
|
32
|
+
McpConnectionOption,
|
|
33
|
+
MarketplaceField,
|
|
34
|
+
SseTransport,
|
|
35
|
+
StdioTransport,
|
|
36
|
+
StreamableHttpTransport,
|
|
17
37
|
)
|
|
18
38
|
|
|
19
39
|
__all__ = [
|
|
20
40
|
"INTEGRATION_CATALOG_SNAPSHOT",
|
|
41
|
+
"HttpConnectionOption",
|
|
42
|
+
"IntegrationAuthConfig",
|
|
43
|
+
"IntegrationCatalogEntry",
|
|
44
|
+
"IntegrationCatalogSnapshot",
|
|
45
|
+
"IntegrationConnectionOption",
|
|
46
|
+
"IntegrationConnectionModel",
|
|
47
|
+
"IntegrationHttpConfig",
|
|
48
|
+
"IntegrationIdentityMapping",
|
|
49
|
+
"IntegrationOAuthConfig",
|
|
50
|
+
"IntegrationResourceDiscovery",
|
|
51
|
+
"IntegrationTransport",
|
|
52
|
+
"MarketplaceField",
|
|
53
|
+
"McpConnectionOption",
|
|
54
|
+
"SseTransport",
|
|
55
|
+
"StdioTransport",
|
|
56
|
+
"StreamableHttpTransport",
|
|
21
57
|
"__version__",
|
|
22
58
|
"get_integration_catalog_entry",
|
|
59
|
+
"get_integration_catalog_entry_model",
|
|
23
60
|
"list_integration_catalog",
|
|
61
|
+
"list_integration_catalog_models",
|
|
24
62
|
]
|
|
@@ -16,8 +16,8 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
16
16
|
|
|
17
17
|
#: Fallback used only when the package is not installed (no dist metadata).
|
|
18
18
|
#: Kept in lock-step with pyproject.toml/package.json by the version test.
|
|
19
|
-
# x-release-please-
|
|
20
|
-
_FALLBACK_VERSION = "0.
|
|
19
|
+
# x-release-please-version
|
|
20
|
+
_FALLBACK_VERSION = "0.11.0"
|
|
21
21
|
|
|
22
22
|
try:
|
|
23
23
|
__version__: str = version("openhands-extensions")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Versioned deprecation helpers for OpenHands extensions.
|
|
2
|
+
|
|
3
|
+
This module intentionally mirrors ``openhands.sdk.utils.deprecation`` so public
|
|
4
|
+
extension APIs use the same decorator, runtime warning, and model-input
|
|
5
|
+
compatibility behavior as the SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import date
|
|
13
|
+
from functools import cache
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version as get_version
|
|
15
|
+
from typing import Any, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
from deprecation import (
|
|
18
|
+
DeprecatedWarning,
|
|
19
|
+
UnsupportedWarning,
|
|
20
|
+
deprecated as _deprecated,
|
|
21
|
+
)
|
|
22
|
+
from packaging import version as pkg_version
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cache
|
|
29
|
+
def _current_version() -> str:
|
|
30
|
+
try:
|
|
31
|
+
return get_version("openhands-extensions")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return "0.0.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def deprecated(
|
|
37
|
+
*,
|
|
38
|
+
deprecated_in: str,
|
|
39
|
+
removed_in: str | date | None,
|
|
40
|
+
current_version: str | None = None,
|
|
41
|
+
details: str = "",
|
|
42
|
+
) -> Callable[[_FuncT], _FuncT]:
|
|
43
|
+
"""Return a decorator that deprecates a callable with explicit metadata."""
|
|
44
|
+
base_decorator = _deprecated(
|
|
45
|
+
deprecated_in=deprecated_in,
|
|
46
|
+
removed_in=removed_in,
|
|
47
|
+
current_version=current_version or _current_version(),
|
|
48
|
+
details=details,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def decorator(func: _FuncT) -> _FuncT:
|
|
52
|
+
return cast(_FuncT, base_decorator(func))
|
|
53
|
+
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _should_warn(
|
|
58
|
+
*,
|
|
59
|
+
deprecated_in: str | None,
|
|
60
|
+
removed_in: str | date | None,
|
|
61
|
+
current_version: str | None,
|
|
62
|
+
) -> tuple[bool, bool]:
|
|
63
|
+
is_deprecated = False
|
|
64
|
+
is_unsupported = False
|
|
65
|
+
|
|
66
|
+
if isinstance(removed_in, date):
|
|
67
|
+
if date.today() >= removed_in:
|
|
68
|
+
is_unsupported = True
|
|
69
|
+
else:
|
|
70
|
+
is_deprecated = True
|
|
71
|
+
elif current_version:
|
|
72
|
+
current = pkg_version.parse(current_version)
|
|
73
|
+
if removed_in and current >= pkg_version.parse(str(removed_in)):
|
|
74
|
+
is_unsupported = True
|
|
75
|
+
elif deprecated_in and current >= pkg_version.parse(deprecated_in):
|
|
76
|
+
is_deprecated = True
|
|
77
|
+
else:
|
|
78
|
+
is_deprecated = True
|
|
79
|
+
|
|
80
|
+
return is_deprecated, is_unsupported
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def warn_deprecated(
|
|
84
|
+
feature: str,
|
|
85
|
+
*,
|
|
86
|
+
deprecated_in: str,
|
|
87
|
+
removed_in: str | date | None,
|
|
88
|
+
current_version: str | None = None,
|
|
89
|
+
details: str = "",
|
|
90
|
+
stacklevel: int = 2,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Emit a deprecation warning for dynamic access to a legacy feature."""
|
|
93
|
+
current_version = current_version or _current_version()
|
|
94
|
+
is_deprecated, is_unsupported = _should_warn(
|
|
95
|
+
deprecated_in=deprecated_in,
|
|
96
|
+
removed_in=removed_in,
|
|
97
|
+
current_version=current_version,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not (is_deprecated or is_unsupported):
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
warning_cls = UnsupportedWarning if is_unsupported else DeprecatedWarning
|
|
104
|
+
warning = warning_cls(feature, deprecated_in, removed_in, details)
|
|
105
|
+
warnings.warn(warning, stacklevel=stacklevel)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def warn_cleanup(
|
|
109
|
+
workaround: str,
|
|
110
|
+
*,
|
|
111
|
+
cleanup_by: str | date,
|
|
112
|
+
current_version: str | None = None,
|
|
113
|
+
details: str = "",
|
|
114
|
+
stacklevel: int = 2,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Emit a warning when a temporary workaround has reached its deadline."""
|
|
117
|
+
current_version = current_version or _current_version()
|
|
118
|
+
|
|
119
|
+
should_cleanup = False
|
|
120
|
+
if isinstance(cleanup_by, date):
|
|
121
|
+
should_cleanup = date.today() >= cleanup_by
|
|
122
|
+
else:
|
|
123
|
+
try:
|
|
124
|
+
current = pkg_version.parse(current_version)
|
|
125
|
+
target = pkg_version.parse(str(cleanup_by))
|
|
126
|
+
should_cleanup = current >= target
|
|
127
|
+
except pkg_version.InvalidVersion:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
if should_cleanup:
|
|
131
|
+
message = (
|
|
132
|
+
f"Cleanup required: {workaround}. "
|
|
133
|
+
f"This workaround was scheduled for removal by {cleanup_by}."
|
|
134
|
+
)
|
|
135
|
+
if details:
|
|
136
|
+
message += f" {details}"
|
|
137
|
+
warnings.warn(message, UserWarning, stacklevel=stacklevel)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def handle_deprecated_model_fields(
|
|
141
|
+
data: Any,
|
|
142
|
+
deprecated_fields: tuple[str, ...],
|
|
143
|
+
) -> Any:
|
|
144
|
+
"""Remove permanently supported legacy fields from Pydantic model input."""
|
|
145
|
+
if not isinstance(data, dict):
|
|
146
|
+
return data
|
|
147
|
+
|
|
148
|
+
for field in deprecated_fields:
|
|
149
|
+
data.pop(field, None)
|
|
150
|
+
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = [
|
|
155
|
+
"deprecated",
|
|
156
|
+
"warn_deprecated",
|
|
157
|
+
"warn_cleanup",
|
|
158
|
+
"handle_deprecated_model_fields",
|
|
159
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Typed models for the OpenHands integration catalog.
|
|
2
|
+
|
|
3
|
+
The hand-authored JSON files under ``integrations/catalog`` remain the source
|
|
4
|
+
of truth. These models provide a Python representation of the public catalog
|
|
5
|
+
contract defined by ``integrations/catalog.schema.json`` and
|
|
6
|
+
``integrations/index.d.ts``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
NonEmptyString = Annotated[str, Field(min_length=1)]
|
|
17
|
+
HttpsUrl = Annotated[str, Field(pattern=r"^https://", min_length=8)]
|
|
18
|
+
IdentityPath = Annotated[
|
|
19
|
+
str,
|
|
20
|
+
Field(pattern=r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", min_length=1),
|
|
21
|
+
]
|
|
22
|
+
IntegrationAuthStrategy = Literal["none", "api_key", "bearer", "basic", "oauth2"]
|
|
23
|
+
IntegrationProvider = Literal["mcp", "http"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CatalogModel(BaseModel):
|
|
27
|
+
"""Base model for strict, portable catalog data."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="before")
|
|
32
|
+
@classmethod
|
|
33
|
+
def reject_explicit_nulls(cls, values: Any) -> Any:
|
|
34
|
+
"""Match the JSON Schema: optional fields may be omitted, never null."""
|
|
35
|
+
if not isinstance(values, dict):
|
|
36
|
+
return values
|
|
37
|
+
|
|
38
|
+
null_fields = [field for field, value in values.items() if value is None]
|
|
39
|
+
if null_fields:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"Catalog fields must be omitted instead of set to null: "
|
|
42
|
+
f"{', '.join(null_fields)}"
|
|
43
|
+
)
|
|
44
|
+
return values
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MarketplaceField(CatalogModel):
|
|
48
|
+
key: NonEmptyString
|
|
49
|
+
label: NonEmptyString
|
|
50
|
+
type: Literal["text", "password"]
|
|
51
|
+
placeholder: str | None = None
|
|
52
|
+
helperText: str | None = None
|
|
53
|
+
helperLink: HttpsUrl | None = None
|
|
54
|
+
required: bool
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def password_fields_have_help(self) -> MarketplaceField:
|
|
58
|
+
if self.type == "password" and not self.helperText:
|
|
59
|
+
raise ValueError("Password marketplace fields require helperText.")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StreamableHttpTransport(CatalogModel):
|
|
64
|
+
kind: Literal["shttp"]
|
|
65
|
+
url: HttpsUrl
|
|
66
|
+
apiKeyOptional: bool | None = None
|
|
67
|
+
headerFields: list[MarketplaceField] | None = None
|
|
68
|
+
urlEditable: bool | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SseTransport(CatalogModel):
|
|
72
|
+
kind: Literal["sse"]
|
|
73
|
+
url: HttpsUrl
|
|
74
|
+
apiKeyOptional: bool | None = None
|
|
75
|
+
headerFields: list[MarketplaceField] | None = None
|
|
76
|
+
urlEditable: bool | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StdioTransport(CatalogModel):
|
|
80
|
+
kind: Literal["stdio"]
|
|
81
|
+
serverName: NonEmptyString
|
|
82
|
+
command: NonEmptyString
|
|
83
|
+
args: list[str]
|
|
84
|
+
envFields: list[MarketplaceField] | None = None
|
|
85
|
+
argFields: list[MarketplaceField] | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
IntegrationTransport = Annotated[
|
|
89
|
+
StreamableHttpTransport | SseTransport | StdioTransport,
|
|
90
|
+
Field(discriminator="kind"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class IntegrationOAuthConfig(CatalogModel):
|
|
95
|
+
authorizationUrl: HttpsUrl | None = None
|
|
96
|
+
tokenUrl: HttpsUrl | None = None
|
|
97
|
+
scopes: list[NonEmptyString] | None = None
|
|
98
|
+
optionalScopes: list[NonEmptyString] | None = None
|
|
99
|
+
toolScopes: list[NonEmptyString] | None = None
|
|
100
|
+
scopeSeparator: Literal["space", "comma"] | None = None
|
|
101
|
+
pkce: bool | None = None
|
|
102
|
+
clientAuthentication: Literal["basic", "body", "none"] | None = None
|
|
103
|
+
registrationUrl: HttpsUrl | None = None
|
|
104
|
+
additionalAuthorizationParams: dict[str, str] | None = None
|
|
105
|
+
additionalTokenParams: dict[str, str] | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class IntegrationAuthConfig(CatalogModel):
|
|
109
|
+
strategy: IntegrationAuthStrategy
|
|
110
|
+
authModes: list[IntegrationAuthStrategy] | None = Field(default=None, min_length=1)
|
|
111
|
+
credentialLabel: NonEmptyString | None = None
|
|
112
|
+
credentialPlaceholder: NonEmptyString | None = None
|
|
113
|
+
credentialHelp: NonEmptyString | None = None
|
|
114
|
+
credentialSecretName: NonEmptyString | None = None
|
|
115
|
+
saveCredentialAsSecretByDefault: bool | None = None
|
|
116
|
+
apiKeyHeaderName: NonEmptyString | None = None
|
|
117
|
+
apiKeyOptional: bool | None = None
|
|
118
|
+
oauth: IntegrationOAuthConfig | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class IntegrationHttpConfig(CatalogModel):
|
|
122
|
+
apiBaseUrl: HttpsUrl
|
|
123
|
+
openApiUrl: HttpsUrl
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class IntegrationIdentityMapping(CatalogModel):
|
|
127
|
+
source: Literal["oauth_token_response", "access_token_claims", "identity_api"]
|
|
128
|
+
endpoint: HttpsUrl | None = None
|
|
129
|
+
externalPrincipalIdPath: IdentityPath
|
|
130
|
+
externalTenantIdPath: IdentityPath | None = None
|
|
131
|
+
externalResourceIdPath: IdentityPath | None = None
|
|
132
|
+
resourceNamePath: IdentityPath | None = None
|
|
133
|
+
resourceUrlPath: IdentityPath | None = None
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="after")
|
|
136
|
+
def identity_api_requires_endpoint(self) -> IntegrationIdentityMapping:
|
|
137
|
+
if self.source == "identity_api" and not self.endpoint:
|
|
138
|
+
raise ValueError("identity_api mappings require an endpoint.")
|
|
139
|
+
if self.source != "identity_api" and self.endpoint:
|
|
140
|
+
raise ValueError("Only identity_api mappings may define an endpoint.")
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class IntegrationResourceDiscovery(CatalogModel):
|
|
145
|
+
endpoint: HttpsUrl
|
|
146
|
+
itemsPath: IdentityPath | None = None
|
|
147
|
+
externalResourceIdPath: IdentityPath
|
|
148
|
+
resourceNamePath: IdentityPath | None = None
|
|
149
|
+
resourceUrlPath: IdentityPath | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class IntegrationConnectionModel(CatalogModel):
|
|
153
|
+
principalType: Literal["user", "bot", "service_account", "application"]
|
|
154
|
+
credentialScope: Literal["account", "tenant", "organization", "resource"]
|
|
155
|
+
resourceType: NonEmptyString
|
|
156
|
+
resourceCardinality: Literal["one", "many"]
|
|
157
|
+
selectionMode: Literal["automatic", "post_auth", "runtime"]
|
|
158
|
+
identityMapping: IntegrationIdentityMapping | None = None
|
|
159
|
+
resourceDiscovery: IntegrationResourceDiscovery | None = None
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="after")
|
|
162
|
+
def post_auth_requires_resource_discovery(self) -> IntegrationConnectionModel:
|
|
163
|
+
if self.selectionMode == "post_auth" and not self.resourceDiscovery:
|
|
164
|
+
raise ValueError("post_auth connections require resourceDiscovery.")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class McpConnectionOption(CatalogModel):
|
|
169
|
+
id: NonEmptyString
|
|
170
|
+
provider: Literal["mcp"]
|
|
171
|
+
transport: IntegrationTransport
|
|
172
|
+
auth: IntegrationAuthConfig
|
|
173
|
+
connectionModel: IntegrationConnectionModel | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class HttpConnectionOption(CatalogModel):
|
|
177
|
+
id: NonEmptyString
|
|
178
|
+
provider: Literal["http"]
|
|
179
|
+
http: IntegrationHttpConfig
|
|
180
|
+
auth: IntegrationAuthConfig
|
|
181
|
+
connectionModel: IntegrationConnectionModel | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
IntegrationConnectionOption = Annotated[
|
|
185
|
+
McpConnectionOption | HttpConnectionOption,
|
|
186
|
+
Field(discriminator="provider"),
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class IntegrationCatalogEntry(CatalogModel):
|
|
191
|
+
id: NonEmptyString
|
|
192
|
+
name: NonEmptyString
|
|
193
|
+
description: NonEmptyString
|
|
194
|
+
categories: list[NonEmptyString] | None = Field(default=None, min_length=1)
|
|
195
|
+
appUrl: HttpsUrl | None = None
|
|
196
|
+
docsUrl: HttpsUrl
|
|
197
|
+
notes: NonEmptyString | None = None
|
|
198
|
+
iconBg: NonEmptyString | None = None
|
|
199
|
+
iconColor: NonEmptyString | None = None
|
|
200
|
+
logoUrl: HttpsUrl | None = None
|
|
201
|
+
keywords: list[NonEmptyString] | None = None
|
|
202
|
+
popularityRank: int | None = Field(default=None, ge=0)
|
|
203
|
+
installHint: NonEmptyString | None = None
|
|
204
|
+
connectionOptions: list[IntegrationConnectionOption] = Field(min_length=1)
|
|
205
|
+
|
|
206
|
+
@field_validator("categories", "keywords")
|
|
207
|
+
@classmethod
|
|
208
|
+
def list_values_are_unique(
|
|
209
|
+
cls, values: list[NonEmptyString] | None
|
|
210
|
+
) -> list[NonEmptyString] | None:
|
|
211
|
+
if values is not None and len(values) != len(set(values)):
|
|
212
|
+
raise ValueError("Catalog list values must be unique.")
|
|
213
|
+
return values
|
|
214
|
+
|
|
215
|
+
@model_validator(mode="after")
|
|
216
|
+
def validate_auth_requirements(self) -> IntegrationCatalogEntry:
|
|
217
|
+
for option in self.connectionOptions:
|
|
218
|
+
if option.provider == "http" and option.auth.strategy == "api_key":
|
|
219
|
+
if not option.auth.apiKeyHeaderName:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"HTTP API-key connections require apiKeyHeaderName."
|
|
222
|
+
)
|
|
223
|
+
if option.provider == "http" and option.auth.strategy == "oauth2":
|
|
224
|
+
oauth = option.auth.oauth
|
|
225
|
+
if not oauth or not oauth.authorizationUrl or not oauth.tokenUrl:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
"HTTP OAuth connections require authorizationUrl and tokenUrl."
|
|
228
|
+
)
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class IntegrationCatalogSnapshot(CatalogModel):
|
|
233
|
+
integrations: list[IntegrationCatalogEntry]
|