agentscope-runtime 1.0.4__py3-none-any.whl → 1.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. agentscope_runtime/adapters/agentscope/stream.py +1 -1
  2. agentscope_runtime/adapters/langgraph/stream.py +120 -70
  3. agentscope_runtime/cli/commands/deploy.py +465 -1
  4. agentscope_runtime/cli/commands/stop.py +16 -0
  5. agentscope_runtime/common/container_clients/__init__.py +52 -0
  6. agentscope_runtime/common/container_clients/agentrun_client.py +6 -4
  7. agentscope_runtime/common/container_clients/boxlite_client.py +442 -0
  8. agentscope_runtime/common/container_clients/docker_client.py +0 -20
  9. agentscope_runtime/common/container_clients/fc_client.py +6 -4
  10. agentscope_runtime/common/container_clients/gvisor_client.py +38 -0
  11. agentscope_runtime/common/container_clients/knative_client.py +1 -0
  12. agentscope_runtime/common/utils/deprecation.py +164 -0
  13. agentscope_runtime/engine/app/agent_app.py +16 -4
  14. agentscope_runtime/engine/deployers/__init__.py +31 -20
  15. agentscope_runtime/engine/deployers/adapter/__init__.py +8 -0
  16. agentscope_runtime/engine/deployers/adapter/a2a/a2a_protocol_adapter.py +9 -8
  17. agentscope_runtime/engine/deployers/adapter/a2a/nacos_a2a_registry.py +19 -1
  18. agentscope_runtime/engine/deployers/adapter/agui/__init__.py +8 -0
  19. agentscope_runtime/engine/deployers/adapter/agui/agui_adapter_utils.py +652 -0
  20. agentscope_runtime/engine/deployers/adapter/agui/agui_protocol_adapter.py +225 -0
  21. agentscope_runtime/engine/deployers/pai_deployer.py +2335 -0
  22. agentscope_runtime/engine/deployers/utils/net_utils.py +37 -0
  23. agentscope_runtime/engine/deployers/utils/oss_utils.py +38 -0
  24. agentscope_runtime/engine/deployers/utils/package.py +46 -42
  25. agentscope_runtime/engine/helpers/agent_api_client.py +372 -0
  26. agentscope_runtime/engine/runner.py +1 -0
  27. agentscope_runtime/engine/schemas/agent_schemas.py +9 -3
  28. agentscope_runtime/engine/services/agent_state/__init__.py +7 -0
  29. agentscope_runtime/engine/services/memory/__init__.py +7 -0
  30. agentscope_runtime/engine/services/memory/redis_memory_service.py +15 -16
  31. agentscope_runtime/engine/services/session_history/__init__.py +7 -0
  32. agentscope_runtime/engine/tracing/local_logging_handler.py +2 -3
  33. agentscope_runtime/sandbox/box/sandbox.py +4 -0
  34. agentscope_runtime/sandbox/manager/sandbox_manager.py +11 -25
  35. agentscope_runtime/sandbox/manager/server/config.py +3 -1
  36. agentscope_runtime/sandbox/model/manager_config.py +11 -9
  37. agentscope_runtime/tools/modelstudio_memory/__init__.py +106 -0
  38. agentscope_runtime/tools/modelstudio_memory/base.py +220 -0
  39. agentscope_runtime/tools/modelstudio_memory/config.py +86 -0
  40. agentscope_runtime/tools/modelstudio_memory/core.py +594 -0
  41. agentscope_runtime/tools/modelstudio_memory/exceptions.py +60 -0
  42. agentscope_runtime/tools/modelstudio_memory/schemas.py +253 -0
  43. agentscope_runtime/version.py +1 -1
  44. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/METADATA +101 -62
  45. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/RECORD +49 -34
  46. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/WHEEL +0 -0
  47. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/entry_points.txt +0 -0
  48. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/licenses/LICENSE +0 -0
  49. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2335 @@
1
+ # -*- coding: utf-8 -*-
2
+ # pylint:disable=too-many-nested-blocks, too-many-return-statements,
3
+ # pylint:disable=too-many-branches, too-many-statements, try-except-raise
4
+ # pylint:disable=ungrouped-imports, arguments-renamed, protected-access
5
+ #
6
+ # flake8: noqa: E501
7
+ import asyncio
8
+ import fnmatch
9
+ import glob
10
+ import json
11
+ import logging
12
+ import os
13
+ import posixpath
14
+ import time
15
+ import zipfile
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Dict, Optional, List, Union, Any, Literal, cast
19
+
20
+ import yaml
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+
24
+ from ...version import __version__
25
+
26
+ from .utils.oss_utils import parse_oss_uri
27
+ from .utils.net_utils import is_tcp_reachable
28
+ from .adapter.protocol_adapter import ProtocolAdapter
29
+ from .base import DeployManager
30
+ from .state import Deployment
31
+ from .utils.package import generate_build_directory
32
+
33
+
34
+ try:
35
+ import alibabacloud_oss_v2 as oss
36
+ from alibabacloud_aiworkspace20210204.client import (
37
+ Client as WorkspaceClient,
38
+ )
39
+ from alibabacloud_eas20210701.client import Client as EASClient
40
+ from alibabacloud_tea_openapi import models as open_api_models
41
+ from alibabacloud_tea_openapi.client import Client as OpenApiClient
42
+ from alibabacloud_tea_openapi import utils_models as open_api_util_models
43
+ from alibabacloud_tea_openapi.utils import Utils as OpenApiUtils
44
+
45
+ PAI_AVAILABLE = True
46
+ except ImportError:
47
+ oss = None
48
+ WorkspaceClient = None
49
+ EASClient = None
50
+ open_api_models = None
51
+ OpenApiClient = None
52
+ open_api_util_models = None
53
+ OpenApiUtils = None
54
+ PAI_AVAILABLE = False
55
+
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ class LangStudioClient:
61
+ """
62
+ A lightweight PAI LangStudio API client .
63
+
64
+ This client provides direct access to the PAI LangStudio API endpoints
65
+ using the alibabacloud_tea_openapi.client.Client for request handling.
66
+ """
67
+
68
+ API_VERSION = "2024-07-10"
69
+
70
+ def __init__(
71
+ self,
72
+ config: "open_api_models.Config",
73
+ ):
74
+ """
75
+ Initialize the LangStudio client.
76
+
77
+ Args:
78
+ config: OpenAPI configuration with credentials and endpoint
79
+ """
80
+ if OpenApiClient is None:
81
+ raise ImportError(
82
+ "alibabacloud_tea_openapi is required. "
83
+ "Install with: pip install alibabacloud_tea_openapi",
84
+ )
85
+ self._client = OpenApiClient(config)
86
+ self._client._endpoint_rule = ""
87
+ self._client.check_config(config)
88
+ if config.endpoint:
89
+ self._client._endpoint = config.endpoint
90
+ else:
91
+ self._client._endpoint = (
92
+ f"pailangstudio.{config.region_id}.aliyuncs.com"
93
+ )
94
+
95
+ def _build_params(
96
+ self,
97
+ action: str,
98
+ pathname: str,
99
+ method: str,
100
+ ) -> "open_api_util_models.Params":
101
+ """Build request parameters."""
102
+ return open_api_util_models.Params(
103
+ action=action,
104
+ version=self.API_VERSION,
105
+ protocol="HTTPS",
106
+ pathname=pathname,
107
+ method=method,
108
+ auth_type="AK",
109
+ style="ROA",
110
+ req_body_type="json",
111
+ body_type="json",
112
+ )
113
+
114
+ @staticmethod
115
+ def _percent_encode(value: str) -> str:
116
+ """URL percent-encode a value."""
117
+ from urllib.parse import quote
118
+
119
+ return quote(str(value), safe="")
120
+
121
+ # =========================================================================
122
+ # Flow APIs
123
+ # =========================================================================
124
+
125
+ async def list_flows_async(
126
+ self,
127
+ workspace_id: str,
128
+ flow_name: Optional[str] = None,
129
+ flow_id: Optional[str] = None,
130
+ flow_type: Optional[str] = None,
131
+ creator: Optional[str] = None,
132
+ user_id: Optional[str] = None,
133
+ sort_by: Optional[str] = None,
134
+ order: Optional[str] = None,
135
+ page_number: Optional[int] = None,
136
+ page_size: Optional[int] = None,
137
+ max_results: Optional[int] = None,
138
+ next_token: Optional[str] = None,
139
+ ) -> Dict[str, Any]:
140
+ """
141
+ List flows in a workspace.
142
+
143
+ Args:
144
+ workspace_id: The workspace ID
145
+ flow_name: Filter by flow name
146
+ flow_id: Filter by flow ID
147
+ flow_type: Filter by flow type
148
+ creator: Filter by creator
149
+ user_id: Filter by user ID
150
+ sort_by: Sort field
151
+ order: Sort order (ASC/DESC)
152
+ page_number: Page number
153
+ page_size: Page size
154
+ max_results: Maximum results
155
+ next_token: Pagination token
156
+
157
+ Returns:
158
+ Dict containing flows list and pagination info
159
+ """
160
+ from darabonba.runtime import RuntimeOptions
161
+
162
+ query: Dict[str, Any] = {"WorkspaceId": workspace_id}
163
+ if flow_name is not None:
164
+ query["FlowName"] = flow_name
165
+ if flow_id is not None:
166
+ query["FlowId"] = flow_id
167
+ if flow_type is not None:
168
+ query["FlowType"] = flow_type
169
+ if creator is not None:
170
+ query["Creator"] = creator
171
+ if user_id is not None:
172
+ query["UserId"] = user_id
173
+ if sort_by is not None:
174
+ query["SortBy"] = sort_by
175
+ if order is not None:
176
+ query["Order"] = order
177
+ if page_number is not None:
178
+ query["PageNumber"] = page_number
179
+ if page_size is not None:
180
+ query["PageSize"] = page_size
181
+ if max_results is not None:
182
+ query["MaxResults"] = max_results
183
+ if next_token is not None:
184
+ query["NextToken"] = next_token
185
+
186
+ req = open_api_util_models.OpenApiRequest(
187
+ headers={},
188
+ query=OpenApiUtils.query(query),
189
+ )
190
+ params = self._build_params(
191
+ action="ListFlows",
192
+ pathname="/api/v1/langstudio/flows",
193
+ method="GET",
194
+ )
195
+ runtime = RuntimeOptions()
196
+ result = await self._client.call_api_async(params, req, runtime)
197
+ return result.get("body", {})
198
+
199
+ async def get_flow_async(
200
+ self,
201
+ flow_id: str,
202
+ workspace_id: str,
203
+ include_code_mode_run_info: Optional[bool] = None,
204
+ ) -> Dict[str, Any]:
205
+ """
206
+ Get a flow by ID.
207
+
208
+ Args:
209
+ flow_id: The flow ID
210
+ workspace_id: The workspace ID
211
+ include_code_mode_run_info: Include code mode run info
212
+
213
+ Returns:
214
+ Dict containing flow details
215
+ """
216
+ from darabonba.runtime import RuntimeOptions
217
+
218
+ query: Dict[str, Any] = {"WorkspaceId": workspace_id}
219
+ if include_code_mode_run_info is not None:
220
+ query["IncludeCodeModeRunInfo"] = include_code_mode_run_info
221
+
222
+ req = open_api_util_models.OpenApiRequest(
223
+ headers={},
224
+ query=OpenApiUtils.query(query),
225
+ )
226
+ params = self._build_params(
227
+ action="GetFlow",
228
+ pathname=f"/api/v1/langstudio/flows/{self._percent_encode(flow_id)}",
229
+ method="GET",
230
+ )
231
+ runtime = RuntimeOptions()
232
+ result = await self._client.call_api_async(params, req, runtime)
233
+ return result.get("body", {})
234
+
235
+ async def create_flow_async(
236
+ self,
237
+ workspace_id: str,
238
+ flow_name: str,
239
+ flow_type: str,
240
+ description: Optional[str] = None,
241
+ source_uri: Optional[str] = None,
242
+ work_dir: Optional[str] = None,
243
+ create_from: Optional[str] = None,
244
+ accessibility: Optional[str] = None,
245
+ flow_template_id: Optional[str] = None,
246
+ runtime_id: Optional[str] = None,
247
+ source_flow_id: Optional[str] = None,
248
+ ) -> Dict[str, Any]:
249
+ """
250
+ Create a new flow.
251
+
252
+ Args:
253
+ workspace_id: The workspace ID
254
+ flow_name: The flow name
255
+ flow_type: The flow type (e.g., "Code")
256
+ description: Flow description
257
+ source_uri: Source URI (for OSS imports)
258
+ work_dir: Working directory
259
+ create_from: Creation source (e.g., "OSS")
260
+ accessibility: Accessibility setting
261
+ flow_template_id: Template ID to create from
262
+ runtime_id: Runtime ID
263
+ source_flow_id: Source flow ID to copy from
264
+
265
+ Returns:
266
+ Dict containing the created flow info with flow_id
267
+ """
268
+ from darabonba.runtime import RuntimeOptions
269
+
270
+ body: Dict[str, Any] = {
271
+ "WorkspaceId": workspace_id,
272
+ "FlowName": flow_name,
273
+ "FlowType": flow_type,
274
+ }
275
+ if description is not None:
276
+ body["Description"] = description
277
+ if source_uri is not None:
278
+ body["SourceUri"] = source_uri
279
+ if work_dir is not None:
280
+ body["WorkDir"] = work_dir
281
+ if create_from is not None:
282
+ body["CreateFrom"] = create_from
283
+ if accessibility is not None:
284
+ body["Accessibility"] = accessibility
285
+ if flow_template_id is not None:
286
+ body["FlowTemplateId"] = flow_template_id
287
+ if runtime_id is not None:
288
+ body["RuntimeId"] = runtime_id
289
+ if source_flow_id is not None:
290
+ body["SourceFlowId"] = source_flow_id
291
+
292
+ req = open_api_util_models.OpenApiRequest(
293
+ headers={},
294
+ body=OpenApiUtils.parse_to_map(body),
295
+ )
296
+ params = self._build_params(
297
+ action="CreateFlow",
298
+ pathname="/api/v1/langstudio/flows",
299
+ method="POST",
300
+ )
301
+ runtime = RuntimeOptions()
302
+ result = await self._client.call_api_async(params, req, runtime)
303
+ return result.get("body", {})
304
+
305
+ async def delete_flow_async(
306
+ self,
307
+ flow_id: str,
308
+ workspace_id: str,
309
+ ) -> Dict[str, Any]:
310
+ """
311
+ Delete a flow.
312
+
313
+ Args:
314
+ flow_id: The flow ID to delete
315
+ workspace_id: The workspace ID
316
+
317
+ Returns:
318
+ Dict containing deletion result
319
+ """
320
+ from darabonba.runtime import RuntimeOptions
321
+
322
+ query: Dict[str, Any] = {"WorkspaceId": workspace_id}
323
+
324
+ req = open_api_util_models.OpenApiRequest(
325
+ headers={},
326
+ query=OpenApiUtils.query(query),
327
+ )
328
+ params = self._build_params(
329
+ action="DeleteFlow",
330
+ pathname=f"/api/v1/langstudio/flows/{self._percent_encode(flow_id)}",
331
+ method="DELETE",
332
+ )
333
+ runtime = RuntimeOptions()
334
+ result = await self._client.call_api_async(params, req, runtime)
335
+ return result.get("body", {})
336
+
337
+ # =========================================================================
338
+ # Snapshot APIs
339
+ # =========================================================================
340
+
341
+ async def create_snapshot_async(
342
+ self,
343
+ workspace_id: str,
344
+ snapshot_resource_type: str,
345
+ snapshot_resource_id: str,
346
+ snapshot_name: str,
347
+ source_storage_path: Optional[str] = None,
348
+ work_dir: Optional[str] = None,
349
+ description: Optional[str] = None,
350
+ accessibility: Optional[str] = None,
351
+ creation_type: Optional[str] = None,
352
+ ) -> Dict[str, Any]:
353
+ """
354
+ Create a snapshot.
355
+
356
+ Args:
357
+ workspace_id: The workspace ID
358
+ snapshot_resource_type: Resource type (e.g., "Flow")
359
+ snapshot_resource_id: Resource ID (e.g., flow_id)
360
+ snapshot_name: Name for the snapshot
361
+ source_storage_path: Source storage path (OSS URI)
362
+ work_dir: Working directory
363
+ description: Snapshot description
364
+ accessibility: Accessibility setting
365
+ creation_type: Creation type
366
+
367
+ Returns:
368
+ Dict containing snapshot_id
369
+ """
370
+ from darabonba.runtime import RuntimeOptions
371
+
372
+ body: Dict[str, Any] = {
373
+ "WorkspaceId": workspace_id,
374
+ "SnapshotResourceType": snapshot_resource_type,
375
+ "SnapshotResourceId": snapshot_resource_id,
376
+ "SnapshotName": snapshot_name,
377
+ }
378
+ if source_storage_path is not None:
379
+ body["SourceStoragePath"] = source_storage_path
380
+ if work_dir is not None:
381
+ body["WorkDir"] = work_dir
382
+ if description is not None:
383
+ body["Description"] = description
384
+ if accessibility is not None:
385
+ body["Accessibility"] = accessibility
386
+ if creation_type is not None:
387
+ body["CreationType"] = creation_type
388
+
389
+ req = open_api_util_models.OpenApiRequest(
390
+ headers={},
391
+ body=OpenApiUtils.parse_to_map(body),
392
+ )
393
+ params = self._build_params(
394
+ action="CreateSnapshot",
395
+ pathname="/api/v1/langstudio/snapshots",
396
+ method="POST",
397
+ )
398
+ runtime = RuntimeOptions()
399
+ result = await self._client.call_api_async(params, req, runtime)
400
+ return result.get("body", {})
401
+
402
+ # =========================================================================
403
+ # Deployment APIs
404
+ # =========================================================================
405
+
406
+ async def create_deployment_async(
407
+ self,
408
+ workspace_id: str,
409
+ resource_type: str,
410
+ resource_id: str,
411
+ resource_snapshot_id: str,
412
+ service_name: str,
413
+ work_dir: Optional[str] = None,
414
+ deployment_config: Optional[str] = None,
415
+ credential_config: Optional[Dict[str, Any]] = None,
416
+ enable_trace: Optional[bool] = None,
417
+ auto_approval: Optional[bool] = None,
418
+ service_group: Optional[str] = None,
419
+ description: Optional[str] = None,
420
+ accessibility: Optional[str] = None,
421
+ envs: Optional[Dict[str, str]] = None,
422
+ labels: Optional[Dict[str, str]] = None,
423
+ user_vpc: Optional[Dict[str, Any]] = None,
424
+ ecs_spec: Optional[str] = None,
425
+ data_sources: Optional[List[Dict[str, Any]]] = None,
426
+ chat_history_config: Optional[Dict[str, Any]] = None,
427
+ content_moderation_config: Optional[Dict[str, Any]] = None,
428
+ ) -> Dict[str, Any]:
429
+ """
430
+ Create a deployment.
431
+
432
+ Args:
433
+ workspace_id: The workspace ID
434
+ resource_type: Resource type (e.g., "Flow")
435
+ resource_id: Resource ID (e.g., flow_id)
436
+ resource_snapshot_id: Snapshot ID
437
+ service_name: Service name
438
+ work_dir: Working directory (OSS path)
439
+ deployment_config: Deployment configuration JSON string
440
+ credential_config: Credential configuration dict
441
+ enable_trace: Enable tracing
442
+ auto_approval: Auto approve deployment
443
+ service_group: Service group name
444
+ description: Deployment description
445
+ accessibility: Accessibility setting
446
+ envs: Environment variables
447
+ labels: Labels/tags
448
+ user_vpc: VPC configuration
449
+ ecs_spec: ECS specification
450
+ data_sources: Data sources configuration
451
+ chat_history_config: Chat history configuration
452
+ content_moderation_config: Content moderation configuration
453
+
454
+ Returns:
455
+ Dict containing deployment_id
456
+ """
457
+ from darabonba.runtime import RuntimeOptions
458
+
459
+ body: Dict[str, Any] = {
460
+ "WorkspaceId": workspace_id,
461
+ "ResourceType": resource_type,
462
+ "ResourceId": resource_id,
463
+ "ResourceSnapshotId": resource_snapshot_id,
464
+ "ServiceName": service_name,
465
+ }
466
+ if work_dir is not None:
467
+ body["WorkDir"] = work_dir
468
+ if deployment_config is not None:
469
+ body["DeploymentConfig"] = deployment_config
470
+ if credential_config is not None:
471
+ body["CredentialConfig"] = credential_config
472
+ if enable_trace is not None:
473
+ body["EnableTrace"] = enable_trace
474
+ if auto_approval is not None:
475
+ body["AutoApproval"] = auto_approval
476
+ if service_group is not None:
477
+ body["ServiceGroup"] = service_group
478
+ if description is not None:
479
+ body["Description"] = description
480
+ if accessibility is not None:
481
+ body["Accessibility"] = accessibility
482
+ if envs is not None:
483
+ body["Envs"] = envs
484
+ if labels is not None:
485
+ body["Labels"] = labels
486
+ if user_vpc is not None:
487
+ body["UserVpc"] = user_vpc
488
+ if ecs_spec is not None:
489
+ body["EcsSpec"] = ecs_spec
490
+ if data_sources is not None:
491
+ body["DataSources"] = data_sources
492
+ if chat_history_config is not None:
493
+ body["ChatHistoryConfig"] = chat_history_config
494
+ if content_moderation_config is not None:
495
+ body["ContentModerationConfig"] = content_moderation_config
496
+
497
+ req = open_api_util_models.OpenApiRequest(
498
+ headers={},
499
+ body=OpenApiUtils.parse_to_map(body),
500
+ )
501
+ params = self._build_params(
502
+ action="CreateDeployment",
503
+ pathname="/api/v1/langstudio/deployments",
504
+ method="POST",
505
+ )
506
+ runtime = RuntimeOptions()
507
+ result = await self._client.call_api_async(params, req, runtime)
508
+ return result.get("body", {})
509
+
510
+ async def get_deployment_async(
511
+ self,
512
+ deployment_id: str,
513
+ workspace_id: str,
514
+ ) -> Dict[str, Any]:
515
+ """
516
+ Get deployment details.
517
+
518
+ Args:
519
+ deployment_id: The deployment ID
520
+ workspace_id: The workspace ID
521
+
522
+ Returns:
523
+ Dict containing deployment details including status
524
+ """
525
+ from darabonba.runtime import RuntimeOptions
526
+
527
+ query: Dict[str, Any] = {"WorkspaceId": workspace_id}
528
+
529
+ req = open_api_util_models.OpenApiRequest(
530
+ headers={},
531
+ query=OpenApiUtils.query(query),
532
+ )
533
+ params = self._build_params(
534
+ action="GetDeployment",
535
+ pathname=(
536
+ f"/api/v1/langstudio/deployments/"
537
+ f"{self._percent_encode(deployment_id)}"
538
+ ),
539
+ method="GET",
540
+ )
541
+ runtime = RuntimeOptions()
542
+ result = await self._client.call_api_async(params, req, runtime)
543
+ return result.get("body", {})
544
+
545
+ async def update_deployment_async(
546
+ self,
547
+ deployment_id: str,
548
+ workspace_id: str,
549
+ stage_action: Optional[str] = None,
550
+ auto_approval: Optional[bool] = None,
551
+ deployment_config: Optional[str] = None,
552
+ description: Optional[str] = None,
553
+ ) -> Dict[str, Any]:
554
+ """
555
+ Update a deployment.
556
+
557
+ Args:
558
+ deployment_id: The deployment ID
559
+ workspace_id: The workspace ID
560
+ stage_action: Stage action JSON (e.g., {"Stage": 3, "Action": "Confirm"})
561
+ auto_approval: Auto approval setting
562
+ deployment_config: Deployment configuration
563
+ description: Deployment description
564
+
565
+ Returns:
566
+ Dict containing update result
567
+ """
568
+ from darabonba.runtime import RuntimeOptions
569
+
570
+ body: Dict[str, Any] = {"WorkspaceId": workspace_id}
571
+ if stage_action is not None:
572
+ body["StageAction"] = stage_action
573
+ if auto_approval is not None:
574
+ body["AutoApproval"] = auto_approval
575
+ if deployment_config is not None:
576
+ body["DeploymentConfig"] = deployment_config
577
+ if description is not None:
578
+ body["Description"] = description
579
+
580
+ req = open_api_util_models.OpenApiRequest(
581
+ headers={},
582
+ body=OpenApiUtils.parse_to_map(body),
583
+ )
584
+ params = self._build_params(
585
+ action="UpdateDeployment",
586
+ pathname=(
587
+ f"/api/v1/langstudio/deployments/"
588
+ f"{self._percent_encode(deployment_id)}"
589
+ ),
590
+ method="PUT",
591
+ )
592
+ runtime = RuntimeOptions()
593
+ result = await self._client.call_api_async(params, req, runtime)
594
+ return result.get("body", {})
595
+
596
+
597
+ class ConfigBaseModel(BaseModel):
598
+ model_config = ConfigDict(extra="allow")
599
+
600
+
601
+ class PAICodeConfig(ConfigBaseModel):
602
+ """Code configuration for PAI deployment."""
603
+
604
+ source_dir: Optional[str] = Field(
605
+ None,
606
+ description="Path to project root directory",
607
+ )
608
+ entrypoint: Optional[str] = Field(
609
+ None,
610
+ description="Entrypoint file within source_dir",
611
+ )
612
+
613
+
614
+ class PAIResourcesConfig(ConfigBaseModel):
615
+ """Resource configuration for PAI deployment."""
616
+
617
+ instance_count: int = Field(1, description="Number of service instances")
618
+ type: Optional[Literal["public", "resource", "quota"]] = Field(
619
+ None,
620
+ description="Resource type: public, resource (EAS group), or quota",
621
+ )
622
+ instance_type: Optional[str] = Field(
623
+ None,
624
+ description="ECS instance type for public mode",
625
+ )
626
+ resource_id: Optional[str] = Field(
627
+ None,
628
+ description="EAS resource group ID for resource mode",
629
+ )
630
+ quota_id: Optional[str] = Field(
631
+ None,
632
+ description="PAI quota ID for quota mode",
633
+ )
634
+ cpu: Optional[int] = Field(
635
+ None,
636
+ description="CPU cores for resource/quota mode",
637
+ )
638
+ memory: Optional[int] = Field(
639
+ None,
640
+ description="Memory in MB for resource/quota mode",
641
+ )
642
+
643
+
644
+ class PAIVpcConfig(ConfigBaseModel):
645
+ """VPC configuration for PAI deployment."""
646
+
647
+ vpc_id: Optional[str] = None
648
+ vswitch_id: Optional[str] = None
649
+ security_group_id: Optional[str] = None
650
+
651
+
652
+ class PAIIdentityConfig(ConfigBaseModel):
653
+ """Identity/Permission configuration for PAI deployment."""
654
+
655
+ ram_role_arn: Optional[str] = Field(
656
+ None,
657
+ description="RAM role ARN for service runtime",
658
+ )
659
+
660
+
661
+ class PAIObservabilityConfig(ConfigBaseModel):
662
+ """Observability configuration for PAI deployment."""
663
+
664
+ enable_trace: bool = Field(True, description="Enable tracing/telemetry")
665
+
666
+
667
+ class PAIStorageConfig(ConfigBaseModel):
668
+ """Storage configuration for PAI deployment."""
669
+
670
+ work_dir: Optional[str] = Field(
671
+ None,
672
+ description="OSS working directory for artifacts",
673
+ )
674
+
675
+
676
+ class PAIContextConfig(ConfigBaseModel):
677
+ """Context configuration (where to deploy)."""
678
+
679
+ workspace_id: Optional[str] = Field(
680
+ None,
681
+ description="PAI workspace ID",
682
+ )
683
+ region: Optional[str] = Field(
684
+ None,
685
+ description="Region code (e.g., cn-hangzhou)",
686
+ )
687
+ storage: PAIStorageConfig = Field(
688
+ default_factory=PAIStorageConfig,
689
+ description="Default storage configuration (fallback for spec.storage)",
690
+ )
691
+
692
+
693
+ class PAISpecConfig(ConfigBaseModel):
694
+ """Spec configuration (what to deploy)."""
695
+
696
+ name: Optional[str] = Field(None, description="Service name")
697
+ code: PAICodeConfig = Field(default_factory=PAICodeConfig)
698
+ service_group_name: Optional[str] = Field(
699
+ None,
700
+ description="Service group name",
701
+ )
702
+ resources: PAIResourcesConfig = Field(default_factory=PAIResourcesConfig)
703
+ vpc_config: PAIVpcConfig = Field(default_factory=PAIVpcConfig)
704
+ identity: PAIIdentityConfig = Field(
705
+ default_factory=PAIIdentityConfig,
706
+ )
707
+ observability: PAIObservabilityConfig = Field(
708
+ default_factory=PAIObservabilityConfig,
709
+ )
710
+ storage: PAIStorageConfig = Field(default_factory=PAIStorageConfig)
711
+ env: Dict[str, str] = Field(
712
+ default_factory=dict,
713
+ description="Environment variables",
714
+ )
715
+ tags: Dict[str, str] = Field(
716
+ default_factory=dict,
717
+ description="Tags for the deployment",
718
+ )
719
+
720
+
721
+ class PAIDeployConfig(ConfigBaseModel):
722
+ """
723
+ Complete PAI deployment configuration.
724
+
725
+ Supports both nested YAML structure and flat CLI parameters.
726
+ """
727
+
728
+ # Nested structure (from config file)
729
+ context: PAIContextConfig = Field(default_factory=PAIContextConfig)
730
+ spec: PAISpecConfig = Field(default_factory=PAISpecConfig)
731
+
732
+ # Deployment behavior
733
+ wait: bool = Field(True, description="Wait for deployment to complete")
734
+ timeout: int = Field(1800, description="Deployment timeout in seconds")
735
+ auto_approve: bool = Field(True, description="Auto approve deployment")
736
+
737
+ @classmethod
738
+ def from_yaml(cls, path: Union[str, Path]) -> "PAIDeployConfig":
739
+ """Load configuration from YAML file."""
740
+ with open(path, "r", encoding="utf-8") as f:
741
+ data = yaml.safe_load(f) or {}
742
+ return cls.model_validate(data)
743
+
744
+ @classmethod
745
+ def from_dict(cls, data: Dict[str, Any]) -> "PAIDeployConfig":
746
+ """Create configuration from dictionary."""
747
+ return cls.model_validate(data)
748
+
749
+ def merge_cli(
750
+ self,
751
+ source: Optional[str] = None,
752
+ name: Optional[str] = None,
753
+ entrypoint: Optional[str] = None,
754
+ workspace_id: Optional[str] = None,
755
+ region: Optional[str] = None,
756
+ oss_path: Optional[str] = None,
757
+ instance_type: Optional[str] = None,
758
+ instance_count: Optional[int] = None,
759
+ resource_id: Optional[str] = None,
760
+ quota_id: Optional[str] = None,
761
+ cpu: Optional[int] = None,
762
+ memory: Optional[int] = None,
763
+ service_group: Optional[str] = None,
764
+ resource_type: Optional[str] = None,
765
+ vpc_id: Optional[str] = None,
766
+ vswitch_id: Optional[str] = None,
767
+ security_group_id: Optional[str] = None,
768
+ ram_role_arn: Optional[str] = None,
769
+ enable_trace: Optional[bool] = None,
770
+ wait: Optional[bool] = None,
771
+ timeout: Optional[int] = None,
772
+ auto_approve: Optional[bool] = None,
773
+ environment: Optional[Dict[str, str]] = None,
774
+ tags: Optional[Dict[str, str]] = None,
775
+ ) -> "PAIDeployConfig":
776
+ """
777
+ Merge CLI parameters into config. CLI values override config values.
778
+
779
+ Returns a new PAIDeployConfig with merged values.
780
+ """
781
+ data = self.model_dump()
782
+
783
+ # Context overrides
784
+ if workspace_id is not None:
785
+ data["context"]["workspace_id"] = workspace_id
786
+ if region is not None:
787
+ data["context"]["region"] = region
788
+
789
+ # Spec overrides
790
+ if name is not None:
791
+ data["spec"]["name"] = name
792
+ if source is not None:
793
+ data["spec"]["code"]["source_dir"] = source
794
+ if entrypoint is not None:
795
+ data["spec"]["code"]["entrypoint"] = entrypoint
796
+ if service_group is not None:
797
+ data["spec"]["service_group_name"] = service_group
798
+
799
+ # Resources overrides
800
+ if resource_type is not None:
801
+ data["spec"]["resources"]["type"] = resource_type
802
+ if instance_type is not None:
803
+ data["spec"]["resources"]["instance_type"] = instance_type
804
+ if instance_count is not None:
805
+ data["spec"]["resources"]["instance_count"] = instance_count
806
+ if resource_id is not None:
807
+ data["spec"]["resources"]["resource_id"] = resource_id
808
+ if quota_id is not None:
809
+ data["spec"]["resources"]["quota_id"] = quota_id
810
+ if cpu is not None:
811
+ data["spec"]["resources"]["cpu"] = cpu
812
+ if memory is not None:
813
+ data["spec"]["resources"]["memory"] = memory
814
+
815
+ # VPC overrides
816
+ if vpc_id is not None:
817
+ data["spec"]["vpc_config"]["vpc_id"] = vpc_id
818
+ if vswitch_id is not None:
819
+ data["spec"]["vpc_config"]["vswitch_id"] = vswitch_id
820
+ if security_group_id is not None:
821
+ data["spec"]["vpc_config"]["security_group_id"] = security_group_id
822
+
823
+ # IAM overrides
824
+ if ram_role_arn is not None:
825
+ data["spec"]["identity"]["ram_role_arn"] = ram_role_arn
826
+
827
+ # Observability overrides
828
+ if enable_trace is not None:
829
+ data["spec"]["observability"]["enable_trace"] = enable_trace
830
+
831
+ # Storage overrides
832
+ if oss_path is not None:
833
+ data["spec"]["storage"]["work_dir"] = oss_path
834
+
835
+ # Environment overrides (merge, CLI takes precedence)
836
+ if environment:
837
+ data["spec"]["env"].update(environment)
838
+
839
+ # Tags overrides (merge, CLI takes precedence)
840
+ if tags:
841
+ data["spec"]["tags"].update(tags)
842
+
843
+ # Deployment behavior overrides
844
+ if wait is not None:
845
+ data["wait"] = wait
846
+ if timeout is not None:
847
+ data["timeout"] = timeout
848
+ if auto_approve is not None:
849
+ data["auto_approve"] = auto_approve
850
+
851
+ return PAIDeployConfig.model_validate(data)
852
+
853
+ def resolve_resource_type(self) -> str:
854
+ """
855
+ Resolve resource type with implicit inference.
856
+
857
+ Priority:
858
+ 1. Explicit type if set
859
+ 2. 'quota' if quota_id is provided
860
+ 3. 'resource' if resource_id is provided
861
+ 4. 'public' (default)
862
+ """
863
+ resources = self.spec.resources
864
+ if resources.type:
865
+ return resources.type
866
+ if resources.quota_id:
867
+ return "quota"
868
+ if resources.resource_id:
869
+ return "resource"
870
+ return "public"
871
+
872
+ def resolve_oss_work_dir(self) -> Optional[str]:
873
+ """
874
+ Resolve OSS work directory with fallback.
875
+
876
+ Priority:
877
+ 1. spec.storage.work_dir if set
878
+ 2. context.storage.work_dir as fallback
879
+ 3. None (deployer will use workspace default)
880
+ """
881
+ if self.spec.storage.work_dir:
882
+ return self.spec.storage.work_dir
883
+ if self.context.storage.work_dir:
884
+ return self.context.storage.work_dir
885
+ return None
886
+
887
+ def to_deployer_kwargs(self) -> Dict[str, Any]:
888
+ """
889
+ Convert config to kwargs for PAIDeployManager.deploy().
890
+ """
891
+ resource_type = self.resolve_resource_type()
892
+ resources = self.spec.resources
893
+
894
+ # Determine RAM role mode
895
+ ram_role_arn = self.spec.identity.ram_role_arn
896
+ ram_role_mode = "custom" if ram_role_arn else "default"
897
+
898
+ # Apply default values based on resource_type
899
+ instance_type = resources.instance_type
900
+ cpu = resources.cpu
901
+ memory = resources.memory
902
+
903
+ if resource_type == "public":
904
+ # Default instance_type for public mode
905
+ if not instance_type:
906
+ instance_type = "ecs.c6.large"
907
+ elif resource_type in ("resource", "quota"):
908
+ # Default cpu and memory for resource/quota mode
909
+ if cpu is None:
910
+ cpu = 2
911
+ if memory is None:
912
+ memory = 2048
913
+
914
+ kwargs = {
915
+ "project_dir": self.spec.code.source_dir,
916
+ "entrypoint": self.spec.code.entrypoint,
917
+ "service_name": self.spec.name,
918
+ "service_group_name": self.spec.service_group_name,
919
+ "resource_type": resource_type,
920
+ "instance_count": resources.instance_count,
921
+ "instance_type": instance_type,
922
+ "resource_id": resources.resource_id,
923
+ "quota_id": resources.quota_id,
924
+ "cpu": cpu,
925
+ "memory": memory,
926
+ "vpc_id": self.spec.vpc_config.vpc_id,
927
+ "vswitch_id": self.spec.vpc_config.vswitch_id,
928
+ "security_group_id": self.spec.vpc_config.security_group_id,
929
+ "ram_role_mode": ram_role_mode,
930
+ "ram_role_arn": ram_role_arn,
931
+ "enable_trace": self.spec.observability.enable_trace,
932
+ "environment": self.spec.env if self.spec.env else None,
933
+ "tags": self.spec.tags if self.spec.tags else None,
934
+ "wait": self.wait,
935
+ "timeout": self.timeout,
936
+ "auto_approve": self.auto_approve,
937
+ }
938
+
939
+ # Remove None values to use deployer defaults
940
+ return {k: v for k, v in kwargs.items() if v is not None}
941
+
942
+ def validate_for_deploy(self) -> None:
943
+ """
944
+ Validate configuration is complete for deployment.
945
+
946
+ Raises:
947
+ ValueError: If required fields are missing
948
+ """
949
+ errors = []
950
+
951
+ if not self.spec.name:
952
+ errors.append("Service name is required (spec.name or --name)")
953
+
954
+ if not self.spec.code.source_dir:
955
+ errors.append(
956
+ "Source directory is required "
957
+ "(spec.code.source_dir or SOURCE argument)",
958
+ )
959
+
960
+ # Validate source_dir exists
961
+ if self.spec.code.source_dir:
962
+ source_path = Path(self.spec.code.source_dir)
963
+ if not source_path.exists():
964
+ errors.append(
965
+ f"Source directory not found: {self.spec.code.source_dir}",
966
+ )
967
+
968
+ # Resource type specific validation
969
+ resource_type = self.resolve_resource_type()
970
+ resources = self.spec.resources
971
+
972
+ if resource_type == "resource" and not resources.resource_id:
973
+ errors.append("resource_id is required for resource mode")
974
+ if resource_type == "quota" and not resources.quota_id:
975
+ errors.append("quota_id is required for quota mode")
976
+
977
+ if errors:
978
+ raise ValueError(
979
+ "Configuration validation failed:\n - "
980
+ + "\n - ".join(errors),
981
+ )
982
+
983
+
984
+ def _read_ignore_file(ignore_file_path: Path) -> List[str]:
985
+ """
986
+ Read patterns from .gitignore or .dockerignore file.
987
+
988
+ Args:
989
+ ignore_file_path: Path to the ignore file
990
+
991
+ Returns:
992
+ List of ignore patterns
993
+ """
994
+ patterns = []
995
+ if ignore_file_path.exists():
996
+ with open(ignore_file_path, "r", encoding="utf-8") as f:
997
+ for line in f:
998
+ line = line.strip()
999
+ # Skip empty lines and comments
1000
+ if line and not line.startswith("#"):
1001
+ patterns.append(line)
1002
+ return patterns
1003
+
1004
+
1005
+ def _should_ignore(path: str, patterns: List[str]) -> bool:
1006
+ """
1007
+ Check if path should be ignored based on patterns.
1008
+
1009
+ Args:
1010
+ path: Path to check (relative)
1011
+ patterns: List of ignore patterns
1012
+
1013
+ Returns:
1014
+ True if path should be ignored
1015
+ """
1016
+ path_parts = Path(path).parts
1017
+
1018
+ for pattern in patterns:
1019
+ pattern = pattern.lstrip("/")
1020
+ pattern_normalized = pattern.rstrip("/")
1021
+ if pattern_normalized in path_parts:
1022
+ return True
1023
+
1024
+ if "*" in pattern or "?" in pattern:
1025
+ if fnmatch.fnmatch(path, pattern):
1026
+ return True
1027
+ for part in path_parts:
1028
+ if fnmatch.fnmatch(part, pattern):
1029
+ return True
1030
+ if (
1031
+ path.startswith(pattern_normalized + "/")
1032
+ or path == pattern_normalized
1033
+ ):
1034
+ return True
1035
+
1036
+ return False
1037
+
1038
+
1039
+ def _get_default_ignore_patterns() -> List[str]:
1040
+ """
1041
+ Get default ignore patterns for OSS upload.
1042
+
1043
+ Returns:
1044
+ List of default ignore patterns (similar to .dockerignore/.gitignore)
1045
+ """
1046
+ return [
1047
+ "__pycache__",
1048
+ "*.pyc",
1049
+ "*.pyo",
1050
+ "*.pyd",
1051
+ ".git",
1052
+ ".gitignore",
1053
+ ".dockerignore",
1054
+ ".pytest_cache",
1055
+ ".mypy_cache",
1056
+ ".tox",
1057
+ "venv",
1058
+ "env",
1059
+ ".venv",
1060
+ "virtualenv",
1061
+ "node_modules",
1062
+ ".DS_Store",
1063
+ "*.egg-info",
1064
+ "build",
1065
+ "dist",
1066
+ ".cache",
1067
+ "*.swp",
1068
+ "*.swo",
1069
+ "*~",
1070
+ ".idea",
1071
+ ".vscode",
1072
+ "*.log",
1073
+ "logs",
1074
+ ".agentscope_runtime",
1075
+ "*.tmp",
1076
+ "*.temp",
1077
+ ".coverage",
1078
+ "htmlcov",
1079
+ ".pytest_cache",
1080
+ ]
1081
+
1082
+
1083
+ def _generate_deployment_tool_tags(
1084
+ deploy_method: str = "cli",
1085
+ ) -> Dict[str, str]:
1086
+ """
1087
+ Generate automatic tags for deployment tool information.
1088
+
1089
+ Args:
1090
+ deploy_method: Deployment method, either "cli" or "sdk"
1091
+
1092
+ Returns:
1093
+ Dictionary of auto-generated tags with agentscope.io/ prefix
1094
+ """
1095
+ return {
1096
+ "deployed-by": "agentscope-runtime",
1097
+ "client-version": __version__,
1098
+ "deploy-method": deploy_method,
1099
+ }
1100
+
1101
+
1102
+ class PAIDeployManager(DeployManager):
1103
+ """
1104
+ Deployer for Alibaba Cloud PAI (Platform for AI) platform.
1105
+
1106
+ This deployer:
1107
+ 1. Packages the application and uploads to OSS
1108
+ 2. Creates/updates a Flow snapshot
1109
+ 3. Deploys the snapshot as a service with configurable resource types
1110
+ """
1111
+
1112
+ def __init__(
1113
+ self,
1114
+ workspace_id: Optional[str] = None,
1115
+ region_id: Optional[str] = None,
1116
+ access_key_id: Optional[str] = None,
1117
+ access_key_secret: Optional[str] = None,
1118
+ security_token: Optional[str] = None,
1119
+ oss_path: Optional[str] = None,
1120
+ build_root: Optional[Union[str, Path]] = None,
1121
+ state_manager=None,
1122
+ ) -> None:
1123
+ """
1124
+ Initialize PAI deployer.
1125
+ """
1126
+ super().__init__(state_manager=state_manager)
1127
+ self.workspace_id: str = workspace_id or os.getenv(
1128
+ "PAI_WORKSPACE_ID",
1129
+ "",
1130
+ )
1131
+ self.region_id = (
1132
+ region_id
1133
+ or os.getenv("REGION")
1134
+ or os.getenv("ALIBABA_CLOUD_REGION_ID")
1135
+ or os.getenv("REGION_ID")
1136
+ or "cn-hangzhou"
1137
+ )
1138
+ self.access_key_id = access_key_id or os.getenv(
1139
+ "ALIBABA_CLOUD_ACCESS_KEY_ID",
1140
+ )
1141
+ self.access_key_secret = access_key_secret or os.getenv(
1142
+ "ALIBABA_CLOUD_ACCESS_KEY_SECRET",
1143
+ )
1144
+ self.security_token = security_token or os.getenv(
1145
+ "ALIBABA_CLOUD_SECURITY_TOKEN",
1146
+ )
1147
+ self.oss_path = oss_path
1148
+ self.build_root = Path(build_root) if build_root else None
1149
+
1150
+ if not self.workspace_id:
1151
+ raise ValueError("Workspace ID is required")
1152
+
1153
+ if not self.oss_path:
1154
+ self.oss_path = self.get_workspace_default_oss_storage_path()
1155
+
1156
+ @classmethod
1157
+ def is_available(cls) -> bool:
1158
+ """Check if PAI is available."""
1159
+
1160
+ return all(
1161
+ [
1162
+ oss is not None,
1163
+ open_api_models is not None,
1164
+ LangStudioClient is not None,
1165
+ WorkspaceClient is not None,
1166
+ EASClient is not None,
1167
+ ],
1168
+ )
1169
+
1170
+ def _assert_cloud_sdks_available(self):
1171
+ """Ensure required cloud SDKs are installed."""
1172
+ credential_client = self._credential_client()
1173
+
1174
+ try:
1175
+ _ = credential_client.get_credential()
1176
+ except Exception as e:
1177
+ raise RuntimeError(
1178
+ f"Failed to get credential: {e}. Please check your credential "
1179
+ "configuration.",
1180
+ ) from e
1181
+
1182
+ async def _create_snapshot(
1183
+ self,
1184
+ archive_oss_uri: str,
1185
+ proj_id: str,
1186
+ service_name: str,
1187
+ ) -> str:
1188
+ """
1189
+ Create a snapshot for given archive_oss_uri
1190
+ """
1191
+ client = self.get_langstudio_client()
1192
+
1193
+ resp = await client.create_snapshot_async(
1194
+ workspace_id=self.workspace_id,
1195
+ snapshot_resource_type="Flow",
1196
+ snapshot_resource_id=proj_id,
1197
+ snapshot_name=(
1198
+ f"{service_name}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
1199
+ ),
1200
+ source_storage_path=archive_oss_uri,
1201
+ )
1202
+ return resp.get("SnapshotId", "")
1203
+
1204
+ def _build_deployment_config(
1205
+ self,
1206
+ resource_type: str,
1207
+ instance_count: int = 1,
1208
+ resource_id: Optional[str] = None,
1209
+ quota_id: Optional[str] = None,
1210
+ instance_type: Optional[str] = None,
1211
+ cpu: Optional[int] = None,
1212
+ memory: Optional[int] = None,
1213
+ vpc_id: Optional[str] = None,
1214
+ vswitch_id: Optional[str] = None,
1215
+ security_group_id: Optional[str] = None,
1216
+ service_group_name: Optional[str] = None,
1217
+ environment: Optional[Dict[str, str]] = None,
1218
+ tags: Optional[Dict[str, str]] = None,
1219
+ ) -> str:
1220
+ """
1221
+ Build deployment configuration JSON string.
1222
+ """
1223
+ config: Dict[str, Any] = {
1224
+ "metadata": {
1225
+ "instance": instance_count,
1226
+ "workspace_id": self.workspace_id,
1227
+ },
1228
+ "cloud": {
1229
+ "networking": {},
1230
+ },
1231
+ }
1232
+
1233
+ if tags:
1234
+ config["labels"] = tags
1235
+
1236
+ if environment:
1237
+ config["containers"] = [
1238
+ {
1239
+ "env": [
1240
+ {
1241
+ "name": key,
1242
+ "value": value,
1243
+ }
1244
+ for key, value in environment.items()
1245
+ ],
1246
+ },
1247
+ ]
1248
+
1249
+ if service_group_name:
1250
+ config["metadata"]["group"] = service_group_name
1251
+
1252
+ # Add resource-specific configuration
1253
+ if resource_type == "public":
1254
+ # Public resource pool
1255
+ if instance_type:
1256
+ config["cloud"]["computing"] = {
1257
+ "instances": [{"type": instance_type}],
1258
+ }
1259
+ elif resource_type == "resource":
1260
+ # EAS resource group
1261
+ if not resource_id:
1262
+ raise ValueError(
1263
+ "resource_id required for resource type",
1264
+ )
1265
+ config["metadata"]["resource"] = resource_id
1266
+ if cpu:
1267
+ config["metadata"]["cpu"] = cpu
1268
+ if memory:
1269
+ config["metadata"]["memory"] = memory
1270
+ elif resource_type == "quota":
1271
+ # Quota-based
1272
+ if not quota_id:
1273
+ raise ValueError("quota_id required for quota resource type")
1274
+ config["metadata"]["quota_id"] = quota_id
1275
+ if cpu:
1276
+ config["metadata"]["cpu"] = cpu
1277
+ if memory:
1278
+ config["metadata"]["memory"] = memory
1279
+ config["options"] = {"priority": 9}
1280
+ else:
1281
+ raise ValueError(f"Unsupported resource_type: {resource_type}")
1282
+
1283
+ # Add VPC configuration if provided
1284
+ if vpc_id:
1285
+ config["cloud"]["networking"]["vpc_id"] = vpc_id
1286
+ if vswitch_id:
1287
+ config["cloud"]["networking"]["vswitch_id"] = vswitch_id
1288
+ if security_group_id:
1289
+ config["cloud"]["networking"][
1290
+ "security_group_id"
1291
+ ] = security_group_id
1292
+
1293
+ return json.dumps(config)
1294
+
1295
+ def _build_credential_config(
1296
+ self,
1297
+ ram_role_mode: str = "default",
1298
+ ram_role_arn: Optional[str] = None,
1299
+ ) -> Dict[str, Any]:
1300
+ """
1301
+ Build credential configuration.
1302
+
1303
+ Args:
1304
+ ram_role_mode: "default", "custom", or "none"
1305
+ ram_role_arn: RAM role ARN (required for custom mode)
1306
+
1307
+ Returns:
1308
+ Credential configuration dict
1309
+ """
1310
+ if ram_role_mode == "none":
1311
+ return {
1312
+ "EnableCredentialInject": False,
1313
+ }
1314
+
1315
+ cred_config: Dict[str, Any] = {
1316
+ "EnableCredentialInject": True,
1317
+ "AliyunEnvRoleKey": "0",
1318
+ "CredentialConfigItems": [
1319
+ {
1320
+ "Type": "Role",
1321
+ "Key": "0",
1322
+ "Roles": [],
1323
+ },
1324
+ ],
1325
+ }
1326
+
1327
+ if ram_role_mode == "custom":
1328
+ if not ram_role_arn:
1329
+ raise ValueError(
1330
+ "ram_role_arn required for custom ram_role_mode",
1331
+ )
1332
+ cred_config["CredentialConfigItems"][0]["Roles"] = [ram_role_arn]
1333
+
1334
+ return cred_config
1335
+
1336
+ async def _deploy_snapshot(
1337
+ self,
1338
+ snapshot_id: str,
1339
+ proj_id: str,
1340
+ service_name: str,
1341
+ oss_work_dir: str,
1342
+ enable_trace: bool = True,
1343
+ resource_type: str = "public",
1344
+ service_group_name: Optional[str] = None,
1345
+ ram_role_mode: str = "default",
1346
+ ram_role_arn: Optional[str] = None,
1347
+ auto_approve: bool = True,
1348
+ **deployment_kwargs,
1349
+ ) -> str:
1350
+ """
1351
+ Deploy a snapshot as a service.
1352
+ """
1353
+ logger.info(
1354
+ "Deploying snapshot %s as service %s",
1355
+ snapshot_id,
1356
+ service_name,
1357
+ )
1358
+
1359
+ client = self.get_langstudio_client()
1360
+
1361
+ # Build deployment configuration
1362
+ deployment_config = self._build_deployment_config(
1363
+ resource_type=resource_type,
1364
+ service_group_name=service_group_name,
1365
+ **deployment_kwargs,
1366
+ )
1367
+
1368
+ # Build credential configuration
1369
+ credential_config = self._build_credential_config(
1370
+ ram_role_mode=ram_role_mode,
1371
+ ram_role_arn=ram_role_arn,
1372
+ )
1373
+
1374
+ response = await client.create_deployment_async(
1375
+ workspace_id=self.workspace_id,
1376
+ resource_type="Flow",
1377
+ resource_id=proj_id,
1378
+ resource_snapshot_id=snapshot_id,
1379
+ service_name=service_name,
1380
+ enable_trace=enable_trace,
1381
+ work_dir=self._oss_uri_patch_endpoint(oss_work_dir),
1382
+ deployment_config=deployment_config,
1383
+ credential_config=credential_config,
1384
+ auto_approval=auto_approve,
1385
+ service_group=service_group_name,
1386
+ )
1387
+
1388
+ deployment_id = response.get("DeploymentId", "")
1389
+ logger.info("Deployment created: %s", deployment_id)
1390
+ return deployment_id
1391
+
1392
+ def _oss_uri_patch_endpoint(self, oss_uri: str) -> str:
1393
+ """
1394
+ Patch OSS URI endpoint to the correct endpoint.
1395
+ """
1396
+ bucket_name, endpoint, object_key = parse_oss_uri(oss_uri)
1397
+ if not endpoint:
1398
+ endpoint = self._get_oss_endpoint(self.region_id)
1399
+ return f"oss://{bucket_name}.{endpoint}/{object_key}"
1400
+
1401
+ async def _wait_for_deployment(
1402
+ self,
1403
+ deployment_id: str,
1404
+ timeout: int = 1800,
1405
+ poll_interval: int = 10,
1406
+ ) -> Dict[str, Any]:
1407
+ """
1408
+ Wait for deployment to reach running state.
1409
+
1410
+ Args:
1411
+ deployment_id: Deployment ID to monitor
1412
+ timeout: Maximum wait time in seconds
1413
+ poll_interval: Polling interval in seconds
1414
+
1415
+ Returns:
1416
+ Final deployment status dict
1417
+
1418
+ Raises:
1419
+ TimeoutError: If deployment doesn't complete within timeout
1420
+ RuntimeError: If deployment fails
1421
+ """
1422
+ logger.info("Waiting for deployment %s to complete...", deployment_id)
1423
+ client = self.get_langstudio_client()
1424
+
1425
+ start_time = time.time()
1426
+
1427
+ while time.time() - start_time < timeout:
1428
+ # Get deployment status
1429
+ response = await client.get_deployment_async(
1430
+ deployment_id=deployment_id,
1431
+ workspace_id=self.workspace_id,
1432
+ )
1433
+ status = response.get("DeploymentStatus", "")
1434
+ logger.info("Deployment status: %s", status)
1435
+
1436
+ if status == "Succeed":
1437
+ return {}
1438
+ elif status == "Failed":
1439
+ error_msg = response.get("ErrorMessage", "Unknown error")
1440
+ raise RuntimeError(
1441
+ f"Deployment {deployment_id} failed: {error_msg}",
1442
+ )
1443
+ elif status == "Canceled":
1444
+ raise RuntimeError(f"Deployment {deployment_id} cancled.")
1445
+ elif status in (
1446
+ "Running",
1447
+ "Creating",
1448
+ "WaitForConfirm",
1449
+ "Waiting",
1450
+ ):
1451
+ await asyncio.sleep(poll_interval)
1452
+ else:
1453
+ logger.warning(
1454
+ "Deployment %s status unknown: %s",
1455
+ deployment_id,
1456
+ status,
1457
+ )
1458
+ await asyncio.sleep(poll_interval)
1459
+
1460
+ raise TimeoutError(
1461
+ f"Deployment {deployment_id} did not complete within {timeout} seconds",
1462
+ )
1463
+
1464
+ async def deploy( # pylint: disable=unused-argument
1465
+ self,
1466
+ project_dir: Optional[Union[str, Path]] = None,
1467
+ entrypoint: Optional[str] = None,
1468
+ protocol_adapters: Optional[list[ProtocolAdapter]] = None,
1469
+ environment: Optional[Dict[str, str]] = None,
1470
+ service_name: Optional[str] = None,
1471
+ app_name: Optional[str] = None,
1472
+ service_group_name: Optional[str] = None,
1473
+ tags: Optional[Dict[str, str]] = None,
1474
+ resource_type: str = "public",
1475
+ resource_id: Optional[str] = None,
1476
+ quota_id: Optional[str] = None,
1477
+ instance_count: int = 1,
1478
+ instance_type: Optional[str] = None,
1479
+ cpu: Optional[int] = None,
1480
+ memory: Optional[int] = None,
1481
+ vpc_id: Optional[str] = None,
1482
+ vswitch_id: Optional[str] = None,
1483
+ security_group_id: Optional[str] = None,
1484
+ ram_role_mode: str = "default",
1485
+ ram_role_arn: Optional[str] = None,
1486
+ enable_trace: bool = True,
1487
+ wait: bool = True,
1488
+ timeout: int = 1800,
1489
+ auto_approve: bool = True,
1490
+ **kwargs,
1491
+ ) -> Dict[str, str]:
1492
+ """
1493
+ Deploy application to PAI platform.
1494
+
1495
+ Args:
1496
+ app: AgentScope application instance
1497
+ runner: Runner instance
1498
+ endpoint_path: API endpoint path
1499
+ protocol_adapters: Protocol adapters
1500
+ environment: Environment variables
1501
+ project_dir: Local project directory
1502
+ service_name: Service name (required)
1503
+ app_name: Application name
1504
+ workspace_id: PAI workspace ID
1505
+ service_group_name: Service group name
1506
+ tags: Tags for the deployment
1507
+ resource_type: "public", "resource", or "quota"
1508
+ resource_id: EAS resource group ID
1509
+ quota_id: Quota ID
1510
+ instance_count: Number of instances
1511
+ instance_type: Instance type for public resource (e.g. ecs.c6.large)
1512
+ cpu: CPU cores (for resource/quota mode, default 2)
1513
+ memory: Memory in MB (for resource/quota mode, default 2048)
1514
+ vpc_id: VPC ID
1515
+ vswitch_id: VSwitch ID
1516
+ security_group_id: Security group ID
1517
+ ram_role_mode: "default", "custom", or "none"
1518
+ ram_role_arn: RAM role ARN
1519
+ enable_trace: Enable tracing
1520
+ wait: Wait for deployment to complete
1521
+ timeout: Deployment timeout in seconds
1522
+ custom_endpoints: Custom endpoints configuration
1523
+ auto_approve: Auto approve the deployment
1524
+ deploy_method: Deployment method ("cli" or "sdk")
1525
+
1526
+ Returns:
1527
+ Dict containing deployment information
1528
+
1529
+ Raises:
1530
+ ValueError: If required parameters are missing
1531
+ RuntimeError: If deployment fails
1532
+ """
1533
+ from agentscope_runtime.engine.deployers.local_deployer import (
1534
+ LocalDeployManager,
1535
+ )
1536
+
1537
+ if not service_name:
1538
+ raise ValueError("service_name is required for PAI deployment")
1539
+
1540
+ # Merge auto-generated tags with user tags
1541
+ # Priority: auto tags < user tags (user can override auto tags)
1542
+
1543
+ deploy_method = kwargs.get("deploy_method", "sdk")
1544
+ final_tags = _generate_deployment_tool_tags(deploy_method)
1545
+ if tags:
1546
+ final_tags.update(tags)
1547
+
1548
+ try:
1549
+ # Ensure SDKs are available
1550
+ self._assert_cloud_sdks_available()
1551
+
1552
+ app = kwargs.get("app")
1553
+
1554
+ if not project_dir and app:
1555
+ logger.info("Creating detached project from app/runner")
1556
+ project_dir = await LocalDeployManager.create_detached_project(
1557
+ app=app,
1558
+ protocol_adapters=protocol_adapters,
1559
+ **kwargs,
1560
+ )
1561
+
1562
+ if not project_dir:
1563
+ raise ValueError(
1564
+ "Either project_dir or app/runner must be provided",
1565
+ )
1566
+
1567
+ project_dir = Path(project_dir).resolve()
1568
+ if not project_dir.is_dir():
1569
+ raise FileNotFoundError(
1570
+ f"Project directory not found: {project_dir}",
1571
+ )
1572
+
1573
+ # Create a zip archive of the project
1574
+ logger.info("Creating project archive")
1575
+ archive_path = self._create_project_archive(
1576
+ service_name,
1577
+ project_dir,
1578
+ )
1579
+
1580
+ if not self.oss_path:
1581
+ raise ValueError("oss_path is required for PAI deployment")
1582
+
1583
+ oss_archive_uri = self._upload_archive(
1584
+ service_name=service_name,
1585
+ archive_path=archive_path,
1586
+ oss_path=self.oss_path,
1587
+ )
1588
+
1589
+ proj_id = await self.get_or_create_langstudio_proj(
1590
+ service_name,
1591
+ oss_archive_uri,
1592
+ self.oss_path,
1593
+ )
1594
+
1595
+ # Step 2: Upload to OSS
1596
+ # Step 3: Create or update snapshot
1597
+ snapshot_id = await self._create_snapshot(
1598
+ archive_oss_uri=oss_archive_uri,
1599
+ proj_id=proj_id,
1600
+ service_name=service_name,
1601
+ )
1602
+
1603
+ # Step 4: Deploy snapshot
1604
+ deployment_id = await self._deploy_snapshot(
1605
+ snapshot_id=snapshot_id,
1606
+ proj_id=proj_id,
1607
+ service_name=service_name,
1608
+ oss_work_dir=self.oss_path,
1609
+ enable_trace=enable_trace,
1610
+ resource_type=resource_type,
1611
+ service_group_name=service_group_name,
1612
+ ram_role_mode=ram_role_mode,
1613
+ ram_role_arn=ram_role_arn,
1614
+ instance_count=instance_count,
1615
+ resource_id=resource_id,
1616
+ quota_id=quota_id,
1617
+ instance_type=instance_type,
1618
+ cpu=cpu,
1619
+ memory=memory,
1620
+ vpc_id=vpc_id,
1621
+ vswitch_id=vswitch_id,
1622
+ security_group_id=security_group_id,
1623
+ auto_approve=auto_approve,
1624
+ environment=environment,
1625
+ tags=final_tags, # Use merged tags
1626
+ )
1627
+
1628
+ # Step 5: Wait for deployment if requested
1629
+ if auto_approve and wait:
1630
+ await self._wait_for_deployment(
1631
+ deployment_id,
1632
+ timeout=timeout,
1633
+ )
1634
+ service_status = "running"
1635
+
1636
+ service = await self.get_service(service_name)
1637
+ endpoint = service.internet_endpoint
1638
+ token = service.access_token
1639
+ else:
1640
+ endpoint = None
1641
+ token = None
1642
+ service_status = "pending"
1643
+
1644
+ console_uri = self.get_deployment_console_uri(
1645
+ proj_id,
1646
+ deployment_id,
1647
+ )
1648
+
1649
+ deployment = Deployment(
1650
+ id=deployment_id,
1651
+ platform="pai",
1652
+ url=endpoint,
1653
+ token=token,
1654
+ status=service_status,
1655
+ created_at=datetime.now().isoformat(),
1656
+ agent_source=str(project_dir),
1657
+ config={
1658
+ "deployment_id": deployment_id,
1659
+ "flow_id": proj_id,
1660
+ "snapshot_id": snapshot_id,
1661
+ "service_name": service_name,
1662
+ "workspace_id": self.workspace_id,
1663
+ "region": self.region_id,
1664
+ "oss_path": self.oss_path,
1665
+ },
1666
+ )
1667
+ self.state_manager.save(deployment)
1668
+
1669
+ # Return deployment information
1670
+ result = {
1671
+ "deploy_id": deployment_id,
1672
+ "flow_id": proj_id,
1673
+ "snapshot_id": snapshot_id,
1674
+ "service_name": service_name,
1675
+ "workspace_id": self.workspace_id,
1676
+ "url": console_uri,
1677
+ "status": service_status,
1678
+ }
1679
+
1680
+ logger.info("PAI deployment completed successfully")
1681
+ logger.info("Console URL: %s", console_uri)
1682
+
1683
+ return result
1684
+
1685
+ except Exception as e:
1686
+ logger.error("Failed to deploy to PAI: %s", e, exc_info=True)
1687
+ raise
1688
+
1689
+ def get_deployment_console_uri(
1690
+ self,
1691
+ proj_id: str,
1692
+ deployment_id: str,
1693
+ ) -> str:
1694
+ """
1695
+ Return the console URI for a deployment.
1696
+
1697
+ """
1698
+ return (
1699
+ f"https://pai.console.aliyun.com/?regionId="
1700
+ f"{self.region_id}&workspaceId="
1701
+ f"{self.workspace_id}#/lang-studio/flows/"
1702
+ f"flow-{proj_id}/deployments/{deployment_id}"
1703
+ )
1704
+
1705
+ def get_service_console_uri(self, service_name: str) -> str:
1706
+ """
1707
+ Return the console URI for a service.
1708
+
1709
+ """
1710
+ return (
1711
+ f"https://pai.console.aliyun.com/?regionId="
1712
+ f"{self.region_id}&workspaceId="
1713
+ f"{self.workspace_id}#/eas/serviceDetail/"
1714
+ f"{service_name}/detail"
1715
+ )
1716
+
1717
+ def get_workspace_default_oss_storage_path(self) -> Optional[str]:
1718
+ from alibabacloud_aiworkspace20210204.models import ListConfigsRequest
1719
+
1720
+ client = self.get_workspace_client()
1721
+ config_key = "modelExportPath"
1722
+
1723
+ logger.warning("WorkspaceID: %s", self.workspace_id)
1724
+
1725
+ resp = client.list_configs(
1726
+ workspace_id=self.workspace_id,
1727
+ request=ListConfigsRequest(
1728
+ config_keys=config_key,
1729
+ ),
1730
+ )
1731
+ default_oss_storage_uri = next(
1732
+ (c for c in resp.body.configs if c.config_key == config_key),
1733
+ None,
1734
+ )
1735
+
1736
+ if default_oss_storage_uri:
1737
+ bucket, _, key = parse_oss_uri(
1738
+ default_oss_storage_uri.config_value,
1739
+ )
1740
+ return f"oss://{bucket}/{key}"
1741
+ else:
1742
+ return None
1743
+
1744
+ def _create_project_archive(self, service_name, project_dir: Path):
1745
+ build_dir = generate_build_directory("pai")
1746
+ build_dir.mkdir(parents=True, exist_ok=True)
1747
+
1748
+ ignore_patterns = _get_default_ignore_patterns()
1749
+
1750
+ project_path = Path(project_dir).resolve()
1751
+
1752
+ gitignore_path = project_path / ".gitignore"
1753
+ if gitignore_path.exists():
1754
+ ignore_patterns.extend(_read_ignore_file(gitignore_path))
1755
+
1756
+ dockerignore_path = project_path / ".dockerignore"
1757
+ if dockerignore_path.exists():
1758
+ ignore_patterns.extend(_read_ignore_file(dockerignore_path))
1759
+
1760
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1761
+ zip_filename = f"{service_name}_{timestamp}.zip"
1762
+ archive_path = build_dir / zip_filename
1763
+
1764
+ with zipfile.ZipFile(
1765
+ archive_path,
1766
+ "w",
1767
+ zipfile.ZIP_DEFLATED,
1768
+ ) as archive:
1769
+ source_files = glob.glob(
1770
+ pathname=str(project_path / "**"),
1771
+ recursive=True,
1772
+ )
1773
+
1774
+ for file_path in source_files:
1775
+ file_path_obj = Path(file_path)
1776
+
1777
+ # Skip if not a file (e.g., directories)
1778
+ if not file_path_obj.is_file():
1779
+ continue
1780
+
1781
+ file_relative_path = file_path_obj.relative_to(
1782
+ project_path,
1783
+ ).as_posix()
1784
+
1785
+ # Skip . and .. directory references
1786
+ if file_relative_path in (".", ".."):
1787
+ continue
1788
+
1789
+ if _should_ignore(file_relative_path, ignore_patterns):
1790
+ logger.debug(
1791
+ "Skipping ignored file: %s",
1792
+ file_relative_path,
1793
+ )
1794
+ continue
1795
+ archive.write(file_path, file_relative_path)
1796
+
1797
+ logger.info("Project archived to: %s", archive_path)
1798
+
1799
+ return archive_path
1800
+
1801
+ def _upload_archive(
1802
+ self,
1803
+ archive_path: Path,
1804
+ oss_path: str,
1805
+ service_name: str,
1806
+ oss_endpoint: Optional[str] = None,
1807
+ region: Optional[str] = None,
1808
+ ):
1809
+ """
1810
+ Upload archive to OSS.
1811
+
1812
+ Args:
1813
+ archive_path: Path to the archive file
1814
+ oss_path: OSS path to upload the archive to
1815
+
1816
+ Returns:
1817
+ OSS path of the uploaded archive
1818
+ """
1819
+ from alibabacloud_oss_v2.models import PutObjectRequest
1820
+
1821
+ bucket_name, endpoint, object_key = parse_oss_uri(oss_path)
1822
+ archive_obj_key = posixpath.join(
1823
+ object_key,
1824
+ "temp",
1825
+ f"{service_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip",
1826
+ )
1827
+ if endpoint and not oss_endpoint:
1828
+ oss_endpoint = endpoint
1829
+
1830
+ if not oss_endpoint:
1831
+ oss_endpoint = self._get_oss_endpoint(self.region_id)
1832
+
1833
+ client = self._get_oss_client(
1834
+ oss_endpoint=oss_endpoint,
1835
+ region=region,
1836
+ )
1837
+
1838
+ client.put_object_from_file(
1839
+ request=PutObjectRequest(
1840
+ bucket=bucket_name,
1841
+ key=archive_obj_key,
1842
+ ),
1843
+ filepath=archive_path,
1844
+ )
1845
+ return f"oss://{bucket_name}.{oss_endpoint}/{archive_obj_key}"
1846
+
1847
+ def _get_oss_client(
1848
+ self,
1849
+ oss_endpoint: Optional[str] = None,
1850
+ region: Optional[str] = None,
1851
+ ):
1852
+ from alibabacloud_credentials.client import (
1853
+ Client as CredClient,
1854
+ )
1855
+
1856
+ class _CustomOssCredentialsProvider(
1857
+ oss.credentials.CredentialsProvider,
1858
+ ):
1859
+ def __init__(self, credential_client: "CredClient"):
1860
+ self.credential_client = credential_client
1861
+
1862
+ def get_credentials(self) -> oss.credentials.Credentials:
1863
+ cred = self.credential_client.get_credential()
1864
+
1865
+ return oss.credentials.Credentials(
1866
+ access_key_id=cred.access_key_id,
1867
+ access_key_secret=cred.access_key_secret,
1868
+ security_token=cred.security_token,
1869
+ )
1870
+
1871
+ return oss.Client(
1872
+ config=oss.Config(
1873
+ region=region or self.region_id,
1874
+ endpoint=oss_endpoint,
1875
+ credentials_provider=_CustomOssCredentialsProvider(
1876
+ self._credential_client(),
1877
+ ),
1878
+ ),
1879
+ )
1880
+
1881
+ def _credential_client(self):
1882
+ from alibabacloud_credentials.client import (
1883
+ Client as CredClient,
1884
+ )
1885
+ from alibabacloud_credentials.models import Config
1886
+ from alibabacloud_credentials.utils import auth_constant as ac
1887
+
1888
+ if self.access_key_id and self.access_key_secret:
1889
+ if not self.security_token:
1890
+ config = Config(
1891
+ type=ac.ACCESS_KEY,
1892
+ access_key_id=self.access_key_id,
1893
+ access_key_secret=self.access_key_secret,
1894
+ )
1895
+
1896
+ else:
1897
+ config = Config(
1898
+ type=ac.STS,
1899
+ access_key_id=self.access_key_id,
1900
+ access_key_secret=self.access_key_secret,
1901
+ security_token=self.security_token,
1902
+ )
1903
+ else:
1904
+ config = None
1905
+
1906
+ return CredClient(config=config)
1907
+
1908
+ def _eas_service_client(self) -> EASClient:
1909
+ return EASClient(
1910
+ config=open_api_models.Config(
1911
+ credential=self._credential_client(),
1912
+ region_id=self.region_id,
1913
+ endpoint=self._get_eas_endpoint(self.region_id),
1914
+ ),
1915
+ )
1916
+
1917
+ async def get_service(self, service_name: str) -> Optional[Any]:
1918
+ """Get service information.
1919
+
1920
+ Args:
1921
+ service_name: Name of the service to retrieve
1922
+
1923
+ Returns:
1924
+ Service object if found, None otherwise
1925
+ """
1926
+ from alibabacloud_tea_openapi.exceptions import AlibabaCloudException
1927
+
1928
+ eas_client = self._eas_service_client()
1929
+
1930
+ try:
1931
+ resp = await eas_client.describe_service_async(
1932
+ cluster_id=self.region_id,
1933
+ service_name=service_name,
1934
+ )
1935
+ return resp.body
1936
+ except AlibabaCloudException as e:
1937
+ if e.code == "Forbidden.PrivilegeCheckFailed":
1938
+ logger.warning(
1939
+ f"Given service name is owned by another user: {e}",
1940
+ )
1941
+
1942
+ raise ValueError(
1943
+ f"Given service name is owned by another user: "
1944
+ f"{service_name}. Please use a different service name.",
1945
+ ) from e
1946
+ if e.code == "InvalidService.NotFound":
1947
+ return None
1948
+ raise
1949
+
1950
+ async def get_or_create_langstudio_proj(
1951
+ self,
1952
+ service_name,
1953
+ proj_archive_oss_uri: str,
1954
+ oss_path: str,
1955
+ ):
1956
+ from alibabacloud_eas20210701.models import Service
1957
+ from alibabacloud_tea_openapi.exceptions import AlibabaCloudException
1958
+
1959
+ langstudio_client = self.get_langstudio_client()
1960
+
1961
+ service = await self.get_service(service_name)
1962
+ service = cast(Optional[Service], service)
1963
+
1964
+ # try to reuse existing project from service label
1965
+ if service and service.labels:
1966
+ proj_id_from_svc_label: Optional[str] = next(
1967
+ (
1968
+ label.label_value
1969
+ for label in service.labels
1970
+ if label.label_key == "FlowId"
1971
+ ),
1972
+ None,
1973
+ )
1974
+ if not proj_id_from_svc_label:
1975
+ proj_id = None
1976
+ else:
1977
+ try:
1978
+ resp = await langstudio_client.get_flow_async(
1979
+ flow_id=proj_id_from_svc_label,
1980
+ workspace_id=self.workspace_id,
1981
+ )
1982
+ proj_id = resp.get("FlowId")
1983
+ except AlibabaCloudException as e:
1984
+ if e.status_code == 400:
1985
+ logger.info(
1986
+ "No flow found with id: %s, %s",
1987
+ proj_id_from_svc_label,
1988
+ e,
1989
+ )
1990
+ proj_id = None
1991
+ else:
1992
+ raise e
1993
+ else:
1994
+ proj_id = None
1995
+
1996
+ if not proj_id:
1997
+ flow_proj = await self._get_langstudio_proj_by_name(service_name)
1998
+ if flow_proj:
1999
+ proj_id = flow_proj.get("FlowId")
2000
+
2001
+ if not proj_id:
2002
+ resp = await langstudio_client.create_flow_async(
2003
+ workspace_id=self.workspace_id,
2004
+ flow_name=service_name,
2005
+ description=f"Project {service_name} created by Agentscope Runtime.",
2006
+ flow_type="Code",
2007
+ source_uri=proj_archive_oss_uri,
2008
+ work_dir=self._oss_uri_patch_endpoint(oss_path),
2009
+ create_from="OSS",
2010
+ )
2011
+ proj_id = resp.get("FlowId")
2012
+
2013
+ return proj_id
2014
+
2015
+ async def _get_langstudio_proj(
2016
+ self,
2017
+ flow_id: str,
2018
+ ) -> Optional[Dict[str, Any]]:
2019
+ from alibabacloud_tea_openapi.exceptions import AlibabaCloudException
2020
+
2021
+ client = self.get_langstudio_client()
2022
+
2023
+ try:
2024
+ resp = await client.get_flow_async(
2025
+ flow_id=flow_id,
2026
+ workspace_id=self.workspace_id,
2027
+ )
2028
+ return resp
2029
+ except AlibabaCloudException as e:
2030
+ if e.status_code == 400:
2031
+ logger.info("No flow found with id: %s, %s", flow_id, e)
2032
+ return None
2033
+ else:
2034
+ raise e
2035
+
2036
+ def get_langstudio_client(self) -> LangStudioClient:
2037
+ client = LangStudioClient(
2038
+ config=open_api_models.Config(
2039
+ credential=self._credential_client(),
2040
+ region_id=self.region_id,
2041
+ endpoint=self._get_langstudio_endpoint(self.region_id),
2042
+ ),
2043
+ )
2044
+ return client
2045
+
2046
+ def get_workspace_client(self) -> WorkspaceClient:
2047
+ from alibabacloud_tea_openapi import models as openapi_models
2048
+
2049
+ client = WorkspaceClient(
2050
+ config=openapi_models.Config(
2051
+ credential=self._credential_client(),
2052
+ region_id=self.region_id,
2053
+ endpoint=self._get_workspace_endpoint(self.region_id),
2054
+ ),
2055
+ )
2056
+ return client
2057
+
2058
+ def _get_workspace_endpoint(self, region_id: str) -> str:
2059
+ internal_endpoint = f"aiworkspace-vpc.{region_id}.aliyuncs.com"
2060
+ public_endpoint = f"aiworkspace.{region_id}.aliyuncs.com"
2061
+
2062
+ return (
2063
+ internal_endpoint
2064
+ if is_tcp_reachable(internal_endpoint)
2065
+ else public_endpoint
2066
+ )
2067
+
2068
+ @staticmethod
2069
+ def _get_langstudio_endpoint(region_id: str) -> str:
2070
+ internal_endpoint = f"pailangstudio-vpc.{region_id}.aliyuncs.com"
2071
+ public_endpoint = f"pailangstudio.{region_id}.aliyuncs.com"
2072
+
2073
+ return (
2074
+ internal_endpoint
2075
+ if is_tcp_reachable(internal_endpoint)
2076
+ else public_endpoint
2077
+ )
2078
+
2079
+ @staticmethod
2080
+ def _get_eas_endpoint(region_id: str) -> str:
2081
+ internal_endpoint = f"pai-eas-manage-vpc.{region_id}.aliyuncs.com"
2082
+ public_endpoint = f"pai-eas.{region_id}.aliyuncs.com"
2083
+
2084
+ return (
2085
+ internal_endpoint
2086
+ if is_tcp_reachable(internal_endpoint)
2087
+ else public_endpoint
2088
+ )
2089
+
2090
+ @staticmethod
2091
+ def _get_oss_endpoint(region_id: str) -> str:
2092
+ internal_endpoint = f"oss-{region_id}-internal.aliyuncs.com"
2093
+ public_endpoint = f"oss-{region_id}.aliyuncs.com"
2094
+
2095
+ return (
2096
+ internal_endpoint
2097
+ if is_tcp_reachable(internal_endpoint)
2098
+ else public_endpoint
2099
+ )
2100
+
2101
+ async def stop(self, deploy_id: str, **kwargs) -> Dict[str, Any]:
2102
+ """
2103
+ Stop PAI deployment by stopping the deployed EAS service.
2104
+
2105
+ Args:
2106
+ deploy_id: Deployment identifier
2107
+ **kwargs: Additional parameters
2108
+
2109
+ Returns:
2110
+ Dict with success status and message
2111
+ """
2112
+ # Get deployment from state
2113
+ deployment = self.state_manager.get(deploy_id)
2114
+ if not deployment:
2115
+ return {
2116
+ "success": False,
2117
+ "message": f"Deployment {deploy_id} not found",
2118
+ }
2119
+
2120
+ service_name = deployment.config.get("service_name")
2121
+ if not service_name:
2122
+ return {
2123
+ "success": False,
2124
+ "message": "Service name not found in deployment state",
2125
+ }
2126
+
2127
+ # Ensure SDKs available
2128
+ self._assert_cloud_sdks_available()
2129
+
2130
+ # Get EAS client and stop the service
2131
+ eas_client = self._eas_service_client()
2132
+
2133
+ logger.info("Stopping EAS service: %s", service_name)
2134
+
2135
+ await eas_client.stop_service_async(
2136
+ cluster_id=self.region_id,
2137
+ service_name=service_name,
2138
+ )
2139
+
2140
+ # Update deployment status in state
2141
+ self.state_manager.update_status(deploy_id, "stopped")
2142
+
2143
+ logger.info("EAS service stopped successfully: %s", service_name)
2144
+
2145
+ return {
2146
+ "success": True,
2147
+ "message": f"Service {service_name} stopped",
2148
+ "details": {
2149
+ "deploy_id": deploy_id,
2150
+ "service_name": service_name,
2151
+ },
2152
+ }
2153
+
2154
+ def get_status(self) -> str:
2155
+ """Get deployment status (not fully implemented)."""
2156
+ return "unknown"
2157
+
2158
+ async def _get_langstudio_proj_by_name(
2159
+ self,
2160
+ name: str,
2161
+ ) -> Optional[Dict[str, Any]]:
2162
+ next_token = None
2163
+
2164
+ client = self.get_langstudio_client()
2165
+
2166
+ while True:
2167
+ resp = await client.list_flows_async(
2168
+ workspace_id=self.workspace_id,
2169
+ flow_name=name,
2170
+ sort_by="GmtCreateTime",
2171
+ order="DESC",
2172
+ next_token=next_token,
2173
+ page_size=100,
2174
+ )
2175
+ flows = resp.get("Flows", [])
2176
+ for flow in flows:
2177
+ if flow.get("FlowName") == name:
2178
+ return flow
2179
+
2180
+ next_token = resp.get("NextToken")
2181
+ if not next_token:
2182
+ break
2183
+ return None
2184
+
2185
+ async def _update_deployment(
2186
+ self,
2187
+ deployment_id: str,
2188
+ auto_approve: bool, # pylint: disable=unused-argument
2189
+ ) -> Dict[str, Any]:
2190
+ client = self.get_langstudio_client()
2191
+ resp = await client.update_deployment_async(
2192
+ deployment_id=deployment_id,
2193
+ workspace_id=self.workspace_id,
2194
+ stage_action=json.dumps({"Stage": 3, "Action": "Confirm"}),
2195
+ )
2196
+
2197
+ return resp
2198
+
2199
+ async def delete_service(self, service_name: str) -> None:
2200
+ service_client = self._eas_service_client()
2201
+
2202
+ await service_client.delete_service_async(
2203
+ cluster_id=self.region_id,
2204
+ service_name=service_name,
2205
+ )
2206
+
2207
+ async def delete_project(self, project_name: str) -> None:
2208
+ proj = await self._get_langstudio_proj_by_name(project_name)
2209
+ if not proj:
2210
+ return
2211
+ client = self.get_langstudio_client()
2212
+
2213
+ await client.delete_flow_async(
2214
+ flow_id=proj.get("FlowId", ""),
2215
+ workspace_id=self.workspace_id,
2216
+ )
2217
+
2218
+ async def wait_for_approval_stage(
2219
+ self,
2220
+ deployment_id: str,
2221
+ timeout: int = 300,
2222
+ poll_interval: int = 5,
2223
+ ) -> bool:
2224
+ """
2225
+ Wait for deployment to reach approval stage (WaitingForApproval).
2226
+
2227
+ Args:
2228
+ deployment_id: Deployment ID to monitor
2229
+ timeout: Maximum wait time in seconds
2230
+ poll_interval: Polling interval in seconds
2231
+
2232
+ Returns:
2233
+ True if deployment reached approval stage, False otherwise
2234
+
2235
+ Raises:
2236
+ TimeoutError: If deployment doesn't reach approval stage
2237
+ RuntimeError: If deployment fails before approval stage
2238
+ """
2239
+ logger.info(
2240
+ "Waiting for deployment %s to reach approval stage...",
2241
+ deployment_id,
2242
+ )
2243
+ client = self.get_langstudio_client()
2244
+
2245
+ start_time = time.time()
2246
+
2247
+ while time.time() - start_time < timeout:
2248
+ response = await client.get_deployment_async(
2249
+ deployment_id=deployment_id,
2250
+ workspace_id=self.workspace_id,
2251
+ )
2252
+ status = response.get("DeploymentStatus", "")
2253
+
2254
+ if status == "WaitForConfirm":
2255
+ logger.info("Deployment is ready for approval")
2256
+ return True
2257
+ if status in ("Failed", "Canceled"):
2258
+ error_msg = response.get("ErrorMessage", "Unknown error")
2259
+ raise RuntimeError(
2260
+ f"Deployment {deployment_id} failed: {error_msg}",
2261
+ )
2262
+ if status in ("Running", "Creating"):
2263
+ await asyncio.sleep(poll_interval)
2264
+ continue
2265
+ if status == "Succeed":
2266
+ # Already approved and succeeded
2267
+ return True
2268
+
2269
+ await asyncio.sleep(poll_interval)
2270
+
2271
+ raise TimeoutError(
2272
+ f"Deployment {deployment_id} did not reach approval stage "
2273
+ f"within {timeout} seconds",
2274
+ )
2275
+
2276
+ async def approve_deployment(
2277
+ self,
2278
+ deployment_id: str,
2279
+ wait: bool = True,
2280
+ timeout: int = 1800,
2281
+ poll_interval: int = 10,
2282
+ ) -> Dict[str, Any]:
2283
+ """
2284
+ Approve a deployment.
2285
+
2286
+ Args:
2287
+ deployment_id: Deployment ID to approve
2288
+ wait: Wait for deployment to complete after approval
2289
+ timeout: Deployment timeout in seconds
2290
+ poll_interval: Polling interval in seconds
2291
+
2292
+ Returns:
2293
+ Dict with approval result
2294
+ """
2295
+ logger.info("Approving deployment %s", deployment_id)
2296
+ client = self.get_langstudio_client()
2297
+
2298
+ await client.update_deployment_async(
2299
+ deployment_id=deployment_id,
2300
+ workspace_id=self.workspace_id,
2301
+ stage_action=json.dumps({"Stage": 3, "Action": "Confirm"}),
2302
+ )
2303
+
2304
+ if wait:
2305
+ await self._wait_for_deployment(
2306
+ deployment_id,
2307
+ timeout=timeout,
2308
+ poll_interval=poll_interval,
2309
+ )
2310
+
2311
+ return {"success": True, "deployment_id": deployment_id}
2312
+
2313
+ async def cancel_deployment(
2314
+ self,
2315
+ deployment_id: str,
2316
+ ) -> Dict[str, Any]:
2317
+ """
2318
+ Cancel a deployment.
2319
+
2320
+ Args:
2321
+ deployment_id: Deployment ID to reject
2322
+
2323
+ Returns:
2324
+ Dict with rejection result
2325
+ """
2326
+ logger.info("Cancelling deployment %s", deployment_id)
2327
+ client = self.get_langstudio_client()
2328
+
2329
+ await client.update_deployment_async(
2330
+ deployment_id=deployment_id,
2331
+ workspace_id=self.workspace_id,
2332
+ stage_action=json.dumps({"Stage": 3, "Action": "Cancel"}),
2333
+ )
2334
+
2335
+ return {"success": True, "deployment_id": deployment_id}