agenta 0.52.6__py3-none-any.whl → 0.63.2__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.
- agenta/__init__.py +12 -3
- agenta/client/__init__.py +4 -4
- agenta/client/backend/__init__.py +4 -4
- agenta/client/backend/api_keys/client.py +2 -2
- agenta/client/backend/billing/client.py +2 -2
- agenta/client/backend/billing/raw_client.py +2 -2
- agenta/client/backend/client.py +56 -48
- agenta/client/backend/core/client_wrapper.py +2 -2
- agenta/client/backend/core/file.py +3 -1
- agenta/client/backend/core/http_client.py +3 -3
- agenta/client/backend/core/pydantic_utilities.py +13 -3
- agenta/client/backend/human_evaluations/client.py +2 -2
- agenta/client/backend/human_evaluations/raw_client.py +2 -2
- agenta/client/backend/organization/client.py +46 -34
- agenta/client/backend/organization/raw_client.py +32 -26
- agenta/client/backend/raw_client.py +26 -26
- agenta/client/backend/testsets/client.py +18 -18
- agenta/client/backend/testsets/raw_client.py +30 -30
- agenta/client/backend/types/__init__.py +4 -4
- agenta/client/backend/types/account_request.py +3 -1
- agenta/client/backend/types/account_response.py +3 -1
- agenta/client/backend/types/agenta_node_dto.py +3 -1
- agenta/client/backend/types/agenta_nodes_response.py +3 -1
- agenta/client/backend/types/agenta_root_dto.py +3 -1
- agenta/client/backend/types/agenta_roots_response.py +3 -1
- agenta/client/backend/types/agenta_tree_dto.py +3 -1
- agenta/client/backend/types/agenta_trees_response.py +3 -1
- agenta/client/backend/types/aggregated_result.py +3 -1
- agenta/client/backend/types/analytics_response.py +3 -1
- agenta/client/backend/types/annotation.py +6 -4
- agenta/client/backend/types/annotation_create.py +3 -1
- agenta/client/backend/types/annotation_edit.py +3 -1
- agenta/client/backend/types/annotation_link.py +3 -1
- agenta/client/backend/types/annotation_link_response.py +3 -1
- agenta/client/backend/types/annotation_query.py +3 -1
- agenta/client/backend/types/annotation_query_request.py +3 -1
- agenta/client/backend/types/annotation_reference.py +3 -1
- agenta/client/backend/types/annotation_references.py +3 -1
- agenta/client/backend/types/annotation_response.py +3 -1
- agenta/client/backend/types/annotations_response.py +3 -1
- agenta/client/backend/types/app.py +3 -1
- agenta/client/backend/types/app_variant_response.py +3 -1
- agenta/client/backend/types/app_variant_revision.py +3 -1
- agenta/client/backend/types/artifact.py +6 -4
- agenta/client/backend/types/base_output.py +3 -1
- agenta/client/backend/types/body_fetch_workflow_revision.py +3 -1
- agenta/client/backend/types/body_import_testset.py +3 -1
- agenta/client/backend/types/bucket_dto.py +3 -1
- agenta/client/backend/types/collect_status_response.py +3 -1
- agenta/client/backend/types/config_db.py +3 -1
- agenta/client/backend/types/config_dto.py +3 -1
- agenta/client/backend/types/config_response_model.py +3 -1
- agenta/client/backend/types/correct_answer.py +3 -1
- agenta/client/backend/types/create_app_output.py +3 -1
- agenta/client/backend/types/custom_model_settings_dto.py +3 -1
- agenta/client/backend/types/custom_provider_dto.py +3 -1
- agenta/client/backend/types/custom_provider_kind.py +1 -1
- agenta/client/backend/types/custom_provider_settings_dto.py +3 -1
- agenta/client/backend/types/delete_evaluation.py +3 -1
- agenta/client/backend/types/environment_output.py +3 -1
- agenta/client/backend/types/environment_output_extended.py +3 -1
- agenta/client/backend/types/environment_revision.py +3 -1
- agenta/client/backend/types/error.py +3 -1
- agenta/client/backend/types/evaluation.py +3 -1
- agenta/client/backend/types/evaluation_scenario.py +3 -1
- agenta/client/backend/types/evaluation_scenario_input.py +3 -1
- agenta/client/backend/types/evaluation_scenario_output.py +3 -1
- agenta/client/backend/types/evaluation_scenario_result.py +3 -1
- agenta/client/backend/types/evaluator.py +6 -4
- agenta/client/backend/types/evaluator_config.py +6 -4
- agenta/client/backend/types/evaluator_flags.py +3 -1
- agenta/client/backend/types/evaluator_mapping_output_interface.py +3 -1
- agenta/client/backend/types/evaluator_output_interface.py +3 -1
- agenta/client/backend/types/evaluator_query.py +3 -1
- agenta/client/backend/types/evaluator_query_request.py +3 -1
- agenta/client/backend/types/evaluator_request.py +3 -1
- agenta/client/backend/types/evaluator_response.py +3 -1
- agenta/client/backend/types/evaluators_response.py +3 -1
- agenta/client/backend/types/exception_dto.py +3 -1
- agenta/client/backend/types/extended_o_tel_tracing_response.py +3 -1
- agenta/client/backend/types/get_config_response.py +3 -1
- agenta/client/backend/types/header.py +3 -1
- agenta/client/backend/types/http_validation_error.py +3 -1
- agenta/client/backend/types/human_evaluation.py +3 -1
- agenta/client/backend/types/human_evaluation_scenario.py +3 -1
- agenta/client/backend/types/human_evaluation_scenario_input.py +3 -1
- agenta/client/backend/types/human_evaluation_scenario_output.py +3 -1
- agenta/client/backend/types/invite_request.py +3 -1
- agenta/client/backend/types/legacy_analytics_response.py +3 -1
- agenta/client/backend/types/legacy_data_point.py +3 -1
- agenta/client/backend/types/legacy_evaluator.py +3 -1
- agenta/client/backend/types/legacy_scope_request.py +3 -1
- agenta/client/backend/types/legacy_scopes_response.py +3 -1
- agenta/client/backend/types/legacy_subscription_request.py +3 -1
- agenta/client/backend/types/legacy_user_request.py +3 -1
- agenta/client/backend/types/legacy_user_response.py +3 -1
- agenta/client/backend/types/lifecycle_dto.py +3 -1
- agenta/client/backend/types/link_dto.py +3 -1
- agenta/client/backend/types/list_api_keys_response.py +3 -1
- agenta/client/backend/types/llm_run_rate_limit.py +3 -1
- agenta/client/backend/types/meta_request.py +3 -1
- agenta/client/backend/types/metrics_dto.py +3 -1
- agenta/client/backend/types/new_testset.py +3 -1
- agenta/client/backend/types/node_dto.py +3 -1
- agenta/client/backend/types/o_tel_context_dto.py +3 -1
- agenta/client/backend/types/o_tel_event.py +6 -4
- agenta/client/backend/types/o_tel_event_dto.py +3 -1
- agenta/client/backend/types/o_tel_extra_dto.py +3 -1
- agenta/client/backend/types/o_tel_flat_span.py +6 -4
- agenta/client/backend/types/o_tel_link.py +6 -4
- agenta/client/backend/types/o_tel_link_dto.py +3 -1
- agenta/client/backend/types/o_tel_links_response.py +3 -1
- agenta/client/backend/types/o_tel_span.py +1 -1
- agenta/client/backend/types/o_tel_span_dto.py +3 -1
- agenta/client/backend/types/o_tel_spans_tree.py +3 -1
- agenta/client/backend/types/o_tel_tracing_data_response.py +3 -1
- agenta/client/backend/types/o_tel_tracing_request.py +3 -1
- agenta/client/backend/types/o_tel_tracing_response.py +3 -1
- agenta/client/backend/types/organization.py +3 -1
- agenta/client/backend/types/organization_details.py +3 -1
- agenta/client/backend/types/organization_membership_request.py +3 -1
- agenta/client/backend/types/organization_output.py +3 -1
- agenta/client/backend/types/organization_request.py +3 -1
- agenta/client/backend/types/parent_dto.py +3 -1
- agenta/client/backend/types/project_membership_request.py +3 -1
- agenta/client/backend/types/project_request.py +3 -1
- agenta/client/backend/types/project_scope.py +3 -1
- agenta/client/backend/types/projects_response.py +3 -1
- agenta/client/backend/types/reference.py +6 -4
- agenta/client/backend/types/reference_dto.py +3 -1
- agenta/client/backend/types/reference_request_model.py +3 -1
- agenta/client/backend/types/result.py +3 -1
- agenta/client/backend/types/root_dto.py +3 -1
- agenta/client/backend/types/scopes_response_model.py +3 -1
- agenta/client/backend/types/secret_dto.py +3 -1
- agenta/client/backend/types/secret_response_dto.py +3 -1
- agenta/client/backend/types/simple_evaluation_output.py +3 -1
- agenta/client/backend/types/span_dto.py +6 -4
- agenta/client/backend/types/standard_provider_dto.py +3 -1
- agenta/client/backend/types/standard_provider_settings_dto.py +3 -1
- agenta/client/backend/types/status_dto.py +3 -1
- agenta/client/backend/types/tags_request.py +3 -1
- agenta/client/backend/types/testcase_response.py +6 -4
- agenta/client/backend/types/testset.py +6 -4
- agenta/client/backend/types/{test_set_output_response.py → testset_output_response.py} +4 -2
- agenta/client/backend/types/testset_request.py +3 -1
- agenta/client/backend/types/testset_response.py +3 -1
- agenta/client/backend/types/{test_set_simple_response.py → testset_simple_response.py} +4 -2
- agenta/client/backend/types/testsets_response.py +3 -1
- agenta/client/backend/types/time_dto.py +3 -1
- agenta/client/backend/types/tree_dto.py +3 -1
- agenta/client/backend/types/update_app_output.py +3 -1
- agenta/client/backend/types/user_request.py +3 -1
- agenta/client/backend/types/validation_error.py +3 -1
- agenta/client/backend/types/workflow_artifact.py +6 -4
- agenta/client/backend/types/workflow_data.py +3 -1
- agenta/client/backend/types/workflow_flags.py +3 -1
- agenta/client/backend/types/workflow_request.py +3 -1
- agenta/client/backend/types/workflow_response.py +3 -1
- agenta/client/backend/types/workflow_revision.py +6 -4
- agenta/client/backend/types/workflow_revision_request.py +3 -1
- agenta/client/backend/types/workflow_revision_response.py +3 -1
- agenta/client/backend/types/workflow_revisions_response.py +3 -1
- agenta/client/backend/types/workflow_variant.py +6 -4
- agenta/client/backend/types/workflow_variant_request.py +3 -1
- agenta/client/backend/types/workflow_variant_response.py +3 -1
- agenta/client/backend/types/workflow_variants_response.py +3 -1
- agenta/client/backend/types/workflows_response.py +3 -1
- agenta/client/backend/types/workspace.py +3 -1
- agenta/client/backend/types/workspace_member_response.py +3 -1
- agenta/client/backend/types/workspace_membership_request.py +3 -1
- agenta/client/backend/types/workspace_permission.py +3 -1
- agenta/client/backend/types/workspace_request.py +3 -1
- agenta/client/backend/types/workspace_response.py +3 -1
- agenta/client/backend/vault/raw_client.py +4 -4
- agenta/client/backend/workspace/client.py +2 -2
- agenta/client/client.py +102 -88
- agenta/sdk/__init__.py +52 -3
- agenta/sdk/agenta_init.py +43 -16
- agenta/sdk/assets.py +23 -15
- agenta/sdk/context/serving.py +20 -8
- agenta/sdk/context/tracing.py +40 -22
- agenta/sdk/contexts/__init__.py +0 -0
- agenta/sdk/contexts/routing.py +38 -0
- agenta/sdk/contexts/running.py +57 -0
- agenta/sdk/contexts/tracing.py +86 -0
- agenta/sdk/decorators/__init__.py +1 -0
- agenta/sdk/decorators/routing.py +284 -0
- agenta/sdk/decorators/running.py +692 -98
- agenta/sdk/decorators/serving.py +20 -21
- agenta/sdk/decorators/tracing.py +176 -131
- agenta/sdk/engines/__init__.py +0 -0
- agenta/sdk/engines/running/__init__.py +0 -0
- agenta/sdk/engines/running/utils.py +17 -0
- agenta/sdk/engines/tracing/__init__.py +1 -0
- agenta/sdk/engines/tracing/attributes.py +185 -0
- agenta/sdk/engines/tracing/conventions.py +49 -0
- agenta/sdk/engines/tracing/exporters.py +130 -0
- agenta/sdk/engines/tracing/inline.py +1154 -0
- agenta/sdk/engines/tracing/processors.py +190 -0
- agenta/sdk/engines/tracing/propagation.py +102 -0
- agenta/sdk/engines/tracing/spans.py +136 -0
- agenta/sdk/engines/tracing/tracing.py +324 -0
- agenta/sdk/evaluations/__init__.py +2 -0
- agenta/sdk/evaluations/metrics.py +37 -0
- agenta/sdk/evaluations/preview/__init__.py +0 -0
- agenta/sdk/evaluations/preview/evaluate.py +765 -0
- agenta/sdk/evaluations/preview/utils.py +861 -0
- agenta/sdk/evaluations/results.py +66 -0
- agenta/sdk/evaluations/runs.py +153 -0
- agenta/sdk/evaluations/scenarios.py +48 -0
- agenta/sdk/litellm/litellm.py +12 -0
- agenta/sdk/litellm/mockllm.py +6 -8
- agenta/sdk/litellm/mocks/__init__.py +5 -5
- agenta/sdk/managers/applications.py +304 -0
- agenta/sdk/managers/config.py +2 -2
- agenta/sdk/managers/evaluations.py +0 -0
- agenta/sdk/managers/evaluators.py +303 -0
- agenta/sdk/managers/secrets.py +161 -24
- agenta/sdk/managers/shared.py +3 -1
- agenta/sdk/managers/testsets.py +441 -0
- agenta/sdk/managers/vault.py +3 -3
- agenta/sdk/middleware/auth.py +0 -176
- agenta/sdk/middleware/config.py +27 -9
- agenta/sdk/middleware/vault.py +204 -9
- agenta/sdk/middlewares/__init__.py +0 -0
- agenta/sdk/middlewares/routing/__init__.py +0 -0
- agenta/sdk/middlewares/routing/auth.py +263 -0
- agenta/sdk/middlewares/routing/cors.py +30 -0
- agenta/sdk/middlewares/routing/otel.py +29 -0
- agenta/sdk/middlewares/running/__init__.py +0 -0
- agenta/sdk/middlewares/running/normalizer.py +321 -0
- agenta/sdk/middlewares/running/resolver.py +161 -0
- agenta/sdk/middlewares/running/vault.py +140 -0
- agenta/sdk/models/__init__.py +0 -0
- agenta/sdk/models/blobs.py +33 -0
- agenta/sdk/models/evaluations.py +119 -0
- agenta/sdk/models/git.py +126 -0
- agenta/sdk/models/shared.py +167 -0
- agenta/sdk/models/testsets.py +163 -0
- agenta/sdk/models/tracing.py +202 -0
- agenta/sdk/models/workflows.py +753 -0
- agenta/sdk/tracing/attributes.py +4 -4
- agenta/sdk/tracing/exporters.py +67 -17
- agenta/sdk/tracing/inline.py +37 -45
- agenta/sdk/tracing/processors.py +97 -0
- agenta/sdk/tracing/propagation.py +3 -1
- agenta/sdk/tracing/spans.py +4 -0
- agenta/sdk/tracing/tracing.py +13 -15
- agenta/sdk/types.py +222 -22
- agenta/sdk/utils/cache.py +1 -1
- agenta/sdk/utils/client.py +38 -0
- agenta/sdk/utils/helpers.py +13 -12
- agenta/sdk/utils/logging.py +18 -78
- agenta/sdk/utils/references.py +23 -0
- agenta/sdk/workflows/builtin.py +600 -0
- agenta/sdk/workflows/configurations.py +22 -0
- agenta/sdk/workflows/errors.py +292 -0
- agenta/sdk/workflows/handlers.py +1791 -0
- agenta/sdk/workflows/interfaces.py +948 -0
- agenta/sdk/workflows/sandbox.py +118 -0
- agenta/sdk/workflows/utils.py +303 -6
- {agenta-0.52.6.dist-info → agenta-0.63.2.dist-info}/METADATA +37 -33
- agenta-0.63.2.dist-info/RECORD +421 -0
- {agenta-0.52.6.dist-info → agenta-0.63.2.dist-info}/WHEEL +1 -1
- agenta/sdk/middleware/adapt.py +0 -253
- agenta/sdk/middleware/base.py +0 -40
- agenta/sdk/middleware/flags.py +0 -40
- agenta/sdk/workflows/types.py +0 -472
- agenta-0.52.6.dist-info/RECORD +0 -371
- /agenta/sdk/{workflows → engines/running}/registry.py +0 -0
agenta/sdk/types.py
CHANGED
|
@@ -3,7 +3,7 @@ from dataclasses import dataclass
|
|
|
3
3
|
from typing import List, Union, Optional, Dict, Literal, Any
|
|
4
4
|
|
|
5
5
|
from pydantic import ConfigDict, BaseModel, HttpUrl
|
|
6
|
-
from pydantic import BaseModel, Field, model_validator
|
|
6
|
+
from pydantic import BaseModel, Field, model_validator, AliasChoices
|
|
7
7
|
|
|
8
8
|
from starlette.responses import StreamingResponse
|
|
9
9
|
|
|
@@ -21,13 +21,19 @@ def MCField( # pylint: disable=invalid-name
|
|
|
21
21
|
default: str,
|
|
22
22
|
choices: Union[List[str], Dict[str, List[str]]],
|
|
23
23
|
) -> Field:
|
|
24
|
-
|
|
24
|
+
# Pydantic 2.12+ no longer allows post-creation mutation of field properties
|
|
25
25
|
if isinstance(choices, dict):
|
|
26
|
-
|
|
26
|
+
json_extra = {"choices": choices, "x-parameter": "grouped_choice"}
|
|
27
27
|
elif isinstance(choices, list):
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
json_extra = {"choices": choices, "x-parameter": "choice"}
|
|
29
|
+
else:
|
|
30
|
+
json_extra = {}
|
|
31
|
+
|
|
32
|
+
return Field(
|
|
33
|
+
default=default,
|
|
34
|
+
description="ID of the model to use",
|
|
35
|
+
json_schema_extra=json_extra,
|
|
36
|
+
)
|
|
31
37
|
|
|
32
38
|
|
|
33
39
|
class LLMTokenUsage(BaseModel):
|
|
@@ -65,15 +71,15 @@ class StreamResponse(StreamingResponse):
|
|
|
65
71
|
):
|
|
66
72
|
headers = dict(extra_headers or {})
|
|
67
73
|
if version is not None:
|
|
68
|
-
headers["
|
|
74
|
+
headers["x-ag-version"] = version
|
|
69
75
|
if content_type:
|
|
70
|
-
headers["
|
|
76
|
+
headers["x-ag-content-type"] = content_type
|
|
71
77
|
if tree_id:
|
|
72
|
-
headers["
|
|
78
|
+
headers["x-ag-tree-id"] = tree_id
|
|
73
79
|
if trace_id:
|
|
74
|
-
headers["
|
|
80
|
+
headers["x-ag-trace-id"] = trace_id
|
|
75
81
|
if span_id:
|
|
76
|
-
headers["
|
|
82
|
+
headers["x-ag-span-id"] = span_id
|
|
77
83
|
|
|
78
84
|
super().__init__(
|
|
79
85
|
content=content,
|
|
@@ -329,7 +335,29 @@ class ContentPartImage(BaseModel):
|
|
|
329
335
|
image_url: ImageURL
|
|
330
336
|
|
|
331
337
|
|
|
332
|
-
|
|
338
|
+
class FileInput(BaseModel):
|
|
339
|
+
file_id: Optional[str] = Field(
|
|
340
|
+
default=None,
|
|
341
|
+
alias="file_id",
|
|
342
|
+
validation_alias=AliasChoices("file_id", "fileId"),
|
|
343
|
+
)
|
|
344
|
+
file_data: Optional[str] = Field(
|
|
345
|
+
default=None,
|
|
346
|
+
alias="file_data",
|
|
347
|
+
validation_alias=AliasChoices("file_data", "fileData"),
|
|
348
|
+
)
|
|
349
|
+
filename: Optional[str] = None
|
|
350
|
+
format: Optional[str] = None
|
|
351
|
+
|
|
352
|
+
model_config = {"populate_by_name": True}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class ContentPartFile(BaseModel):
|
|
356
|
+
type: Literal["file"] = "file"
|
|
357
|
+
file: FileInput
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
ContentPart = Union[ContentPartText, ContentPartImage, ContentPartFile]
|
|
333
361
|
|
|
334
362
|
|
|
335
363
|
class Message(BaseModel):
|
|
@@ -381,7 +409,7 @@ class ModelConfig(BaseModel):
|
|
|
381
409
|
"""Configuration for model parameters"""
|
|
382
410
|
|
|
383
411
|
model: str = MCField(
|
|
384
|
-
default="gpt-
|
|
412
|
+
default="gpt-4o-mini",
|
|
385
413
|
choices=supported_llm_models,
|
|
386
414
|
)
|
|
387
415
|
|
|
@@ -415,6 +443,14 @@ class ModelConfig(BaseModel):
|
|
|
415
443
|
le=2.0,
|
|
416
444
|
description="Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far",
|
|
417
445
|
)
|
|
446
|
+
reasoning_effort: Optional[Literal["none", "low", "medium", "high"]] = Field(
|
|
447
|
+
default=None,
|
|
448
|
+
description="Controls the reasoning effort for thinking models. Options: 'none' (cost-optimized, 0 tokens), 'low' (1024 tokens), 'medium' (2048 tokens), 'high' (4096 tokens)",
|
|
449
|
+
json_schema_extra={
|
|
450
|
+
"x-parameter": "choice",
|
|
451
|
+
"enum": ["none", "low", "medium", "high"],
|
|
452
|
+
},
|
|
453
|
+
)
|
|
418
454
|
response_format: Optional[ResponseFormat] = Field(
|
|
419
455
|
default=None,
|
|
420
456
|
description="An object specifying the format that the model must output",
|
|
@@ -456,6 +492,154 @@ class TemplateFormatError(PromptTemplateError):
|
|
|
456
492
|
super().__init__(message)
|
|
457
493
|
|
|
458
494
|
|
|
495
|
+
import json
|
|
496
|
+
import re
|
|
497
|
+
from typing import Any, Dict, Iterable, Tuple, Optional
|
|
498
|
+
|
|
499
|
+
# --- Optional dependency: python-jsonpath (provides JSONPath + JSON Pointer) ---
|
|
500
|
+
try:
|
|
501
|
+
import jsonpath # ✅ use module API
|
|
502
|
+
from jsonpath import JSONPointer # pointer class is fine to use
|
|
503
|
+
except Exception:
|
|
504
|
+
jsonpath = None
|
|
505
|
+
JSONPointer = None
|
|
506
|
+
|
|
507
|
+
# ========= Scheme detection =========
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def detect_scheme(expr: str) -> str:
|
|
511
|
+
"""Return 'json-path', 'json-pointer', or 'dot-notation' based on the placeholder prefix."""
|
|
512
|
+
if expr.startswith("$"):
|
|
513
|
+
return "json-path"
|
|
514
|
+
if expr.startswith("/"):
|
|
515
|
+
return "json-pointer"
|
|
516
|
+
return "dot-notation"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ========= Resolvers =========
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def resolve_dot_notation(expr: str, data: dict) -> object:
|
|
523
|
+
if "[" in expr or "]" in expr:
|
|
524
|
+
raise KeyError(f"Bracket syntax is not supported in dot-notation: {expr!r}")
|
|
525
|
+
|
|
526
|
+
# First, check if the expression exists as a literal key (e.g., "topic.story" as a single key)
|
|
527
|
+
# This allows users to use dots in their variable names without nested access
|
|
528
|
+
if expr in data:
|
|
529
|
+
return data[expr]
|
|
530
|
+
|
|
531
|
+
# If not found as a literal key, try to parse as dot-notation path
|
|
532
|
+
cur = data
|
|
533
|
+
for token in (p for p in expr.split(".") if p):
|
|
534
|
+
if isinstance(cur, list) and token.isdigit():
|
|
535
|
+
cur = cur[int(token)]
|
|
536
|
+
else:
|
|
537
|
+
if not isinstance(cur, dict):
|
|
538
|
+
raise KeyError(
|
|
539
|
+
f"Cannot access key {token!r} on non-dict while resolving {expr!r}"
|
|
540
|
+
)
|
|
541
|
+
if token not in cur:
|
|
542
|
+
raise KeyError(f"Missing key {token!r} while resolving {expr!r}")
|
|
543
|
+
cur = cur[token]
|
|
544
|
+
return cur
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def resolve_json_path(expr: str, data: dict) -> object:
|
|
548
|
+
if jsonpath is None:
|
|
549
|
+
raise ImportError("python-jsonpath is required for json-path ($...)")
|
|
550
|
+
|
|
551
|
+
if not (expr == "$" or expr.startswith("$.") or expr.startswith("$[")):
|
|
552
|
+
raise ValueError(
|
|
553
|
+
f"Invalid json-path expression {expr!r}. "
|
|
554
|
+
"Must start with '$', '$.' or '$[' (no implicit normalization)."
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Use package-level APIf
|
|
558
|
+
results = jsonpath.findall(expr, data) # always returns a list
|
|
559
|
+
return results[0] if len(results) == 1 else results
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def resolve_json_pointer(expr: str, data: Dict[str, Any]) -> Any:
|
|
563
|
+
"""Resolve a JSON Pointer; returns a single value."""
|
|
564
|
+
if JSONPointer is None:
|
|
565
|
+
raise ImportError("python-jsonpath is required for json-pointer (/...)")
|
|
566
|
+
return JSONPointer(expr).resolve(data)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def resolve_any(expr: str, data: Dict[str, Any]) -> Any:
|
|
570
|
+
"""Dispatch to the right resolver based on detected scheme."""
|
|
571
|
+
scheme = detect_scheme(expr)
|
|
572
|
+
if scheme == "json-path":
|
|
573
|
+
return resolve_json_path(expr, data)
|
|
574
|
+
if scheme == "json-pointer":
|
|
575
|
+
return resolve_json_pointer(expr, data)
|
|
576
|
+
return resolve_dot_notation(expr, data)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ========= Placeholder & coercion helpers =========
|
|
580
|
+
|
|
581
|
+
_PLACEHOLDER_RE = re.compile(r"\{\{\s*(.*?)\s*\}\}")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def extract_placeholders(template: str) -> Iterable[str]:
|
|
585
|
+
"""Yield the inner text of all {{ ... }} occurrences (trimmed)."""
|
|
586
|
+
for m in _PLACEHOLDER_RE.finditer(template):
|
|
587
|
+
yield m.group(1).strip()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def coerce_to_str(value: Any) -> str:
|
|
591
|
+
"""Pretty stringify values for embedding into templates."""
|
|
592
|
+
if isinstance(value, (dict, list)):
|
|
593
|
+
return json.dumps(value, ensure_ascii=False)
|
|
594
|
+
return str(value)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def build_replacements(
|
|
598
|
+
placeholders: Iterable[str], data: Dict[str, Any]
|
|
599
|
+
) -> Tuple[Dict[str, str], set]:
|
|
600
|
+
"""
|
|
601
|
+
Resolve all placeholders against data.
|
|
602
|
+
Returns (replacements, unresolved_placeholders).
|
|
603
|
+
"""
|
|
604
|
+
replacements: Dict[str, str] = {}
|
|
605
|
+
unresolved: set = set()
|
|
606
|
+
for expr in set(placeholders):
|
|
607
|
+
try:
|
|
608
|
+
val = resolve_any(expr, data)
|
|
609
|
+
# Escape backslashes to avoid regex replacement surprises
|
|
610
|
+
replacements[expr] = coerce_to_str(val).replace("\\", "\\\\")
|
|
611
|
+
except Exception:
|
|
612
|
+
unresolved.add(expr)
|
|
613
|
+
return replacements, unresolved
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def apply_replacements(template: str, replacements: Dict[str, str]) -> str:
|
|
617
|
+
"""Replace {{ expr }} using a callback to avoid regex-injection issues."""
|
|
618
|
+
|
|
619
|
+
def _repl(m: re.Match) -> str:
|
|
620
|
+
expr = m.group(1).strip()
|
|
621
|
+
return replacements.get(expr, m.group(0))
|
|
622
|
+
|
|
623
|
+
return _PLACEHOLDER_RE.sub(_repl, template)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def compute_truly_unreplaced(original: set, rendered: str) -> set:
|
|
627
|
+
"""Only count placeholders that were in the original template and remain."""
|
|
628
|
+
now = set(extract_placeholders(rendered))
|
|
629
|
+
return original & now
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def missing_lib_hints(unreplaced: set) -> Optional[str]:
|
|
633
|
+
"""Suggest installing python-jsonpath if placeholders indicate json-path or json-pointer usage."""
|
|
634
|
+
if any(expr.startswith("$") or expr.startswith("/") for expr in unreplaced) and (
|
|
635
|
+
jsonpath is None or JSONPointer is None
|
|
636
|
+
):
|
|
637
|
+
return (
|
|
638
|
+
"Install python-jsonpath to enable json-path ($...) and json-pointer (/...)"
|
|
639
|
+
)
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
|
|
459
643
|
class PromptTemplate(BaseModel):
|
|
460
644
|
"""A template for generating prompts with formatting capabilities"""
|
|
461
645
|
|
|
@@ -502,6 +686,7 @@ class PromptTemplate(BaseModel):
|
|
|
502
686
|
try:
|
|
503
687
|
if self.template_format == "fstring":
|
|
504
688
|
return content.format(**kwargs)
|
|
689
|
+
|
|
505
690
|
elif self.template_format == "jinja2":
|
|
506
691
|
from jinja2 import Template, TemplateError
|
|
507
692
|
|
|
@@ -512,22 +697,33 @@ class PromptTemplate(BaseModel):
|
|
|
512
697
|
f"Jinja2 template error in content: '{content}'. Error: {str(e)}",
|
|
513
698
|
original_error=e,
|
|
514
699
|
)
|
|
700
|
+
|
|
515
701
|
elif self.template_format == "curly":
|
|
516
|
-
|
|
702
|
+
original_placeholders = set(extract_placeholders(content))
|
|
703
|
+
|
|
704
|
+
replacements, _unresolved = build_replacements(
|
|
705
|
+
original_placeholders, kwargs
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
result = apply_replacements(content, replacements)
|
|
517
709
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if
|
|
522
|
-
|
|
710
|
+
truly_unreplaced = compute_truly_unreplaced(
|
|
711
|
+
original_placeholders, result
|
|
712
|
+
)
|
|
713
|
+
if truly_unreplaced:
|
|
714
|
+
hint = missing_lib_hints(truly_unreplaced)
|
|
715
|
+
suffix = f" Hint: {hint}" if hint else ""
|
|
523
716
|
raise TemplateFormatError(
|
|
524
|
-
f"Unreplaced variables in curly template: {
|
|
717
|
+
f"Unreplaced variables in curly template: {sorted(truly_unreplaced)}.{suffix}"
|
|
525
718
|
)
|
|
719
|
+
|
|
526
720
|
return result
|
|
721
|
+
|
|
527
722
|
else:
|
|
528
723
|
raise TemplateFormatError(
|
|
529
724
|
f"Unknown template format: {self.template_format}"
|
|
530
725
|
)
|
|
726
|
+
|
|
531
727
|
except KeyError as e:
|
|
532
728
|
key = str(e).strip("'")
|
|
533
729
|
raise TemplateFormatError(
|
|
@@ -535,7 +731,8 @@ class PromptTemplate(BaseModel):
|
|
|
535
731
|
)
|
|
536
732
|
except Exception as e:
|
|
537
733
|
raise TemplateFormatError(
|
|
538
|
-
f"Error formatting template '{content}': {str(e)}",
|
|
734
|
+
f"Error formatting template '{content}': {str(e)}",
|
|
735
|
+
original_error=e,
|
|
539
736
|
)
|
|
540
737
|
|
|
541
738
|
def _substitute_variables(self, obj: Any, kwargs: Dict[str, Any]) -> Any:
|
|
@@ -610,7 +807,7 @@ class PromptTemplate(BaseModel):
|
|
|
610
807
|
)
|
|
611
808
|
)
|
|
612
809
|
|
|
613
|
-
new_llm_config = self.llm_config.
|
|
810
|
+
new_llm_config = self.llm_config.model_copy(deep=True)
|
|
614
811
|
if new_llm_config.response_format is not None:
|
|
615
812
|
rf_dict = new_llm_config.response_format.model_dump(by_alias=True)
|
|
616
813
|
substituted = self._substitute_variables(rf_dict, kwargs)
|
|
@@ -652,6 +849,9 @@ class PromptTemplate(BaseModel):
|
|
|
652
849
|
if self.llm_config.presence_penalty is not None:
|
|
653
850
|
kwargs["presence_penalty"] = self.llm_config.presence_penalty
|
|
654
851
|
|
|
852
|
+
if self.llm_config.reasoning_effort is not None:
|
|
853
|
+
kwargs["reasoning_effort"] = self.llm_config.reasoning_effort
|
|
854
|
+
|
|
655
855
|
if self.llm_config.response_format:
|
|
656
856
|
kwargs["response_format"] = self.llm_config.response_format.dict(
|
|
657
857
|
by_alias=True
|
agenta/sdk/utils/cache.py
CHANGED
|
@@ -5,7 +5,7 @@ from collections import OrderedDict
|
|
|
5
5
|
from threading import Lock
|
|
6
6
|
|
|
7
7
|
CACHE_CAPACITY = int(getenv("AGENTA_MIDDLEWARE_CACHE_CAPACITY", "512"))
|
|
8
|
-
CACHE_TTL = int(getenv("AGENTA_MIDDLEWARE_CACHE_TTL", str(
|
|
8
|
+
CACHE_TTL = int(getenv("AGENTA_MIDDLEWARE_CACHE_TTL", str(1 * 60))) # 1 minutes
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class TTLLRUCache:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
BASE_TIMEOUT = 10
|
|
4
|
+
|
|
5
|
+
from agenta.sdk.utils.logging import get_module_logger
|
|
6
|
+
|
|
7
|
+
import agenta as ag
|
|
8
|
+
|
|
9
|
+
log = get_module_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def authed_api():
|
|
13
|
+
"""
|
|
14
|
+
Preconfigured requests for authenticated endpoints (supports all methods).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
api_url = ag.DEFAULT_AGENTA_SINGLETON_INSTANCE.api_url
|
|
18
|
+
api_key = ag.DEFAULT_AGENTA_SINGLETON_INSTANCE.api_key
|
|
19
|
+
|
|
20
|
+
if not api_url or not api_key:
|
|
21
|
+
log.error("Please call ag.init() first.")
|
|
22
|
+
log.error("And don't forget to set AGENTA_API_URL and AGENTA_API_KEY.")
|
|
23
|
+
raise ValueError("API URL and API Key must be set.")
|
|
24
|
+
|
|
25
|
+
def _request(method: str, endpoint: str, **kwargs):
|
|
26
|
+
url = f"{api_url}{endpoint}"
|
|
27
|
+
headers = kwargs.pop("headers", {})
|
|
28
|
+
headers.setdefault("Authorization", f"ApiKey {api_key}")
|
|
29
|
+
|
|
30
|
+
return requests.request(
|
|
31
|
+
method=method,
|
|
32
|
+
url=url,
|
|
33
|
+
headers=headers,
|
|
34
|
+
timeout=BASE_TIMEOUT,
|
|
35
|
+
**kwargs,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return _request
|
agenta/sdk/utils/helpers.py
CHANGED
|
@@ -20,25 +20,26 @@ def parse_url(url: str) -> str:
|
|
|
20
20
|
str: The parsed or rewritten URL suitable for the current environment and Docker network mode.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
-
# Normalize: remove trailing slash and /api suffix
|
|
24
23
|
url = url.rstrip("/")
|
|
25
|
-
if url.endswith("/api"):
|
|
26
|
-
url = url[: -len("/api")]
|
|
27
24
|
|
|
28
|
-
if "localhost" not in url:
|
|
25
|
+
if "localhost" not in url and "0.0.0.0" not in url:
|
|
29
26
|
return url
|
|
30
27
|
|
|
31
|
-
internal_url = os.getenv("AGENTA_API_INTERNAL_URL")
|
|
32
|
-
if internal_url:
|
|
33
|
-
return internal_url
|
|
34
|
-
|
|
35
28
|
docker_network_mode = os.getenv("DOCKER_NETWORK_MODE")
|
|
36
29
|
|
|
37
30
|
if docker_network_mode and docker_network_mode.lower() == "bridge":
|
|
38
|
-
return url.replace(
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
return url.replace(
|
|
32
|
+
"localhost",
|
|
33
|
+
"host.docker.internal",
|
|
34
|
+
).replace(
|
|
35
|
+
"0.0.0.0",
|
|
36
|
+
"host.docker.internal",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
not docker_network_mode
|
|
41
|
+
or (docker_network_mode and docker_network_mode.lower()) == "host"
|
|
42
|
+
):
|
|
41
43
|
return url
|
|
42
44
|
|
|
43
|
-
# For any other network mode, return the URL unchanged
|
|
44
45
|
return url
|
agenta/sdk/utils/logging.py
CHANGED
|
@@ -8,15 +8,6 @@ import logging
|
|
|
8
8
|
import structlog
|
|
9
9
|
from structlog.typing import EventDict, WrappedLogger, Processor
|
|
10
10
|
|
|
11
|
-
# from datetime import datetime
|
|
12
|
-
# from logging.handlers import RotatingFileHandler
|
|
13
|
-
|
|
14
|
-
# from opentelemetry.trace import get_current_span
|
|
15
|
-
# from opentelemetry._logs import set_logger_provider
|
|
16
|
-
# from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
|
|
17
|
-
# from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
18
|
-
# from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
19
|
-
|
|
20
11
|
TRACE_LEVEL = 1
|
|
21
12
|
logging.TRACE = TRACE_LEVEL
|
|
22
13
|
logging.addLevelName(TRACE_LEVEL, "TRACE")
|
|
@@ -40,15 +31,6 @@ structlog.stdlib.BoundLogger.trace = bound_logger_trace
|
|
|
40
31
|
AGENTA_LOG_CONSOLE_ENABLED = os.getenv("AGENTA_LOG_CONSOLE_ENABLED", "true") == "true"
|
|
41
32
|
AGENTA_LOG_CONSOLE_LEVEL = os.getenv("AGENTA_LOG_CONSOLE_LEVEL", "TRACE").upper()
|
|
42
33
|
|
|
43
|
-
# AGENTA_LOG_OTLP_ENABLED = os.getenv("AGENTA_LOG_OTLP_ENABLED", "false") == "true"
|
|
44
|
-
# AGENTA_LOG_OTLP_LEVEL = os.getenv("AGENTA_LOG_OTLP_LEVEL", "INFO").upper()
|
|
45
|
-
|
|
46
|
-
# AGENTA_LOG_FILE_ENABLED = os.getenv("AGENTA_LOG_FILE_ENABLED", "true") == "true"
|
|
47
|
-
# AGENTA_LOG_FILE_LEVEL = os.getenv("AGENTA_LOG_FILE_LEVEL", "WARNING").upper()
|
|
48
|
-
# AGENTA_LOG_FILE_BASE = os.getenv("AGENTA_LOG_FILE_PATH", "error")
|
|
49
|
-
# LOG_FILE_DATE = datetime.utcnow().strftime("%Y-%m-%d")
|
|
50
|
-
# AGENTA_LOG_FILE_PATH = f"{AGENTA_LOG_FILE_BASE}-{LOG_FILE_DATE}.log"
|
|
51
|
-
|
|
52
34
|
# COLORS
|
|
53
35
|
LEVEL_COLORS = {
|
|
54
36
|
"TRACE": "\033[97m",
|
|
@@ -88,15 +70,6 @@ def process_positional_args(_, __, event_dict: EventDict) -> EventDict:
|
|
|
88
70
|
return event_dict
|
|
89
71
|
|
|
90
72
|
|
|
91
|
-
# def add_trace_context(_, __, event_dict: EventDict) -> EventDict:
|
|
92
|
-
# span = get_current_span()
|
|
93
|
-
# if span and span.get_span_context().is_valid:
|
|
94
|
-
# ctx = span.get_span_context()
|
|
95
|
-
# event_dict["TraceId"] = format(ctx.trace_id, "032x")
|
|
96
|
-
# event_dict["SpanId"] = format(ctx.span_id, "016x")
|
|
97
|
-
# return event_dict
|
|
98
|
-
|
|
99
|
-
|
|
100
73
|
def add_logger_info(
|
|
101
74
|
logger: WrappedLogger, method_name: str, event_dict: EventDict
|
|
102
75
|
) -> EventDict:
|
|
@@ -143,36 +116,9 @@ def colored_console_renderer() -> Processor:
|
|
|
143
116
|
return render
|
|
144
117
|
|
|
145
118
|
|
|
146
|
-
# def plain_renderer() -> Processor:
|
|
147
|
-
# hidden = {
|
|
148
|
-
# "SeverityText",
|
|
149
|
-
# "SeverityNumber",
|
|
150
|
-
# "MethodName",
|
|
151
|
-
# "logger_factory",
|
|
152
|
-
# "LoggerName",
|
|
153
|
-
# "level",
|
|
154
|
-
# }
|
|
155
|
-
|
|
156
|
-
# def render(_, __, event_dict: EventDict) -> str:
|
|
157
|
-
# ts = event_dict.pop("Timestamp", "")[:23] + "Z"
|
|
158
|
-
# level = event_dict.get("level", "")
|
|
159
|
-
# msg = event_dict.pop("event", "")
|
|
160
|
-
# padded = f"[{level:<5}]"
|
|
161
|
-
# logger = f"[{event_dict.pop('logger', '')}]"
|
|
162
|
-
# extras = " ".join(f"{k}={v}" for k, v in event_dict.items() if k not in hidden)
|
|
163
|
-
# return f"{ts} {padded} {msg} {logger} {extras}"
|
|
164
|
-
|
|
165
|
-
# return render
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
# def json_renderer() -> Processor:
|
|
169
|
-
# return structlog.processors.JSONRenderer()
|
|
170
|
-
|
|
171
|
-
|
|
172
119
|
SHARED_PROCESSORS: list[Processor] = [
|
|
173
120
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="Timestamp"),
|
|
174
121
|
process_positional_args,
|
|
175
|
-
# add_trace_context,
|
|
176
122
|
add_logger_info,
|
|
177
123
|
structlog.processors.format_exc_info,
|
|
178
124
|
structlog.processors.dict_tracebacks,
|
|
@@ -193,35 +139,29 @@ def create_struct_logger(
|
|
|
193
139
|
)
|
|
194
140
|
|
|
195
141
|
|
|
142
|
+
# Guard against double initialization
|
|
143
|
+
_LOGGING_CONFIGURED = False
|
|
144
|
+
|
|
196
145
|
# CONFIGURE HANDLERS AND STRUCTLOG LOGGERS
|
|
197
146
|
handlers = []
|
|
198
147
|
loggers = []
|
|
199
148
|
|
|
200
|
-
if AGENTA_LOG_CONSOLE_ENABLED:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
logging.getLogger("console")
|
|
205
|
-
loggers.append(create_struct_logger([colored_console_renderer()], "console"))
|
|
149
|
+
if AGENTA_LOG_CONSOLE_ENABLED and not _LOGGING_CONFIGURED:
|
|
150
|
+
_LOGGING_CONFIGURED = True
|
|
151
|
+
|
|
152
|
+
# Check if console logger already has handlers (from OSS module)
|
|
153
|
+
console_logger = logging.getLogger("console")
|
|
206
154
|
|
|
207
|
-
|
|
208
|
-
#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
# provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
|
|
218
|
-
# set_logger_provider(provider)
|
|
219
|
-
# h = LoggingHandler(
|
|
220
|
-
# level=getattr(logging, AGENTA_LOG_OTLP_LEVEL, logging.INFO), logger_provider=provider
|
|
221
|
-
# )
|
|
222
|
-
# h.setFormatter(logging.Formatter("%(message)s"))
|
|
223
|
-
# logging.getLogger("otel").addHandler(h)
|
|
224
|
-
# loggers.append(create_struct_logger([json_renderer()], "otel"))
|
|
155
|
+
if not console_logger.handlers:
|
|
156
|
+
# Only add handler if it doesn't exist yet
|
|
157
|
+
h = logging.StreamHandler(sys.stdout)
|
|
158
|
+
h.setLevel(getattr(logging, AGENTA_LOG_CONSOLE_LEVEL, TRACE_LEVEL))
|
|
159
|
+
h.setFormatter(logging.Formatter("%(message)s"))
|
|
160
|
+
console_logger.addHandler(h)
|
|
161
|
+
console_logger.setLevel(TRACE_LEVEL)
|
|
162
|
+
console_logger.propagate = False
|
|
163
|
+
|
|
164
|
+
loggers.append(create_struct_logger([colored_console_renderer()], "console"))
|
|
225
165
|
|
|
226
166
|
|
|
227
167
|
class MultiLogger:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_slug_from_name_and_id(
|
|
7
|
+
name: str,
|
|
8
|
+
id: UUID, # pylint: disable=redefined-builtin
|
|
9
|
+
) -> str:
|
|
10
|
+
# Normalize Unicode (e.g., é → e)
|
|
11
|
+
name = unicodedata.normalize("NFKD", name)
|
|
12
|
+
# Remove non-ASCII characters
|
|
13
|
+
name = name.encode("ascii", "ignore").decode("ascii")
|
|
14
|
+
# Lowercase and remove non-word characters except hyphens and spaces
|
|
15
|
+
name = re.sub(r"[^\w\s-]", "", name.lower())
|
|
16
|
+
# Replace any sequence of hyphens or whitespace with a single hyphen
|
|
17
|
+
name = re.sub(r"[-\s]+", "-", name)
|
|
18
|
+
# Trim leading/trailing hyphens
|
|
19
|
+
name = name.strip("-")
|
|
20
|
+
# Last 12 characters of the ID
|
|
21
|
+
slug = f"{name}-{id.hex[-12:]}"
|
|
22
|
+
|
|
23
|
+
return slug.lower()
|