ai-utils-lib 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rubens Gomes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-utils-lib
3
+ Version: 0.0.1
4
+ Summary: AI Utils Library.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: ai,config,llm,mcp,agentic
8
+ Author: Rubens Gomes
9
+ Author-email: rubens.s.gomes@gmail.com
10
+ Maintainer: Rubens Gomes
11
+ Maintainer-email: rubens.s.gomes@gmail.com
12
+ Requires-Python: >=3.14,<4.0.0
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: aiohttp (>=3.13,<4)
16
+ Requires-Dist: azure-appconfiguration-provider (>=2.5,<3)
17
+ Requires-Dist: azure-identity (>=1.26,<2)
18
+ Requires-Dist: azure-keyvault-secrets (>=4.11,<5)
19
+ Requires-Dist: colorlog (>=6.12.0,<7)
20
+ Requires-Dist: pydantic (>=2.13.5,<3)
21
+ Requires-Dist: pyyaml (>=6.0.3,<7)
22
+ Project-URL: Documentation, https://github.com/rubensgomes-org/ai-utils-lib/blob/main/README.md
23
+ Project-URL: Homepage, https://github.com/rubensgomes-org/ai-utils-lib/
24
+ Project-URL: Repository, https://github.com/rubensgomes-org/ai-utils-lib/
25
+ Description-Content-Type: text/markdown
26
+
27
+ # AI Utilities Library
28
+
29
+ [![python](https://img.shields.io/badge/python-3.14%2B-0969da?logo=python)](https://www.python.org/downloads/release/python-3147/)
30
+ [![poetry](https://img.shields.io/badge/poetry-2.5%2B-0969da?logo=poetry)](https://python-poetry.org/)
31
+ [![OpenAI](https://img.shields.io/badge/OpenAI-3.22%2B-8250df?logo=openai)](https://github.com/openai/openai-python)
32
+ [![GitHub](https://img.shields.io/badge/GitHub-Actions-0969da?logo=github+actions)](https://github.com/features/actions)
33
+ [![Microsoft](https://img.shields.io/badge/Microsoft-Azure-0969da)](https://azure.microsoft.com/en-us)
34
+ [![AI](https://img.shields.io/badge/AI-Assisted-d29922?logo=claude+code)](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/AI_DISCLAIMER.md)
35
+ [![license](https://img.shields.io/badge/license-MIT-1a7f37)](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/LICENSE)
36
+
37
+ Rubens created this Python library of utilities to be shared and re-used across
38
+ some of his software development projects.
39
+
40
+ ---
41
+
42
+ ## Features
43
+
44
+ - **YAML Config** — API function to load and read config.yaml files
45
+ - **Azure App Configuration** — API function to load and read Azure App Configuration settings
46
+ - **Azure Key Vault** — API function to load and read Azure Key Vault secrets
47
+
48
+ ## AI Disclaimer
49
+
50
+ This project includes code and documentation created with the assistance of AI
51
+ tools. For details on usage, limits, and review practices, please see the
52
+ [AI Disclaimer](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/AI_DISCLAIMER.md).
53
+
54
+ ## Prerequisites
55
+
56
+ - Python 3.14+
57
+ - pip
58
+
59
+ ## Installation
60
+
61
+ 1. Install using `pip`
62
+
63
+ ```bash
64
+ pip install ai-utils-lib
65
+ ```
66
+
67
+ ## Uninstall
68
+
69
+ ```bash
70
+ pip uninstall ai-utils-lib
71
+ pip cache purge
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ - TODO
77
+
78
+ ## Usage
79
+
80
+ - TODO
81
+
82
+ ## Open Source Project Information
83
+
84
+ This project is open source and publicly hosted on GitHub at
85
+ [rubensgomes-org](https://github.com/rubensgomes-org/). It is
86
+ published under an
87
+ [OSI-approved open-source license](https://opensource.org/licenses).
88
+
89
+ > [!IMPORTANT]
90
+ > Public availability and the use of an OSI-approved license are requirements
91
+ > for eligibility to
92
+ > use [SonarQube Cloud](https://sonarcloud.io/) under its free plan for
93
+ > open-source projects.
94
+
95
+ ## License
96
+
97
+ The project is licensed under the
98
+ [MIT License](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/LICENSE).
99
+
100
+ ## Links
101
+
102
+ - [GitHub Project](https://github.com/rubensgomes-org/ai-utils-lib)
103
+
104
+ ---
105
+ Author: [Rubens Gomes](https://rubensgomes.com/)
106
+
@@ -0,0 +1,79 @@
1
+ # AI Utilities Library
2
+
3
+ [![python](https://img.shields.io/badge/python-3.14%2B-0969da?logo=python)](https://www.python.org/downloads/release/python-3147/)
4
+ [![poetry](https://img.shields.io/badge/poetry-2.5%2B-0969da?logo=poetry)](https://python-poetry.org/)
5
+ [![OpenAI](https://img.shields.io/badge/OpenAI-3.22%2B-8250df?logo=openai)](https://github.com/openai/openai-python)
6
+ [![GitHub](https://img.shields.io/badge/GitHub-Actions-0969da?logo=github+actions)](https://github.com/features/actions)
7
+ [![Microsoft](https://img.shields.io/badge/Microsoft-Azure-0969da)](https://azure.microsoft.com/en-us)
8
+ [![AI](https://img.shields.io/badge/AI-Assisted-d29922?logo=claude+code)](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/AI_DISCLAIMER.md)
9
+ [![license](https://img.shields.io/badge/license-MIT-1a7f37)](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/LICENSE)
10
+
11
+ Rubens created this Python library of utilities to be shared and re-used across
12
+ some of his software development projects.
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - **YAML Config** — API function to load and read config.yaml files
19
+ - **Azure App Configuration** — API function to load and read Azure App Configuration settings
20
+ - **Azure Key Vault** — API function to load and read Azure Key Vault secrets
21
+
22
+ ## AI Disclaimer
23
+
24
+ This project includes code and documentation created with the assistance of AI
25
+ tools. For details on usage, limits, and review practices, please see the
26
+ [AI Disclaimer](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/AI_DISCLAIMER.md).
27
+
28
+ ## Prerequisites
29
+
30
+ - Python 3.14+
31
+ - pip
32
+
33
+ ## Installation
34
+
35
+ 1. Install using `pip`
36
+
37
+ ```bash
38
+ pip install ai-utils-lib
39
+ ```
40
+
41
+ ## Uninstall
42
+
43
+ ```bash
44
+ pip uninstall ai-utils-lib
45
+ pip cache purge
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ - TODO
51
+
52
+ ## Usage
53
+
54
+ - TODO
55
+
56
+ ## Open Source Project Information
57
+
58
+ This project is open source and publicly hosted on GitHub at
59
+ [rubensgomes-org](https://github.com/rubensgomes-org/). It is
60
+ published under an
61
+ [OSI-approved open-source license](https://opensource.org/licenses).
62
+
63
+ > [!IMPORTANT]
64
+ > Public availability and the use of an OSI-approved license are requirements
65
+ > for eligibility to
66
+ > use [SonarQube Cloud](https://sonarcloud.io/) under its free plan for
67
+ > open-source projects.
68
+
69
+ ## License
70
+
71
+ The project is licensed under the
72
+ [MIT License](https://github.com/rubensgomes-org/ai-utils-lib/blob/main/LICENSE).
73
+
74
+ ## Links
75
+
76
+ - [GitHub Project](https://github.com/rubensgomes-org/ai-utils-lib)
77
+
78
+ ---
79
+ Author: [Rubens Gomes](https://rubensgomes.com/)
@@ -0,0 +1,134 @@
1
+ [project]
2
+ name = "ai-utils-lib"
3
+ keywords = ["ai", "config", "llm", "mcp", "agentic"]
4
+ version = "0.0.1"
5
+ description = "AI Utils Library."
6
+ authors = [
7
+ { name = "Rubens Gomes", email = "rubens.s.gomes@gmail.com" }
8
+ ]
9
+ maintainers = [
10
+ { name = "Rubens Gomes", email = "rubens.s.gomes@gmail.com" }
11
+ ]
12
+ readme = "README.md"
13
+ license = "MIT"
14
+ requires-python = ">=3.14,<4.0.0"
15
+ classifiers = [
16
+ # How mature is this project? Common values are
17
+ # 3 - Alpha
18
+ # 4 - Beta
19
+ # 5 - Production/Stable
20
+ "Development Status :: 4 - Beta",
21
+ # Python versions supported.
22
+ "Programming Language :: Python :: 3.14"
23
+ ]
24
+ dependencies = [
25
+ "colorlog (>=6.12.0,<7)",
26
+ "pydantic (>=2.13.5,<3)",
27
+ "pyyaml (>=6.0.3,<7)",
28
+ # Azure dependencies
29
+ "azure-appconfiguration-provider (>=2.5,<3)",
30
+ "azure-identity (>=1.26,<2)",
31
+ "azure-keyvault-secrets (>=4.11,<5)",
32
+ "aiohttp (>=3.13,<4)",
33
+ ]
34
+
35
+ [build-system]
36
+ requires = ["poetry-core>=2.5,<3"]
37
+ build-backend = "poetry.core.masonry.api"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "black (>=26.10,<27)",
42
+ "flake8 (>=7.4,<8)",
43
+ "isort (>=9.0,<10)",
44
+ "mypy (>=2.4,<3)",
45
+ "pip-audit (>=2.10,<3)",
46
+ "poethepoet (>=0.48,<1)",
47
+ "poetry (>=2.5,<3)",
48
+ "pylint (>=4.1,<5)",
49
+ "pysonar (>=1.9,<3)",
50
+ "types-pyyaml (>=6.0,<7)",
51
+ "uv (>=0.13,<1)",
52
+ ]
53
+ test = [
54
+ "coverage (>=7.16,<8)",
55
+ "pytest (>=9.1,<10)",
56
+ "pytest-asyncio (>=1.4,<2)",
57
+ "pytest-cov (>=7.1,<8)",
58
+ "pytest-mock (>=3.15,<4)",
59
+ ]
60
+
61
+ [project.urls]
62
+ Homepage = "https://github.com/rubensgomes-org/ai-utils-lib/"
63
+ Documentation = "https://github.com/rubensgomes-org/ai-utils-lib/blob/main/README.md"
64
+ Repository = "https://github.com/rubensgomes-org/ai-utils-lib/"
65
+
66
+ ################################################################################
67
+ # ---------- >>> Tools <<< -----------------------------------------------------
68
+ ################################################################################
69
+
70
+ [tool.black]
71
+ line-length = 80
72
+ target-version = ["py314"]
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ fail_under = 90
77
+ exclude_also = ['if __name__ == .__main__.:']
78
+
79
+ [tool.coverage.run]
80
+ source = ["ai_utils_lib"]
81
+ branch = true
82
+
83
+ [tool.isort]
84
+ line_length = 80
85
+ profile = "black"
86
+
87
+ [tool.mypy]
88
+ mypy_path = "src"
89
+ ignore_missing_imports = true
90
+
91
+ [tool.poetry]
92
+ packages = [{ include = "ai_utils_lib", from = "src" }]
93
+
94
+ [tool.pytest.ini_options]
95
+ asyncio_mode = "auto"
96
+ addopts = "--cov --cov-report=term-missing --cov-report=xml"
97
+
98
+ [tool.sonar]
99
+ # SONAR_TOKEN must be defined as an environment variable
100
+ organization = "rubensgomes-org"
101
+ projectKey = "rubensgomes-org_ai-utils-lib"
102
+ projectName = "ai-utils-lib"
103
+ qualitygate.wait = true
104
+ sources = "src/"
105
+ sourceEncoding = "UTF-8"
106
+ python.version = "3.14"
107
+
108
+ # ---------- >>> poethepoet tasks <<< -----------------------------------------
109
+
110
+ # To run a poe task:
111
+ # `poetry run poe <task-name>` OR
112
+ # `poe <task-name` (e.g., poe clean)
113
+
114
+ [tool.poe.tasks]
115
+ # Poe the Poetry "poethepoet" task runner needs to be installed.
116
+ # Define individual cleanup tasks
117
+ _clean_cache = "poetry cache clear --all"
118
+ _clean_misc = "rm -rf .mypy_cache dist build"
119
+ _clean_pyc = "find . -name '*.pyc' -delete"
120
+ _clean_pycache = "find . -name '__pycache__' -type d -exec rm -rf {} +"
121
+ _clean_pytest = "rm -rf .pytest_cache"
122
+ _clean_venv = "poetry env remove --all"
123
+
124
+ # Combine them into one 'clean' command. Similar to `make clean`
125
+ [tool.poe.tasks.clean]
126
+ sequence = [
127
+ "_clean_cache",
128
+ "_clean_misc",
129
+ "_clean_pyc",
130
+ "_clean_pycache",
131
+ "_clean_pytest",
132
+ "_clean_venv",
133
+ ]
134
+ help = "Remove all project artifacts"
@@ -0,0 +1 @@
1
+ """AI Utilities package."""
@@ -0,0 +1 @@
1
+ """Configuration package for AI Utilities."""
@@ -0,0 +1 @@
1
+ """Azure Configuration for AI Utilities."""
@@ -0,0 +1,67 @@
1
+ """Azure App Configuration settings reader for AI Utilities."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from azure.appconfiguration import ConfigurationSetting
7
+ from azure.appconfiguration.aio import AzureAppConfigurationClient
8
+ from azure.identity.aio import DefaultAzureCredential
9
+
10
+ __all__ = ["list_settings"]
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # In Azure Container Apps, `appcs_endpoint` is usually the
15
+ # APP_CONFIG_ENDPOINT environment variable, and the app's managed identity
16
+ # needs the "App Configuration Data Reader" role.
17
+
18
+ # How do Azure Container Apps get permission to read the Key Vault secret?
19
+ #
20
+ # All Azure Container Apps provisioned by Rubens' Terraform azure-iac
21
+ # project is assigned a shared UAMI (User-Assigned Managed Identiry), also
22
+ # provisioned by the same project. This UAMI is granted a RBAC role permission
23
+ # ("App Configuration Data Reader ") to read App Configuration store.
24
+
25
+ # Where the appcs_endpoint should come from?
26
+ #
27
+ # APP_CONFIG_ENDPOINT : environment variable set in every Azure Container App
28
+ # by Rubens' Terraform azure-iac project that contains the URI of the
29
+ # shared App Configuration store (e.g., https://appcs-rgomes-lab.azconfig.io)
30
+
31
+
32
+ async def list_settings(
33
+ appcs_endpoint: str, key_filter: str, label_filter: str
34
+ ) -> list[ConfigurationSetting]:
35
+ """Return the store's settings matching the key and label filters."""
36
+ # DefaultAzureCredential tries, in order:
37
+ # 1. Environment variables (e.g., AZURE_CLIENT_ID, AZURE_TENANT_ID)
38
+ # 2. Managed identity (e.g., the UAMI of an Azure Container App)
39
+ # 3. Your Azure CLI login
40
+ async with (
41
+ DefaultAzureCredential() as credential,
42
+ AzureAppConfigurationClient(appcs_endpoint, credential) as client,
43
+ ):
44
+ logger.debug(
45
+ "Listing App Configuration settings with key_filter: %s, "
46
+ "label_filter: %s",
47
+ key_filter,
48
+ label_filter,
49
+ )
50
+ settings = client.list_configuration_settings(
51
+ key_filter=key_filter, label_filter=label_filter
52
+ )
53
+ matching_settings: list[ConfigurationSetting] = []
54
+ async for setting in settings:
55
+ matching_settings.append(setting)
56
+ return matching_settings
57
+
58
+
59
+ if __name__ == "__main__":
60
+ # TESTING ONLY!!!
61
+ _APP_CONFIG_ENDPOINT = "https://appcs-rgomes-lab.azconfig.io"
62
+ _KEY_FILTER = "ai-utils-lib/*"
63
+ settings = asyncio.run(
64
+ list_settings(_APP_CONFIG_ENDPOINT, _KEY_FILTER, "lab")
65
+ )
66
+ for setting in settings:
67
+ print(f"{setting.key} = {setting.value}")
@@ -0,0 +1,106 @@
1
+ """Azure App Configuration based loader for AI Utilities."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import logging
6
+ from importlib.resources.abc import Traversable
7
+ from typing import Any
8
+
9
+ from azure.appconfiguration import ConfigurationSetting
10
+
11
+ from ai_utils_lib.config.azure.app_configuration import list_settings
12
+ from ai_utils_lib.config.file.config import read_yaml, resolve_config_path
13
+ from ai_utils_lib.config.models import AppConfig
14
+
15
+ __all__ = ["load_config"]
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @functools.cache
21
+ def load_config(
22
+ package: str, appcs_endpoint: str, label_filter: str
23
+ ) -> AppConfig:
24
+ """Load ``config.yaml`` from ``package`` overlaid with App Configuration.
25
+
26
+ Settings under ``azure.app_config_prefix`` and labeled ``label_filter``
27
+ are read from the store at ``appcs_endpoint`` and override the
28
+ matching YAML values.
29
+
30
+ Must not be called from a running event loop.
31
+ """
32
+ config_yaml_file: Traversable = resolve_config_path(package)
33
+ config_dict: dict[str, Any] = read_yaml(config_yaml_file)
34
+ prefix: str = config_dict["azure"]["app_config_prefix"]
35
+ separator: str = config_dict["azure"]["app_config_separator"]
36
+ settings_dict: dict[str, Any] = asyncio.run(
37
+ _convert_to_dict(appcs_endpoint, separator, prefix, label_filter)
38
+ )
39
+ combined_config_dict: dict[str, Any] = _deep_merge(
40
+ config_dict, settings_dict
41
+ )
42
+ return AppConfig.model_validate(combined_config_dict)
43
+
44
+
45
+ async def _convert_to_dict(
46
+ appcs_endpoint: str, separator: str, prefix: str, label_filter: str
47
+ ) -> dict[str, Any]:
48
+ """Return the settings under ``prefix`` as a nested dictionary.
49
+
50
+ The prefix is removed and the remaining key is split on ``/``, e.g.
51
+ ``prefix/model/name = x`` becomes ``{"model": {"name": "x"}}``.
52
+ """
53
+ key_filter: str = f"{prefix}*"
54
+ settings: list[ConfigurationSetting] = await list_settings(
55
+ appcs_endpoint, key_filter, label_filter
56
+ )
57
+ logger.debug(
58
+ "Read %d App Configuration settings with key_filter: %s",
59
+ len(settings),
60
+ key_filter,
61
+ )
62
+ response: dict[str, Any] = {}
63
+ for setting in settings:
64
+ key_path: str = setting.key.removeprefix(prefix)
65
+ _set_nested(response, key_path.split(separator), setting.value)
66
+ return response
67
+
68
+
69
+ def _set_nested(target: dict[str, Any], keys: list[str], value: Any) -> None:
70
+ """Set ``value`` in ``target`` at the nested path given by ``keys``."""
71
+ *parents, leaf = keys
72
+ node: dict[str, Any] = target
73
+ for key in parents:
74
+ child = node.setdefault(key, {})
75
+ if not isinstance(child, dict):
76
+ raise ValueError(
77
+ f"Setting path {keys} conflicts with existing value at "
78
+ f"'{key}'"
79
+ )
80
+ node = child
81
+ if isinstance(node.get(leaf), dict):
82
+ raise ValueError(f"Setting path {keys} conflicts with a section")
83
+ node[leaf] = value
84
+
85
+
86
+ def _deep_merge(
87
+ base: dict[str, Any], override: dict[str, Any]
88
+ ) -> dict[str, Any]:
89
+ """Return ``base`` recursively updated with ``override`` values."""
90
+ merged: dict[str, Any] = dict(base)
91
+ for key, value in override.items():
92
+ base_value = merged.get(key)
93
+ if isinstance(base_value, dict) and isinstance(value, dict):
94
+ merged[key] = _deep_merge(base_value, value)
95
+ else:
96
+ merged[key] = value
97
+ return merged
98
+
99
+
100
+ if __name__ == "__main__":
101
+ # TESTING ONLY!!!
102
+ _APP_CONFIG_ENDPOINT = "https://appcs-rgomes-lab.azconfig.io"
103
+ config: AppConfig = load_config(
104
+ "ai_utils_lib.config", _APP_CONFIG_ENDPOINT, "lab"
105
+ )
106
+ print(f"AppConfig = {config}")
@@ -0,0 +1,47 @@
1
+ """Microsoft Entra ID token provider for AI Utilities."""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from azure.core.credentials_async import AsyncTokenCredential
7
+ from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider
8
+
9
+ __all__ = ["token_provider"]
10
+
11
+ COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
12
+
13
+
14
+ # Entra ID tokens expire, so a fixed token passed as an API key stops
15
+ # working. Clients such as openai.AsyncOpenAI also accept a
16
+ # Callable[[], Awaitable[str]] and call it whenever they need a key;
17
+ # the provider returns a cached token and renews it before it expires.
18
+ # This is the reason for the token_provider function below.
19
+
20
+
21
+ def token_provider(
22
+ credential: AsyncTokenCredential,
23
+ ) -> Callable[[], Awaitable[str]]:
24
+ """Return an async callable yielding a cached, auto-renewed token.
25
+
26
+ ``credential`` must stay open while the returned callable is in use.
27
+ """
28
+ return get_bearer_token_provider(credential, COGNITIVE_SERVICES_SCOPE)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ # TESTING ONLY!!!
33
+ _VISIBLE_CHARS = 4
34
+
35
+ async def _main() -> None:
36
+ async with DefaultAzureCredential() as credential:
37
+ provider = token_provider(credential)
38
+ token = await provider()
39
+ print(
40
+ f"Token = {token[:_VISIBLE_CHARS]}…{token[-_VISIBLE_CHARS:]}"
41
+ f" ({len(token)} characters)"
42
+ )
43
+ print(
44
+ f"Second call reuses cached token: {await provider() == token}"
45
+ )
46
+
47
+ asyncio.run(_main())
@@ -0,0 +1,61 @@
1
+ """Azure Key Vault reader for AI Utilities."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from azure.identity.aio import DefaultAzureCredential
7
+ from azure.keyvault.secrets.aio import SecretClient
8
+
9
+ __all__ = ["read_secret"]
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # In Azure Container Apps, `vault_url` is usually the KEY_VAULT_URI
14
+ # environment variable, and the app's managed identity needs the
15
+ # "Key Vault Secrets User" role.
16
+
17
+ # How do Azure Container Apps get permission to read the Key Vault secret?
18
+ #
19
+ # All Azure Container Apps provisioned by Rubens' Terraform azure-iac
20
+ # project is assigned a shared UAMI (User-Assigned Managed Identiry),
21
+ # also provisioned by the same project. This UAMI is granted a RBAC role
22
+ # permission ("Key Vault Secrets User") to read Key Vault secrets.
23
+
24
+ # Where the vaulr_url should come from?
25
+ #
26
+ # KEY_VAULT_URI : environment variable set in every Azure Container App
27
+ # by Rubens' Terraform azure-iac project that contains the URI of the
28
+ # shared Key Vault store (e.g., https://kv-rgomes-lab.vault.azure.net/)
29
+
30
+
31
+ async def read_secret(vault_url: str, secret_name: str) -> str:
32
+ """Return the non-empty value of secret ``secret_name`` in ``vault_url``."""
33
+ # DefaultAzureCredential tries, in order:
34
+ # 1. Environment variables (e.g., AZURE_CLIENT_ID, AZURE_TENANT_ID)
35
+ # 2. Managed identity (e.g., the UAMI of an Azure Container App)
36
+ # 3. Your Azure CLI login
37
+ async with (
38
+ DefaultAzureCredential() as credential,
39
+ SecretClient(vault_url=vault_url, credential=credential) as client,
40
+ ):
41
+ logger.debug(
42
+ "Reading secret: %s from Key Vault: %s", secret_name, vault_url
43
+ )
44
+ secret = await client.get_secret(secret_name)
45
+ if not secret.value:
46
+ raise ValueError(
47
+ f"Key Vault secret {secret_name} in {vault_url} is empty."
48
+ )
49
+ return secret.value
50
+
51
+
52
+ if __name__ == "__main__":
53
+ # TESTING ONLY!!!
54
+ _KEY_VAULT_URI = "https://kv-rgomes-lab.vault.azure.net/"
55
+ _SECRET_NAME = "ca-mathagent--FOUNDRY-API-KEY"
56
+ _VISIBLE_CHARS = 4
57
+ secret_value = asyncio.run(read_secret(_KEY_VAULT_URI, _SECRET_NAME))
58
+ print(
59
+ f"{_SECRET_NAME} = {secret_value[:_VISIBLE_CHARS]}…"
60
+ f"{secret_value[-_VISIBLE_CHARS:]} ({len(secret_value)} characters)"
61
+ )
@@ -0,0 +1,49 @@
1
+ """Generic configuration loader for AI Utilities."""
2
+
3
+ import os
4
+
5
+ from ai_utils_lib.config.azure.config import load_config as load_azure_config
6
+ from ai_utils_lib.config.file.config import load_config as load_file_config
7
+ from ai_utils_lib.config.models import AppConfig
8
+
9
+ __all__ = ["load_config"]
10
+
11
+
12
+ def load_config(package: str) -> AppConfig:
13
+ """Load the ``config.yaml`` of ``package`` for the current runtime.
14
+
15
+ When ``APP_CONFIG_ENDPOINT`` is set, the YAML is overlaid with Azure App
16
+ Configuration settings labeled ``APP_ENV``; otherwise only the YAML is
17
+ loaded.
18
+
19
+ Raises:
20
+ KeyError: ``APP_CONFIG_ENDPOINT`` is set but ``APP_ENV`` is not.
21
+ """
22
+ # APP_CONFIG_ENDPOINT is an environment variable set in every ACA by
23
+ # Rubens' Terraform azure-iac project.
24
+ aca_endpoint: str | None = os.environ.get("APP_CONFIG_ENDPOINT")
25
+
26
+ if aca_endpoint:
27
+ # application is running in Azure Container Apps
28
+ # APP_ENV is an environment variable set in every ACA by
29
+ # Rubens' Terraform azure-iac project.
30
+ label: str = os.environ["APP_ENV"] # KeyError if not defined
31
+ return load_azure_config(package, aca_endpoint, label)
32
+
33
+ # application is running locally
34
+ return load_file_config(package)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ # TESTING ONLY!!!
39
+ _PACKAGE = "ai_utils_lib.config"
40
+ # load configuration settings from Azure App Configuration
41
+ os.environ["APP_CONFIG_ENDPOINT"] = "https://appcs-rgomes-lab.azconfig.io"
42
+ os.environ["APP_ENV"] = "lab"
43
+ config: AppConfig = load_config(_PACKAGE)
44
+ print(f"Azure AppConfig = {config}")
45
+
46
+ # load configuration settings from local config.yaml
47
+ os.environ.pop("APP_CONFIG_ENDPOINT", None)
48
+ config = load_config(_PACKAGE)
49
+ print(f"Local AppConfig = {config}")
@@ -0,0 +1,103 @@
1
+ # NOTE: THIS IS A TEMPLATE DUMMY CONFIGURATION FILE. YOU MUST HAVE YOUR
2
+ # OWN CONFIGURATION FILE SAVED IN YOUR APPLICATION ROOT PACKAGE FOLDER.
3
+
4
+ # ====================================================================
5
+ # Microsoft Azure Configuration
6
+ # ====================================================================
7
+ azure:
8
+ # App Configuration prefix
9
+ app_config_prefix: "ai-utils-lib/"
10
+ # app configuration prefix separator
11
+ app_config_separator: "/"
12
+ # Key Vault prefix
13
+ secret_prefix: "dummy-kv-prefix--"
14
+
15
+ # ====================================================================
16
+ # Microsoft Foundry Configuration
17
+ # ====================================================================
18
+ foundry:
19
+ project_endpoint: "https://dummyurl.change.it/foundry"
20
+
21
+ # ====================================================================
22
+ # MCP Configuration
23
+ # ====================================================================
24
+ mcp:
25
+ calculator:
26
+ #url: "http://127.0.0.1:8080/mcp"
27
+ url: "https://dummyurl.change.it/mcp"
28
+ #is_oauth: false
29
+ is_oauth: true
30
+ # location to store OAuth token in the local machine
31
+ token_dir: "~/.calculator_mcp"
32
+ # local port temporarily used by the OAuth callback
33
+ callback_port: 10000
34
+
35
+ # ====================================================================
36
+ # SLM or LLM Model Configuration
37
+ # ====================================================================
38
+ model:
39
+ # OpenAI API style:
40
+ # "responses" -> POST /v1/responses (newer and preferred)
41
+ # "chat" -> POST /v1/chat/completions (legacy)
42
+ api_style: "responses"
43
+ base_url: "https://dummyurl.change.it/v1"
44
+ name: "dummy-model-name"
45
+ # environment variable holding the API key.
46
+ api_key_env: "DUMMY_API_KEY"
47
+ # system instructions being provided to the model.
48
+ instructions: >-
49
+ Dummy system instructions for the model. Change it to your own instructions.
50
+ # Requires both Responses API and model support. It stores conversaiont history on the model server end, and send only new items each turn via
51
+ # previous_response_id.
52
+ is_stateful: false
53
+ # Concurrent prompts per second processed at once
54
+ max_concurrent_prompts: 10
55
+ # sampling temperature between 0 and 2.
56
+ temperature: 1.0
57
+ # Seconds to wait for each model response before failing.
58
+ timeout_seconds: 30
59
+
60
+ # ====================================================================
61
+ # Web App Configuration (e.g., used by FastAPI web app server)
62
+ # ====================================================================
63
+ web:
64
+ host: "0.0.0.0"
65
+ # ensure below port does not conflict with other services.
66
+ port: 9090
67
+
68
+ # ====================================================================
69
+ # Logging Configuration
70
+ # ====================================================================
71
+ logging:
72
+ version: 1
73
+ disable_existing_loggers: false
74
+ formatters:
75
+ # Colors level name and message when logs are being directly
76
+ # sent to computer TTY console. For plain (no colors) when
77
+ # logs are being sent over the network, set the NO_COLOR
78
+ # environment variable as NO_COLOR=true.
79
+ standard:
80
+ (): colorlog.ColoredFormatter
81
+ fmt: "%(asctime)s %(log_color)s[%(levelname)s]%(reset)s %(fg_147)s%(name)s %(filename)s:%(lineno)d%(reset)s: %(log_color)s%(message)s%(reset)s"
82
+ datefmt: "%H:%M:%S"
83
+ log_colors:
84
+ DEBUG: cyan
85
+ INFO: light_green
86
+ WARNING: yellow
87
+ ERROR: red
88
+ CRITICAL: bold_red
89
+ stream: ext://sys.stderr
90
+ handlers:
91
+ console:
92
+ class: logging.StreamHandler
93
+ formatter: standard
94
+ stream: ext://sys.stderr
95
+ loggers:
96
+ ##############################################################
97
+ # ---------- >>> ai_utils_lib <<< ---------------------------#
98
+ ##############################################################
99
+ ai_utils_lib:
100
+ level: DEBUG
101
+ handlers:
102
+ - console
103
+ propagate: false
@@ -0,0 +1 @@
1
+ """File-based configuration loader for AI Utilities."""
@@ -0,0 +1,52 @@
1
+ """File-based configuration loader for AI Utilities."""
2
+
3
+ import functools
4
+ import logging.config
5
+ from importlib.resources import files
6
+ from importlib.resources.abc import Traversable
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from ai_utils_lib.config.models import AppConfig
12
+ from ai_utils_lib.constants import CONFIG_FILE_NAME
13
+
14
+ __all__ = [
15
+ "load_config",
16
+ "configure_logging",
17
+ "read_yaml",
18
+ "resolve_config_path",
19
+ ]
20
+
21
+
22
+ @functools.cache
23
+ def load_config(package: str) -> AppConfig:
24
+ """Load and validate ``config.yaml`` from the root of ``package``.
25
+
26
+ Applications typically pass their root package name, e.g.
27
+ ``load_config(__package__)`` or ``load_config("my_app")``.
28
+ """
29
+ config_yaml_file: Traversable = resolve_config_path(package)
30
+ config_dict: dict[str, Any] = read_yaml(config_yaml_file)
31
+ return AppConfig.model_validate(config_dict)
32
+
33
+
34
+ def configure_logging(config: AppConfig) -> None:
35
+ """Apply the ``logging`` section of ``config`` via ``dictConfig``."""
36
+ logging.config.dictConfig(config.logging)
37
+
38
+
39
+ def resolve_config_path(package: str) -> Traversable:
40
+ """Return ``config.yaml`` located at the root ``package``."""
41
+ return files(package).joinpath(CONFIG_FILE_NAME)
42
+
43
+
44
+ def read_yaml(resource: Traversable) -> dict[str, Any]:
45
+ """Return the YAML mapping in ``resource``."""
46
+ try:
47
+ content = yaml.safe_load(resource.read_text(encoding="utf-8"))
48
+ except yaml.YAMLError as error:
49
+ raise ValueError(f"Invalid YAML in {resource}") from error
50
+ if not isinstance(content, dict):
51
+ raise ValueError(f"Expected a YAML mapping in {resource}")
52
+ return content
@@ -0,0 +1,73 @@
1
+ """Pydantic models for the config.yaml sections."""
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, Field, HttpUrl
6
+
7
+ from ai_utils_lib.constants import (
8
+ DEFAULT_MAX_CONCURRENT_PROMPTS,
9
+ DEFAULT_MODEL_TIMEOUT_SECONDS,
10
+ DEFAULT_OAUTH_CALLBACK_PORT,
11
+ DEFAULT_WEBAPP_PORT,
12
+ )
13
+
14
+ __all__ = ["Azure", "Foundry", "MCP", "Model", "Web", "AppConfig"]
15
+
16
+
17
+ class Azure(BaseModel):
18
+ """The ``azure`` section of config.yaml."""
19
+
20
+ secret_prefix: str
21
+
22
+
23
+ class Foundry(BaseModel):
24
+ """The ``foundry`` section of config.yaml."""
25
+
26
+ project_endpoint: HttpUrl
27
+
28
+
29
+ class MCP(BaseModel):
30
+ """A named MCP server entry in the ``mcp`` section of config.yaml."""
31
+
32
+ url: HttpUrl
33
+ is_oauth: bool = False
34
+ token_dir: str
35
+ callback_port: int = Field(default=DEFAULT_OAUTH_CALLBACK_PORT, gt=0)
36
+
37
+
38
+ class Model(BaseModel):
39
+ """The ``model`` section of config.yaml."""
40
+
41
+ api_style: Literal["chat", "responses"] = "responses"
42
+ base_url: HttpUrl
43
+ name: str = Field(min_length=1)
44
+ # `api_key_env` may be None if we are using Azure Entra ID credential
45
+ api_key_env: str | None = Field(default=None, min_length=1)
46
+ instructions: str
47
+ is_stateful: bool = False
48
+ max_concurrent_prompts: int = Field(
49
+ default=DEFAULT_MAX_CONCURRENT_PROMPTS, gt=0
50
+ )
51
+ timeout_seconds: int = Field(default=DEFAULT_MODEL_TIMEOUT_SECONDS, gt=0)
52
+ temperature: float | None = Field(default=None, ge=0, le=2)
53
+ # `api_key` if used is read at initializetion time from either a Key
54
+ # Vault or the environment variable defined by `api_key_env`.
55
+ api_key: str | None = Field(default=None, exclude=True, repr=False)
56
+
57
+
58
+ class Web(BaseModel):
59
+ """The ``web`` section of config.yaml."""
60
+
61
+ host: str = "localhost"
62
+ port: int = Field(default=DEFAULT_WEBAPP_PORT, gt=0)
63
+
64
+
65
+ class AppConfig(BaseModel):
66
+ """The full config.yaml."""
67
+
68
+ azure: Azure
69
+ foundry: Foundry
70
+ mcp: dict[str, MCP]
71
+ model: Model
72
+ web: Web
73
+ logging: dict[str, Any]
@@ -0,0 +1,16 @@
1
+ """Constants for the AI Utilities config package."""
2
+
3
+ __all__ = [
4
+ "CONFIG_FILE_NAME",
5
+ "DEFAULT_MAX_CONCURRENT_PROMPTS",
6
+ "DEFAULT_MODEL_TIMEOUT_SECONDS",
7
+ "DEFAULT_OAUTH_CALLBACK_PORT",
8
+ "DEFAULT_WEBAPP_PORT",
9
+ ]
10
+
11
+ CONFIG_FILE_NAME = "config.yaml"
12
+
13
+ DEFAULT_MAX_CONCURRENT_PROMPTS: int = 10
14
+ DEFAULT_MODEL_TIMEOUT_SECONDS: int = 30
15
+ DEFAULT_OAUTH_CALLBACK_PORT: int = 10000
16
+ DEFAULT_WEBAPP_PORT: int = 8080