airbyte-agent-stripe 0.5.28__py3-none-any.whl → 0.5.37__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.
@@ -0,0 +1,262 @@
1
+ """
2
+ Shared introspection utilities for connector metadata.
3
+
4
+ This module provides utilities for introspecting connector metadata,
5
+ generating descriptions, and formatting parameter signatures. These
6
+ functions are used by both the runtime decorators and the generated
7
+ connector code.
8
+
9
+ The module is designed to work with any object conforming to the
10
+ ConnectorModel and EndpointDefinition interfaces from connector_sdk.types.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Protocol
16
+
17
+ # Constants
18
+ MAX_EXAMPLE_QUESTIONS = 5 # Maximum number of example questions to include in description
19
+
20
+
21
+ class EndpointProtocol(Protocol):
22
+ """Protocol defining the expected interface for endpoint parameters.
23
+
24
+ This allows functions to work with any endpoint-like object
25
+ that has these attributes, including EndpointDefinition and mock objects.
26
+ """
27
+
28
+ path_params: list[str]
29
+ path_params_schema: dict[str, dict[str, Any]]
30
+ query_params: list[str]
31
+ query_params_schema: dict[str, dict[str, Any]]
32
+ body_fields: list[str]
33
+ request_schema: dict[str, Any] | None
34
+
35
+
36
+ class EntityProtocol(Protocol):
37
+ """Protocol defining the expected interface for entity definitions."""
38
+
39
+ name: str
40
+ actions: list[Any]
41
+ endpoints: dict[Any, EndpointProtocol]
42
+
43
+
44
+ class ConnectorModelProtocol(Protocol):
45
+ """Protocol defining the expected interface for connector model parameters.
46
+
47
+ This allows functions to work with any connector-like object
48
+ that has these attributes, including ConnectorModel and mock objects.
49
+ """
50
+
51
+ @property
52
+ def entities(self) -> list[EntityProtocol]: ...
53
+
54
+ @property
55
+ def openapi_spec(self) -> Any: ...
56
+
57
+
58
+ def format_param_signature(endpoint: EndpointProtocol) -> str:
59
+ """Format parameter signature for an endpoint action.
60
+
61
+ Returns a string like: (id*) or (limit?, starting_after?, email?)
62
+ where * = required, ? = optional
63
+
64
+ Args:
65
+ endpoint: Object conforming to EndpointProtocol (e.g., EndpointDefinition)
66
+
67
+ Returns:
68
+ Formatted parameter signature string
69
+ """
70
+ params = []
71
+
72
+ # Defensive: safely access attributes with defaults for malformed endpoints
73
+ path_params = getattr(endpoint, "path_params", []) or []
74
+ query_params = getattr(endpoint, "query_params", []) or []
75
+ query_params_schema = getattr(endpoint, "query_params_schema", {}) or {}
76
+ body_fields = getattr(endpoint, "body_fields", []) or []
77
+ request_schema = getattr(endpoint, "request_schema", None)
78
+
79
+ # Path params (always required)
80
+ for name in path_params:
81
+ params.append(f"{name}*")
82
+
83
+ # Query params
84
+ for name in query_params:
85
+ schema = query_params_schema.get(name, {})
86
+ required = schema.get("required", False)
87
+ params.append(f"{name}{'*' if required else '?'}")
88
+
89
+ # Body fields
90
+ if request_schema:
91
+ required_fields = set(request_schema.get("required", []))
92
+ for name in body_fields:
93
+ params.append(f"{name}{'*' if name in required_fields else '?'}")
94
+
95
+ return f"({', '.join(params)})" if params else "()"
96
+
97
+
98
+ def describe_entities(model: ConnectorModelProtocol) -> list[dict[str, Any]]:
99
+ """Generate entity descriptions from ConnectorModel.
100
+
101
+ Returns a list of entity descriptions with detailed parameter information
102
+ for each action. This is used by generated connectors' describe() method.
103
+
104
+ Args:
105
+ model: Object conforming to ConnectorModelProtocol (e.g., ConnectorModel)
106
+
107
+ Returns:
108
+ List of entity description dicts with keys:
109
+ - entity_name: Name of the entity (e.g., "contacts", "deals")
110
+ - description: Entity description from the first endpoint
111
+ - available_actions: List of actions (e.g., ["list", "get", "create"])
112
+ - parameters: Dict mapping action -> list of parameter dicts
113
+ """
114
+ entities = []
115
+ for entity_def in model.entities:
116
+ description = ""
117
+ parameters: dict[str, list[dict[str, Any]]] = {}
118
+
119
+ endpoints = getattr(entity_def, "endpoints", {}) or {}
120
+ if endpoints:
121
+ for action, endpoint in endpoints.items():
122
+ # Get description from first endpoint that has one
123
+ if not description:
124
+ endpoint_desc = getattr(endpoint, "description", None)
125
+ if endpoint_desc:
126
+ description = endpoint_desc
127
+
128
+ action_params: list[dict[str, Any]] = []
129
+
130
+ # Defensive: safely access endpoint attributes
131
+ path_params = getattr(endpoint, "path_params", []) or []
132
+ path_params_schema = getattr(endpoint, "path_params_schema", {}) or {}
133
+ query_params = getattr(endpoint, "query_params", []) or []
134
+ query_params_schema = getattr(endpoint, "query_params_schema", {}) or {}
135
+ body_fields = getattr(endpoint, "body_fields", []) or []
136
+ request_schema = getattr(endpoint, "request_schema", None)
137
+
138
+ # Path params (always required)
139
+ for param_name in path_params:
140
+ schema = path_params_schema.get(param_name, {})
141
+ action_params.append(
142
+ {
143
+ "name": param_name,
144
+ "in": "path",
145
+ "required": True,
146
+ "type": schema.get("type", "string"),
147
+ "description": schema.get("description", ""),
148
+ }
149
+ )
150
+
151
+ # Query params
152
+ for param_name in query_params:
153
+ schema = query_params_schema.get(param_name, {})
154
+ action_params.append(
155
+ {
156
+ "name": param_name,
157
+ "in": "query",
158
+ "required": schema.get("required", False),
159
+ "type": schema.get("type", "string"),
160
+ "description": schema.get("description", ""),
161
+ }
162
+ )
163
+
164
+ # Body fields
165
+ if request_schema:
166
+ required_fields = request_schema.get("required", [])
167
+ properties = request_schema.get("properties", {})
168
+ for param_name in body_fields:
169
+ prop = properties.get(param_name, {})
170
+ action_params.append(
171
+ {
172
+ "name": param_name,
173
+ "in": "body",
174
+ "required": param_name in required_fields,
175
+ "type": prop.get("type", "string"),
176
+ "description": prop.get("description", ""),
177
+ }
178
+ )
179
+
180
+ if action_params:
181
+ # Action is an enum, use .value to get string
182
+ action_key = action.value if hasattr(action, "value") else str(action)
183
+ parameters[action_key] = action_params
184
+
185
+ actions = getattr(entity_def, "actions", []) or []
186
+ entities.append(
187
+ {
188
+ "entity_name": entity_def.name,
189
+ "description": description,
190
+ "available_actions": [a.value if hasattr(a, "value") else str(a) for a in actions],
191
+ "parameters": parameters,
192
+ }
193
+ )
194
+
195
+ return entities
196
+
197
+
198
+ def generate_tool_description(model: ConnectorModelProtocol) -> str:
199
+ """Generate AI tool description from connector metadata.
200
+
201
+ Produces a detailed description that includes:
202
+ - Per-entity/action parameter signatures with required (*) and optional (?) markers
203
+ - Response structure documentation with pagination hints
204
+ - Example questions if available in the OpenAPI spec
205
+
206
+ This is used by the Connector.describe class method decorator to populate
207
+ function docstrings for AI framework integration.
208
+
209
+ Args:
210
+ model: Object conforming to ConnectorModelProtocol (e.g., ConnectorModel)
211
+
212
+ Returns:
213
+ Formatted description string suitable for AI tool documentation
214
+ """
215
+ lines = []
216
+
217
+ # Entity/action parameter details (including pagination params like limit, starting_after)
218
+ lines.append("ENTITIES AND PARAMETERS:")
219
+ for entity in model.entities:
220
+ lines.append(f" {entity.name}:")
221
+ actions = getattr(entity, "actions", []) or []
222
+ endpoints = getattr(entity, "endpoints", {}) or {}
223
+ for action in actions:
224
+ action_str = action.value if hasattr(action, "value") else str(action)
225
+ endpoint = endpoints.get(action)
226
+ if endpoint:
227
+ param_sig = format_param_signature(endpoint)
228
+ lines.append(f" - {action_str}{param_sig}")
229
+ else:
230
+ lines.append(f" - {action_str}()")
231
+
232
+ # Response structure (brief, includes pagination hint)
233
+ lines.append("")
234
+ lines.append("RESPONSE STRUCTURE:")
235
+ lines.append(" - list/api_search: {data: [...], meta: {has_more: bool}}")
236
+ lines.append(" - get: Returns entity directly (no envelope)")
237
+ lines.append(" To paginate: pass starting_after=<last_id> while has_more is true")
238
+
239
+ # Add example questions if available in openapi_spec
240
+ openapi_spec = getattr(model, "openapi_spec", None)
241
+ if openapi_spec:
242
+ info = getattr(openapi_spec, "info", None)
243
+ if info:
244
+ example_questions = getattr(info, "x_airbyte_example_questions", None)
245
+ if example_questions:
246
+ supported = getattr(example_questions, "supported", None)
247
+ if supported:
248
+ lines.append("")
249
+ lines.append("EXAMPLE QUESTIONS:")
250
+ for q in supported[:MAX_EXAMPLE_QUESTIONS]:
251
+ lines.append(f" - {q}")
252
+
253
+ # Generic parameter description for function signature
254
+ lines.append("")
255
+ lines.append("FUNCTION PARAMETERS:")
256
+ lines.append(" - entity: Entity name (string)")
257
+ lines.append(" - action: Operation to perform (string)")
258
+ lines.append(" - params: Operation parameters (dict) - see entity details above")
259
+ lines.append("")
260
+ lines.append("Parameter markers: * = required, ? = optional")
261
+
262
+ return "\n".join(lines)
@@ -0,0 +1,179 @@
1
+ """Unified configuration for connector-sdk."""
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ import yaml
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # New config location
16
+ CONFIG_DIR = Path.home() / ".airbyte" / "connector-sdk"
17
+ CONFIG_PATH = CONFIG_DIR / "config.yaml"
18
+
19
+ # Legacy file locations (for migration)
20
+ LEGACY_USER_ID_PATH = Path.home() / ".airbyte" / "ai_sdk_user_id"
21
+ LEGACY_INTERNAL_MARKER_PATH = Path.home() / ".airbyte" / "internal_user"
22
+
23
+
24
+ @dataclass
25
+ class SDKConfig:
26
+ """Connector SDK configuration."""
27
+
28
+ user_id: str = field(default_factory=lambda: str(uuid.uuid4()))
29
+ is_internal_user: bool = False
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ """Convert to dictionary for YAML serialization."""
33
+ return {
34
+ "user_id": self.user_id,
35
+ "is_internal_user": self.is_internal_user,
36
+ }
37
+
38
+
39
+ def _delete_legacy_files() -> None:
40
+ """
41
+ Delete legacy config files after successful migration.
42
+
43
+ Removes:
44
+ - ~/.airbyte/ai_sdk_user_id
45
+ - ~/.airbyte/internal_user
46
+ """
47
+ for legacy_path in [LEGACY_USER_ID_PATH, LEGACY_INTERNAL_MARKER_PATH]:
48
+ try:
49
+ if legacy_path.exists():
50
+ legacy_path.unlink()
51
+ logger.debug(f"Deleted legacy config file: {legacy_path}")
52
+ except Exception as e:
53
+ logger.debug(f"Could not delete legacy file {legacy_path}: {e}")
54
+
55
+
56
+ def _migrate_legacy_config() -> Optional[SDKConfig]:
57
+ """
58
+ Migrate from legacy file-based config to new YAML format.
59
+
60
+ Reads from:
61
+ - ~/.airbyte/ai_sdk_user_id (user_id)
62
+ - ~/.airbyte/internal_user (is_internal_user marker)
63
+
64
+ Returns SDKConfig if migration was successful, None otherwise.
65
+ """
66
+ user_id = None
67
+ is_internal = False
68
+
69
+ # Try to read legacy user_id
70
+ try:
71
+ if LEGACY_USER_ID_PATH.exists():
72
+ user_id = LEGACY_USER_ID_PATH.read_text().strip()
73
+ if not user_id:
74
+ user_id = None
75
+ except Exception:
76
+ pass
77
+
78
+ # Check legacy internal_user marker
79
+ try:
80
+ is_internal = LEGACY_INTERNAL_MARKER_PATH.exists()
81
+ except Exception:
82
+ pass
83
+
84
+ if user_id or is_internal:
85
+ return SDKConfig(
86
+ user_id=user_id or str(uuid.uuid4()),
87
+ is_internal_user=is_internal,
88
+ )
89
+
90
+ return None
91
+
92
+
93
+ def load_config() -> SDKConfig:
94
+ """
95
+ Load SDK configuration from config file.
96
+
97
+ Checks (in order):
98
+ 1. New config file at ~/.airbyte/connector-sdk/config.yaml
99
+ 2. Legacy files at ~/.airbyte/ai_sdk_user_id and ~/.airbyte/internal_user
100
+ 3. Creates new config with generated user_id if nothing exists
101
+
102
+ Environment variable AIRBYTE_INTERNAL_USER can override is_internal_user.
103
+
104
+ Returns:
105
+ SDKConfig with user_id and is_internal_user
106
+ """
107
+ config = None
108
+
109
+ # Try to load from new config file
110
+ try:
111
+ if CONFIG_PATH.exists():
112
+ content = CONFIG_PATH.read_text()
113
+ data = yaml.safe_load(content) or {}
114
+ config = SDKConfig(
115
+ user_id=data.get("user_id", str(uuid.uuid4())),
116
+ is_internal_user=data.get("is_internal_user", False),
117
+ )
118
+ # Always clean up legacy files if they exist (even if new config exists)
119
+ _delete_legacy_files()
120
+ except Exception as e:
121
+ logger.debug(f"Could not load config from {CONFIG_PATH}: {e}")
122
+
123
+ # Try to migrate from legacy files if new config doesn't exist
124
+ if config is None:
125
+ config = _migrate_legacy_config()
126
+ if config:
127
+ # Save migrated config to new location
128
+ try:
129
+ save_config(config)
130
+ logger.debug("Migrated legacy config to new location")
131
+ # Delete legacy files after successful migration
132
+ _delete_legacy_files()
133
+ except Exception as e:
134
+ logger.debug(f"Could not save migrated config: {e}")
135
+
136
+ # Create new config if nothing exists
137
+ if config is None:
138
+ config = SDKConfig()
139
+ try:
140
+ save_config(config)
141
+ except Exception as e:
142
+ logger.debug(f"Could not save new config: {e}")
143
+
144
+ # Environment variable override for is_internal_user
145
+ env_value = os.getenv("AIRBYTE_INTERNAL_USER", "").lower()
146
+ if env_value in ("true", "1", "yes"):
147
+ config.is_internal_user = True
148
+ elif env_value:
149
+ # Any other non-empty value (including "false", "0", "no") defaults to False
150
+ config.is_internal_user = False
151
+
152
+ return config
153
+
154
+
155
+ def save_config(config: SDKConfig) -> None:
156
+ """
157
+ Save SDK configuration to config file.
158
+
159
+ Creates the config directory if it doesn't exist.
160
+ Uses atomic writes to prevent corruption from concurrent access.
161
+
162
+ Args:
163
+ config: SDKConfig to save
164
+ """
165
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
166
+
167
+ # Use atomic write: write to temp file then rename (atomic on POSIX)
168
+ fd, temp_path = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".tmp")
169
+ try:
170
+ with os.fdopen(fd, "w") as f:
171
+ yaml.dump(config.to_dict(), f, default_flow_style=False)
172
+ os.rename(temp_path, CONFIG_PATH)
173
+ except Exception:
174
+ # Clean up temp file on failure
175
+ try:
176
+ os.unlink(temp_path)
177
+ except OSError:
178
+ pass
179
+ raise
@@ -3,46 +3,40 @@
3
3
  import logging
4
4
  import uuid
5
5
  from datetime import UTC, datetime
6
- from pathlib import Path
7
6
  from typing import Any, Dict, Optional
8
7
 
8
+ from .config import SDKConfig, load_config
9
+
9
10
  logger = logging.getLogger(__name__)
10
11
 
12
+ # Cache the config at module level to avoid repeated reads
13
+ _cached_config: Optional[SDKConfig] = None
14
+
15
+
16
+ def _get_config() -> SDKConfig:
17
+ """Get cached SDK config or load from file."""
18
+ global _cached_config
19
+ if _cached_config is None:
20
+ _cached_config = load_config()
21
+ return _cached_config
22
+
23
+
24
+ def _clear_config_cache() -> None:
25
+ """Clear the cached config. Used for testing."""
26
+ global _cached_config
27
+ _cached_config = None
28
+
11
29
 
12
30
  def get_persistent_user_id() -> str:
13
31
  """
14
- Get or create an anonymous user ID stored in the home directory.
32
+ Get the persistent anonymous user ID.
15
33
 
16
- The ID is stored in ~/.airbyte/ai_sdk_user_id and persists across all sessions.
17
- If the file doesn't exist, a new UUID is generated and saved.
34
+ Now reads from ~/.airbyte/connector-sdk/config.yaml
18
35
 
19
36
  Returns:
20
37
  An anonymous UUID string that uniquely identifies this user across sessions.
21
38
  """
22
- try:
23
- # Create .airbyte directory in home folder if it doesn't exist
24
- airbyte_dir = Path.home() / ".airbyte"
25
- airbyte_dir.mkdir(exist_ok=True)
26
-
27
- # Path to user ID file
28
- user_id_file = airbyte_dir / "ai_sdk_user_id"
29
-
30
- # Try to read existing user ID
31
- if user_id_file.exists():
32
- user_id = user_id_file.read_text().strip()
33
- if user_id: # Validate it's not empty
34
- return user_id
35
-
36
- # Generate new user ID if file doesn't exist or is empty
37
- user_id = str(uuid.uuid4())
38
- user_id_file.write_text(user_id)
39
- logger.debug(f"Generated new anonymous user ID: {user_id}")
40
-
41
- return user_id
42
- except Exception as e:
43
- # If we can't read/write the file, generate a session-only ID
44
- logger.debug(f"Could not access anonymous user ID file: {e}")
45
- return str(uuid.uuid4())
39
+ return _get_config().user_id
46
40
 
47
41
 
48
42
  def get_public_ip() -> Optional[str]:
@@ -65,6 +59,18 @@ def get_public_ip() -> Optional[str]:
65
59
  return None
66
60
 
67
61
 
62
+ def get_is_internal_user() -> bool:
63
+ """
64
+ Check if the current user is an internal Airbyte user.
65
+
66
+ Now reads from ~/.airbyte/connector-sdk/config.yaml
67
+ Environment variable AIRBYTE_INTERNAL_USER can override.
68
+
69
+ Returns False if not set or on any error.
70
+ """
71
+ return _get_config().is_internal_user
72
+
73
+
68
74
  class ObservabilitySession:
69
75
  """Shared session context for both logging and telemetry."""
70
76
 
@@ -84,6 +90,7 @@ class ObservabilitySession:
84
90
  self.operation_count = 0
85
91
  self.metadata: Dict[str, Any] = {}
86
92
  self.public_ip = get_public_ip()
93
+ self.is_internal_user = get_is_internal_user()
87
94
 
88
95
  def increment_operations(self):
89
96
  """Increment the operation counter."""
@@ -65,8 +65,9 @@ class Schema(BaseModel):
65
65
  write_only: Optional[bool] = Field(None, alias="writeOnly")
66
66
  deprecated: Optional[bool] = None
67
67
 
68
- # Airbyte extension
68
+ # Airbyte extensions
69
69
  x_airbyte_entity_name: Optional[str] = Field(None, alias="x-airbyte-entity-name")
70
+ x_airbyte_stream_name: Optional[str] = Field(None, alias="x-airbyte-stream-name")
70
71
 
71
72
 
72
73
  class Parameter(BaseModel):
@@ -61,7 +61,7 @@ class Operation(BaseModel):
61
61
  description=(
62
62
  "JSONPath expression to extract records from API response envelopes. "
63
63
  "When specified, executor extracts data at this path instead of returning "
64
- "full response. Returns array for list/search actions, single record for "
64
+ "full response. Returns array for list/api_search actions, single record for "
65
65
  "get/create/update/delete actions."
66
66
  ),
67
67
  )
@@ -77,6 +77,10 @@ class AuthConfigOption(BaseModel):
77
77
  default_factory=dict,
78
78
  description="Mapping from auth parameters (e.g., 'username', 'password', 'token') to template strings using ${field} syntax",
79
79
  )
80
+ replication_auth_key_mapping: Optional[Dict[str, str]] = Field(
81
+ None,
82
+ description="Mapping from source config paths (e.g., 'credentials.api_key') to auth config keys for direct connectors",
83
+ )
80
84
 
81
85
 
82
86
  class AirbyteAuthConfig(BaseModel):
@@ -99,6 +103,12 @@ class AirbyteAuthConfig(BaseModel):
99
103
  properties: Optional[Dict[str, AuthConfigFieldSpec]] = None
100
104
  auth_mapping: Optional[Dict[str, str]] = None
101
105
 
106
+ # Replication connector auth mapping
107
+ replication_auth_key_mapping: Optional[Dict[str, str]] = Field(
108
+ None,
109
+ description="Mapping from source config paths (e.g., 'credentials.api_key') to auth config keys for direct connectors",
110
+ )
111
+
102
112
  # Multiple options (oneOf)
103
113
  one_of: Optional[List[AuthConfigOption]] = Field(None, alias="oneOf")
104
114
 
@@ -1,6 +1,6 @@
1
1
  """Telemetry event models."""
2
2
 
3
- from dataclasses import asdict, dataclass
3
+ from dataclasses import asdict, dataclass, field
4
4
  from datetime import datetime
5
5
  from typing import Any, Dict, Optional
6
6
 
@@ -13,6 +13,7 @@ class BaseEvent:
13
13
  session_id: str
14
14
  user_id: str
15
15
  execution_context: str
16
+ is_internal_user: bool = field(default=False, kw_only=True)
16
17
 
17
18
  def to_dict(self) -> Dict[str, Any]:
18
19
  """Convert event to dictionary with ISO formatted timestamp."""
@@ -59,6 +59,7 @@ class SegmentTracker:
59
59
  session_id=self.session.session_id,
60
60
  user_id=self.session.user_id,
61
61
  execution_context=self.session.execution_context,
62
+ is_internal_user=self.session.is_internal_user,
62
63
  public_ip=self.session.public_ip,
63
64
  connector_name=self.session.connector_name,
64
65
  connector_version=connector_version,
@@ -101,6 +102,7 @@ class SegmentTracker:
101
102
  session_id=self.session.session_id,
102
103
  user_id=self.session.user_id,
103
104
  execution_context=self.session.execution_context,
105
+ is_internal_user=self.session.is_internal_user,
104
106
  public_ip=self.session.public_ip,
105
107
  connector_name=self.session.connector_name,
106
108
  entity=entity,
@@ -130,6 +132,7 @@ class SegmentTracker:
130
132
  session_id=self.session.session_id,
131
133
  user_id=self.session.user_id,
132
134
  execution_context=self.session.execution_context,
135
+ is_internal_user=self.session.is_internal_user,
133
136
  public_ip=self.session.public_ip,
134
137
  connector_name=self.session.connector_name,
135
138
  duration_seconds=self.session.duration_seconds(),
@@ -22,7 +22,7 @@ class Action(str, Enum):
22
22
  UPDATE = "update"
23
23
  DELETE = "delete"
24
24
  LIST = "list"
25
- SEARCH = "search"
25
+ API_SEARCH = "api_search"
26
26
  DOWNLOAD = "download"
27
27
  AUTHORIZE = "authorize"
28
28
 
@@ -221,6 +221,10 @@ class EntityDefinition(BaseModel):
221
221
  model_config = {"populate_by_name": True}
222
222
 
223
223
  name: str
224
+ stream_name: str | None = Field(
225
+ default=None,
226
+ description="Airbyte stream name for cache lookup (from x-airbyte-stream-name schema extension)",
227
+ )
224
228
  actions: list[Action]
225
229
  endpoints: dict[Action, EndpointDefinition]
226
230
  entity_schema: dict[str, Any] | None = Field(default=None, alias="schema")