From 9ad26d99c4fd14fdb2be5cee57c50506eab10d60 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 12 Aug 2026 17:18:33 +0300 Subject: [PATCH 01/10] feat(core): support AICORE_SERVICE_KEY env var for credentials --- packages/core/ai_core_sdk/credentials.py | 39 +++++++++- .../core/ai_core_sdk/helpers/constants.py | 1 + packages/core/pyproject.toml | 7 ++ .../tests/ai_core_client/test_credentials.py | 73 ++++++++++++++++++- 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index ca3874d..6499a4b 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -9,7 +9,7 @@ from ai_core_sdk.helpers import get_home from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, AUTH_ENDPOINT_SUFFIX, CONFIG_FILE_ENV_VAR, PROFILE_ENV_VAR, - VCAP_AICORE_SERVICE_NAME, VCAP_SERVICES_ENV_VAR) + SERVICE_KEY_ENV_VAR, VCAP_AICORE_SERVICE_NAME, VCAP_SERVICES_ENV_VAR) from ai_core_sdk.helpers.logging import get_logger logger = get_logger() @@ -241,6 +241,37 @@ def _str_or_none(value) -> Optional[str]: return str(value) if value else None +def _parse_service_key(credential_values: List[CredentialsValue]) -> Optional[Callable[[CredentialsValue], Optional[str]]]: + """Return a source getter for AICORE_SERVICE_KEY if the env var is set and valid JSON, else None. + + AICORE_SERVICE_KEY is expected to be the raw JSON string of a BTP service key, i.e. the + ``credentials`` object from a VCAP_SERVICES binding without the outer envelope. Credential + fields are extracted using the ``vcap_key`` paths already defined on each ``CredentialsValue``, + but with the leading ``'credentials'`` segment stripped (same as the CLI's load_service_key). + """ + raw = os.environ.get(SERVICE_KEY_ENV_VAR) + if not raw: + return None + try: + service_key = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError( + f"{SERVICE_KEY_ENV_VAR} is set but contains invalid JSON: {exc}" + ) from exc + + def _get(cv: CredentialsValue) -> Optional[str]: + if not cv.vcap_key: + return None + # vcap_key is e.g. ('credentials', 'clientid') — drop the 'credentials' prefix + key_path = cv.vcap_key[1:] + try: + return _str_or_none(get_nested_value(service_key, key_path)) + except KeyError: + return None + + return _get + + def fetch_credentials(profile: str = None, credential_values: List[CredentialsValue] = CORE_CREDENTIAL_VALUES, validate: bool = True, **kwargs) -> Dict[str, str]: """ @@ -261,11 +292,17 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa except KeyError: vcap_service = None + service_key_getter = _parse_service_key(credential_values) + sources = [ Source("kwargs", lambda cv: _str_or_none(kwargs.get(cv.name))), Source("environment variables", lambda cv: _str_or_none(os.environ.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), + *( + [Source(SERVICE_KEY_ENV_VAR, service_key_getter)] + if service_key_getter is not None else [] + ), Source("config file", lambda cv: _str_or_none(config.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), Source("VCAP service", diff --git a/packages/core/ai_core_sdk/helpers/constants.py b/packages/core/ai_core_sdk/helpers/constants.py index ffd29a3..d9ca9e8 100644 --- a/packages/core/ai_core_sdk/helpers/constants.py +++ b/packages/core/ai_core_sdk/helpers/constants.py @@ -8,6 +8,7 @@ DEFAULT_HOME_PATH = os.path.join(os.path.expanduser('~'), '.aicore') HOME_PATH_ENV_VAR = f'{AI_CORE_PREFIX}_HOME' PROFILE_ENV_VAR = f'{AI_CORE_PREFIX}_PROFILE' +SERVICE_KEY_ENV_VAR = f'{AI_CORE_PREFIX}_SERVICE_KEY' VCAP_AICORE_SERVICE_NAME = 'aicore' VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES' diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 968555e..9fd364e 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -37,10 +37,17 @@ dev = [ "pyhamcrest==2.1.0", "pytest-dotenv>=0.5.2", ] +docs = [ + "sphinx<9.0.0", + "sphinxawesome-theme", +] [tool.pytest.ini_options] testpaths = ["tests"] norecursedirs = ["integration_tests"] +# Prevent pytest-dotenv from loading the repo-root .env (which contains real credentials) +# into unit tests. Integration tests load it explicitly via conftest. +env_files = [] [project.scripts] aicore = "ai_core_sdk.cli:cli" diff --git a/packages/core/tests/ai_core_client/test_credentials.py b/packages/core/tests/ai_core_client/test_credentials.py index 26b779d..d803e38 100644 --- a/packages/core/tests/ai_core_client/test_credentials.py +++ b/packages/core/tests/ai_core_client/test_credentials.py @@ -15,7 +15,7 @@ init_conf, CORE_CREDENTIAL_VALUES, ) from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, HOME_PATH_ENV_VAR, PROFILE_ENV_VAR, VCAP_SERVICES_ENV_VAR, - VCAP_AICORE_SERVICE_NAME, CONFIG_FILE_ENV_VAR) + VCAP_AICORE_SERVICE_NAME, CONFIG_FILE_ENV_VAR, SERVICE_KEY_ENV_VAR) VCAP_SERVICE_DICT = { VCAP_AICORE_SERVICE_NAME: [{ @@ -290,6 +290,77 @@ def test_init_conf_permission_error(self, mock_logger): # Restore permissions for cleanup in teardown config_file.chmod(0o644) + @patch('ai_core_sdk.credentials.logger') + def test_fetch_credentials_from_service_key(self, mock_logger): + mock_logger.debug = MagicMock() + + service_key = { + 'clientid': 'sk-client-id', + 'clientsecret': 'sk-client-secret', + 'url': 'https://sk-auth-url', + 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, + } + with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: json.dumps(service_key)}): + credentials = fetch_credentials() + + self.assertEqual(credentials['client_id'], 'sk-client-id') + self.assertEqual(credentials['client_secret'], 'sk-client-secret') + self.assertEqual(credentials['auth_url'], 'https://sk-auth-url/oauth/token') + self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') + mock_logger.debug.assert_any_call(f"Using credentials from: {SERVICE_KEY_ENV_VAR}") + + @patch('ai_core_sdk.credentials.logger') + def test_fetch_credentials_from_service_key_x509(self, mock_logger): + mock_logger.debug = MagicMock() + + service_key = { + 'clientid': 'sk-client-id', + 'certurl': 'https://sk-cert-url', + 'certificate': 'sk-cert-content', + 'key': 'sk-key-content', + 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, + } + with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: json.dumps(service_key)}): + credentials = fetch_credentials() + + self.assertEqual(credentials['client_id'], 'sk-client-id') + self.assertEqual(credentials['cert_str'], 'sk-cert-content') + self.assertEqual(credentials['key_str'], 'sk-key-content') + self.assertEqual(credentials['auth_url'], 'https://sk-cert-url/oauth/token') + self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') + mock_logger.debug.assert_any_call(f"Using credentials from: {SERVICE_KEY_ENV_VAR}") + + @patch('ai_core_sdk.credentials.logger') + def test_service_key_lower_precedence_than_env_vars(self, mock_logger): + mock_logger.debug = MagicMock() + + service_key = { + 'clientid': 'sk-client-id', + 'clientsecret': 'sk-client-secret', + 'url': 'https://sk-auth-url', + 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, + } + with patch.dict(os.environ, { + SERVICE_KEY_ENV_VAR: json.dumps(service_key), + f'{AI_CORE_PREFIX}_CLIENT_ID': 'env-client-id', + f'{AI_CORE_PREFIX}_CLIENT_SECRET': 'env-client-secret', + f'{AI_CORE_PREFIX}_AUTH_URL': 'https://env-auth-url', + f'{AI_CORE_PREFIX}_BASE_URL': 'https://env-base-url', + }): + credentials = fetch_credentials() + + # env vars win + self.assertEqual(credentials['client_id'], 'env-client-id') + self.assertEqual(credentials['client_secret'], 'env-client-secret') + mock_logger.debug.assert_any_call("Using credentials from: environment variables") + + def test_service_key_invalid_json_raises(self): + with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: 'not-valid-json'}): + with self.assertRaises(ValueError) as ctx: + fetch_credentials() + self.assertIn(SERVICE_KEY_ENV_VAR, str(ctx.exception)) + self.assertIn('invalid JSON', str(ctx.exception)) + @patch('ai_core_sdk.credentials.logger') def test_injecting_credential_values(self, mock_logger): mock_logger.debug = MagicMock() From 14db48acb2fdb23c842665e2a7f721d1c3189f91 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 12 Aug 2026 18:13:05 +0300 Subject: [PATCH 02/10] fix: uv lock updated --- uv.lock | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/uv.lock b/uv.lock index a0d4be4..af1934a 100644 --- a/uv.lock +++ b/uv.lock @@ -4154,6 +4154,11 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-dotenv" }, ] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinxawesome-theme" }, +] [package.metadata] requires-dist = [ @@ -4169,6 +4174,10 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, ] +docs = [ + { name = "sphinx", specifier = "<9.0.0" }, + { name = "sphinxawesome-theme" }, +] [[package]] name = "sap-ai-sdk-gen" From 19916edf0fab9a7810736911113b5b51fa7a6c6c Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:14:00 +0000 Subject: [PATCH 03/10] docs: update pydoc3 documentation [skip ci] --- .../docs/ai_core_sdk.helpers.constants.html | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 packages/core/docs/ai_core_sdk.helpers.constants.html diff --git a/packages/core/docs/ai_core_sdk.helpers.constants.html b/packages/core/docs/ai_core_sdk.helpers.constants.html new file mode 100644 index 0000000..3c4437e --- /dev/null +++ b/packages/core/docs/ai_core_sdk.helpers.constants.html @@ -0,0 +1,102 @@ + + + + +Python: module ai_core_sdk.helpers.constants + + + + + +
 
ai_core_sdk.helpers.constants
index
/home/runner/work/ai-sdk-python/ai-sdk-python/packages/core/ai_core_sdk/helpers/constants.py
+

+

+ + + + + +
 
Modules
       
os
+

+ + + + + +
 
Classes
       
+
enum.Enum(builtins.object) +
+
+
Timeouts +
+
+
+

+ + + + + + + +
 
class Timeouts(enum.Enum)
   Timeouts(*values)

+
 
 
Method resolution order:
+
Timeouts
+
enum.Enum
+
builtins.object
+
+
+Data and other attributes defined here:
+
NUM_REQUEST_RETRIES = <Timeouts.NUM_REQUEST_RETRIES: 3>
+ +
READ_TIMEOUT = <Timeouts.READ_TIMEOUT: 60>
+ +
+Data descriptors inherited from enum.Enum:
+
name
+
The name of the Enum member.
+
+
value
+
The value of the Enum member.
+
+
+Static methods inherited from enum.EnumType:
+
__contains__(value)
Return True if `value` is in `cls`.

+`value` is in `cls` if:
+1) `value` is a member of `cls`, or
+2) `value` is the value of one of the `cls`'s members.
+3) `value` is a pseudo-member (flags)
+ +
__getitem__(name)
Return the member matching `name`.
+ +
__iter__()
Return members in definition order.
+ +
__len__()
Return the number of members (no aliases)
+ +
+Readonly properties inherited from enum.EnumType:
+
__members__
+
Returns a mapping of member name->value.

+This mapping lists all enum members, including aliases.  Note that
+this is a read-only view of the internal mapping.
+
+

+ + + + + +
 
Data
       AI_CORE_PREFIX = 'AICORE'
+AUTH_ENDPOINT_SUFFIX = '/oauth/token'
+CONFIG_FILE_ENV_VAR = 'AICORE_CONFIG'
+DEBUG_ENV_VAR_NAME = 'DEBUG'
+DEFAULT_HOME_PATH = '/home/runner/.aicore'
+HOME_PATH_ENV_VAR = 'AICORE_HOME'
+PROFILE_ENV_VAR = 'AICORE_PROFILE'
+SERVICE_KEY_ENV_VAR = 'AICORE_SERVICE_KEY'
+VCAP_AICORE_SERVICE_NAME = 'aicore'
+VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES'
+ \ No newline at end of file From 3e692b0240329aeea1b99f3cab17205d4309f3a4 Mon Sep 17 00:00:00 2001 From: Yamac Eren Ay <46201716+yamaceay@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:38:41 +0300 Subject: [PATCH 04/10] Update packages/core/ai_core_sdk/credentials.py Co-authored-by: Zhongpin Wang --- packages/core/ai_core_sdk/credentials.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index 6499a4b..be7bb43 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -262,7 +262,10 @@ def _parse_service_key(credential_values: List[CredentialsValue]) -> Optional[Ca def _get(cv: CredentialsValue) -> Optional[str]: if not cv.vcap_key: return None - # vcap_key is e.g. ('credentials', 'clientid') — drop the 'credentials' prefix + # `vcap_key` is a tuple representing the access path to properties such as + # `clientid` and `clientsecret` in the `aicore` service binding, e.g. + # `('credentials', 'clientid')`. Skip the leading path element `credentials` to + # access the nested value in the JSON object. key_path = cv.vcap_key[1:] try: return _str_or_none(get_nested_value(service_key, key_path)) From 3be9edfb07d18919ef3186a8613dd9e3c3432381 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 14 Aug 2026 09:50:49 +0300 Subject: [PATCH 05/10] change env var naming convention --- packages/core/ai_core_sdk/credentials.py | 16 ++++----- packages/core/ai_core_sdk/helpers/__init__.py | 4 +-- .../core/ai_core_sdk/helpers/constants.py | 10 +++--- .../docs/ai_core_sdk.helpers.constants.html | 10 +++--- .../core/integration_tests/test_e2e_x509.py | 8 ++--- .../ai_core_client/test_ai_core_v2_client.py | 6 ++-- .../core/tests/ai_core_client/test_cli.py | 6 ++-- .../tests/ai_core_client/test_credentials.py | 36 +++++++++---------- .../gen/gen_ai_hub/evaluations/constants.py | 8 ++--- .../gen/gen_ai_hub/evaluations/credentials.py | 16 ++++----- 10 files changed, 60 insertions(+), 60 deletions(-) diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index be7bb43..7bbacee 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -8,8 +8,8 @@ from dataclasses import dataclass from ai_core_sdk.helpers import get_home -from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, AUTH_ENDPOINT_SUFFIX, CONFIG_FILE_ENV_VAR, PROFILE_ENV_VAR, - SERVICE_KEY_ENV_VAR, VCAP_AICORE_SERVICE_NAME, VCAP_SERVICES_ENV_VAR) +from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, AUTH_ENDPOINT_SUFFIX, ENV_VAR_AICORE_CONFIG_FILE, ENV_VAR_AICORE_PROFILE, + ENV_VAR_AICORE_SERVICE_KEY, VCAP_AICORE_SERVICE_NAME, ENV_VAR_VCAP_SERVICES) from ai_core_sdk.helpers.logging import get_logger logger = get_logger() @@ -35,7 +35,7 @@ class VCAPEnvironment: @classmethod def from_env(cls, env_var: Optional[str] = None): - env_var = env_var or VCAP_SERVICES_ENV_VAR + env_var = env_var or ENV_VAR_VCAP_SERVICES env = json.loads(os.environ.get(env_var, '{}')) return cls.from_dict(env) @@ -148,9 +148,9 @@ class Source: def init_conf(profile: str = None): # Read configuration from ${AICORE_HOME}/config_.json. home = pathlib.Path(get_home()) - profile = profile or os.environ.get(PROFILE_ENV_VAR) + profile = profile or os.environ.get(ENV_VAR_AICORE_PROFILE) profile_config_file = f'config_{profile}.json' - direct_config_file = pathlib.Path(os.getenv(CONFIG_FILE_ENV_VAR)) if os.getenv(CONFIG_FILE_ENV_VAR) else None + direct_config_file = pathlib.Path(os.getenv(ENV_VAR_AICORE_CONFIG_FILE)) if os.getenv(ENV_VAR_AICORE_CONFIG_FILE) else None path_to_config = (direct_config_file or (home / ('config.json' if profile in ('default', '', None) else profile_config_file))) config = {} @@ -249,14 +249,14 @@ def _parse_service_key(credential_values: List[CredentialsValue]) -> Optional[Ca fields are extracted using the ``vcap_key`` paths already defined on each ``CredentialsValue``, but with the leading ``'credentials'`` segment stripped (same as the CLI's load_service_key). """ - raw = os.environ.get(SERVICE_KEY_ENV_VAR) + raw = os.environ.get(ENV_VAR_AICORE_SERVICE_KEY) if not raw: return None try: service_key = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError( - f"{SERVICE_KEY_ENV_VAR} is set but contains invalid JSON: {exc}" + f"{ENV_VAR_AICORE_SERVICE_KEY} is set but contains invalid JSON: {exc}" ) from exc def _get(cv: CredentialsValue) -> Optional[str]: @@ -303,7 +303,7 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa Source("environment variables", lambda cv: _str_or_none(os.environ.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), *( - [Source(SERVICE_KEY_ENV_VAR, service_key_getter)] + [Source(ENV_VAR_AICORE_SERVICE_KEY, service_key_getter)] if service_key_getter is not None else [] ), Source("config file", diff --git a/packages/core/ai_core_sdk/helpers/__init__.py b/packages/core/ai_core_sdk/helpers/__init__.py index 0b87ffa..91425b2 100644 --- a/packages/core/ai_core_sdk/helpers/__init__.py +++ b/packages/core/ai_core_sdk/helpers/__init__.py @@ -2,7 +2,7 @@ from typing import Dict from ai_api_client_sdk.helpers.authenticator import Authenticator -from .constants import DEFAULT_HOME_PATH, HOME_PATH_ENV_VAR +from .constants import DEFAULT_HOME_PATH, ENV_VAR_AICORE_HOME_PATH def form_top_skip_params(top: int = None, skip: int = None) -> Dict[str, int]: @@ -36,4 +36,4 @@ def is_within_aicore() -> bool: def get_home() -> str: - return os.environ.get(HOME_PATH_ENV_VAR, DEFAULT_HOME_PATH) + return os.environ.get(ENV_VAR_AICORE_HOME_PATH, DEFAULT_HOME_PATH) diff --git a/packages/core/ai_core_sdk/helpers/constants.py b/packages/core/ai_core_sdk/helpers/constants.py index d9ca9e8..5e8f5b6 100644 --- a/packages/core/ai_core_sdk/helpers/constants.py +++ b/packages/core/ai_core_sdk/helpers/constants.py @@ -3,14 +3,14 @@ AI_CORE_PREFIX = 'AICORE' AUTH_ENDPOINT_SUFFIX = '/oauth/token' -CONFIG_FILE_ENV_VAR = f'{AI_CORE_PREFIX}_CONFIG' +ENV_VAR_AICORE_CONFIG_FILE = f'{AI_CORE_PREFIX}_CONFIG' DEBUG_ENV_VAR_NAME = "DEBUG" DEFAULT_HOME_PATH = os.path.join(os.path.expanduser('~'), '.aicore') -HOME_PATH_ENV_VAR = f'{AI_CORE_PREFIX}_HOME' -PROFILE_ENV_VAR = f'{AI_CORE_PREFIX}_PROFILE' -SERVICE_KEY_ENV_VAR = f'{AI_CORE_PREFIX}_SERVICE_KEY' +ENV_VAR_AICORE_HOME_PATH = f'{AI_CORE_PREFIX}_HOME' +ENV_VAR_AICORE_PROFILE = f'{AI_CORE_PREFIX}_PROFILE' +ENV_VAR_AICORE_SERVICE_KEY = f'{AI_CORE_PREFIX}_SERVICE_KEY' VCAP_AICORE_SERVICE_NAME = 'aicore' -VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES' +ENV_VAR_VCAP_SERVICES = 'VCAP_SERVICES' class Timeouts(Enum): diff --git a/packages/core/docs/ai_core_sdk.helpers.constants.html b/packages/core/docs/ai_core_sdk.helpers.constants.html index 3c4437e..d027da9 100644 --- a/packages/core/docs/ai_core_sdk.helpers.constants.html +++ b/packages/core/docs/ai_core_sdk.helpers.constants.html @@ -91,12 +91,12 @@         AI_CORE_PREFIX = 'AICORE'
AUTH_ENDPOINT_SUFFIX = '/oauth/token'
-CONFIG_FILE_ENV_VAR = 'AICORE_CONFIG'
+ENV_VAR_AICORE_CONFIG_FILE = 'AICORE_CONFIG'
DEBUG_ENV_VAR_NAME = 'DEBUG'
DEFAULT_HOME_PATH = '/home/runner/.aicore'
-HOME_PATH_ENV_VAR = 'AICORE_HOME'
-PROFILE_ENV_VAR = 'AICORE_PROFILE'
-SERVICE_KEY_ENV_VAR = 'AICORE_SERVICE_KEY'
+ENV_VAR_AICORE_HOME_PATH = 'AICORE_HOME'
+ENV_VAR_AICORE_PROFILE = 'AICORE_PROFILE'
+ENV_VAR_AICORE_SERVICE_KEY = 'AICORE_SERVICE_KEY'
VCAP_AICORE_SERVICE_NAME = 'aicore'
-VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES' +ENV_VAR_VCAP_SERVICES = 'VCAP_SERVICES' \ No newline at end of file diff --git a/packages/core/integration_tests/test_e2e_x509.py b/packages/core/integration_tests/test_e2e_x509.py index 1c15d7c..16b000d 100644 --- a/packages/core/integration_tests/test_e2e_x509.py +++ b/packages/core/integration_tests/test_e2e_x509.py @@ -9,8 +9,8 @@ from . import write_x509_credentials_into_files, remove_x509_credentials from .ai_core_v2_client_e2e_test_base import AICoreV2ClientE2ETestBase from ai_core_sdk.ai_core_v2_client import AICoreV2Client -from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, HOME_PATH_ENV_VAR, VCAP_AICORE_SERVICE_NAME, - VCAP_SERVICES_ENV_VAR) +from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, ENV_VAR_AICORE_HOME_PATH, VCAP_AICORE_SERVICE_NAME, + ENV_VAR_VCAP_SERVICES) from ai_core_sdk.models import Scenario @@ -72,7 +72,7 @@ def _query_and_assert_scenarios(self, client: AICoreV2Client): self.assertIsNotNone(scenario.id) self.assertIsNotNone(scenario.name) - @patch.dict(os.environ, {VCAP_SERVICES_ENV_VAR: VCAP_SERVICE_X509_ENV_VALUE, 'AICORE_RESOURCE_GROUP': RESOURCE_GROUP_ID}) + @patch.dict(os.environ, {ENV_VAR_VCAP_SERVICES: VCAP_SERVICE_X509_ENV_VALUE, 'AICORE_RESOURCE_GROUP': RESOURCE_GROUP_ID}) def test_x509_from_vcap(self): client = AICoreV2Client.from_env() self._query_and_assert_scenarios(client) @@ -86,7 +86,7 @@ def test_x509_from_profile(self): json.dump({}, f) with open(profile_config_path, 'w') as f: json.dump(self.valid_x509_config, f) - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: str(temp_dir)}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: str(temp_dir)}): client = AICoreV2Client.from_env(profile_name=profile) self._query_and_assert_scenarios(client) diff --git a/packages/core/tests/ai_core_client/test_ai_core_v2_client.py b/packages/core/tests/ai_core_client/test_ai_core_v2_client.py index 6c49260..1f0e5e1 100644 --- a/packages/core/tests/ai_core_client/test_ai_core_v2_client.py +++ b/packages/core/tests/ai_core_client/test_ai_core_v2_client.py @@ -5,7 +5,7 @@ from unittest import TestCase from unittest.mock import MagicMock, patch from ai_core_sdk.ai_core_v2_client import AICoreV2Client -from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, HOME_PATH_ENV_VAR, VCAP_SERVICES_ENV_VAR, +from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, ENV_VAR_AICORE_HOME_PATH, ENV_VAR_VCAP_SERVICES, VCAP_AICORE_SERVICE_NAME) from ai_core_sdk.exception import AIAPIAuthenticatorException from ai_core_sdk.resource_clients.internal_rest_client import InternalRestClient @@ -192,7 +192,7 @@ def test_x509_file_path_from_config(self): config[k] = f'cfg_{v}' with tempfile.TemporaryDirectory() as temp_dir: - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: temp_dir}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: temp_dir}): config_file_path = os.path.join(temp_dir, 'config.json') with open(config_file_path, 'w') as f: json.dump(config, f) @@ -205,7 +205,7 @@ def test_x509_file_path_from_config(self): AICoreV2Client.__init__ = aicv2c_init - @patch.dict(os.environ, {VCAP_SERVICES_ENV_VAR: VCAP_SERVICE_X509_ENV_VALUE}) + @patch.dict(os.environ, {ENV_VAR_VCAP_SERVICES: VCAP_SERVICE_X509_ENV_VALUE}) def test_x509_from_vcap(self): vcap_dict_credentials = VCAP_SERVICE_X509_DICT[VCAP_AICORE_SERVICE_NAME][0]['credentials'] init_mock = MagicMock(return_value=None) diff --git a/packages/core/tests/ai_core_client/test_cli.py b/packages/core/tests/ai_core_client/test_cli.py index 5444d98..e9a6e3c 100644 --- a/packages/core/tests/ai_core_client/test_cli.py +++ b/packages/core/tests/ai_core_client/test_cli.py @@ -8,7 +8,7 @@ from ai_core_sdk.ai_core_v2_client import AICoreV2Client from ai_core_sdk.helpers import get_home -from ai_core_sdk.helpers.constants import HOME_PATH_ENV_VAR +from ai_core_sdk.helpers.constants import ENV_VAR_AICORE_HOME_PATH from click.testing import CliRunner @@ -30,7 +30,7 @@ class TestAICoreCLI(TestCase): def test_from_env(self): with tempfile.TemporaryDirectory() as temp_dir: - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: temp_dir}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: temp_dir}): from ai_core_sdk.cli import cli runner = CliRunner() temp_dir = pathlib.Path(temp_dir) @@ -52,7 +52,7 @@ def test_from_env(self): def test_from_input(self): with tempfile.TemporaryDirectory() as temp_dir: - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: temp_dir}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: temp_dir}): from ai_core_sdk.cli import cli runner = CliRunner() result = runner.invoke(cli, [f'configure', '-s', AICORE_DUMMY_KEY['clientsecret'], diff --git a/packages/core/tests/ai_core_client/test_credentials.py b/packages/core/tests/ai_core_client/test_credentials.py index d803e38..8915295 100644 --- a/packages/core/tests/ai_core_client/test_credentials.py +++ b/packages/core/tests/ai_core_client/test_credentials.py @@ -14,8 +14,8 @@ fetch_credentials, init_conf, CORE_CREDENTIAL_VALUES, ) -from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, HOME_PATH_ENV_VAR, PROFILE_ENV_VAR, VCAP_SERVICES_ENV_VAR, - VCAP_AICORE_SERVICE_NAME, CONFIG_FILE_ENV_VAR, SERVICE_KEY_ENV_VAR) +from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, ENV_VAR_AICORE_HOME_PATH, ENV_VAR_AICORE_PROFILE, ENV_VAR_VCAP_SERVICES, + VCAP_AICORE_SERVICE_NAME, ENV_VAR_AICORE_CONFIG_FILE, ENV_VAR_AICORE_SERVICE_KEY) VCAP_SERVICE_DICT = { VCAP_AICORE_SERVICE_NAME: [{ @@ -60,7 +60,7 @@ def setUpClass(cls): cls.vcap_dict = VCAP_SERVICE_DICT[VCAP_AICORE_SERVICE_NAME][0] def test_vcap_services(self): - with patch.dict(os.environ, {VCAP_SERVICES_ENV_VAR: VCAP_SERVICE_ENV_VALUE}): + with patch.dict(os.environ, {ENV_VAR_VCAP_SERVICES: VCAP_SERVICE_ENV_VALUE}): vcap_services = VCAPEnvironment.from_env() self.assertTrue(all(isinstance(srv, Service) for srv in vcap_services.services)) self.assertEqual(len(vcap_services.services), 1) @@ -141,26 +141,26 @@ def test_init_conf(self, mock_logger): conf = init_conf('MOCK_LLM') # load default config - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: str(self.temp_dir)}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: str(self.temp_dir)}): conf = init_conf() self.assertDictEqual(conf, self.default_config) mock_logger.debug.assert_called_with('Config file path %s', self.temp_dir / 'config.json') # load profile config - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: str(self.temp_dir), PROFILE_ENV_VAR: self.profile}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: str(self.temp_dir), ENV_VAR_AICORE_PROFILE: self.profile}): conf = init_conf() self.assertDictEqual(conf, self.profile_config) mock_logger.debug.assert_called_with('Config file path %s', self.temp_dir / self.file_name_profile) # load profile config with profile param - with patch.dict(os.environ, {HOME_PATH_ENV_VAR: str(self.temp_dir)}): + with patch.dict(os.environ, {ENV_VAR_AICORE_HOME_PATH: str(self.temp_dir)}): conf = init_conf(profile=self.profile) self.assertDictEqual(conf, self.profile_config) mock_logger.debug.assert_called_with('Config file path %s', self.temp_dir / self.file_name_profile) # load profile config via env variable with patch.dict(os.environ, {f'{AI_CORE_PREFIX}_PROFILE': self.profile, - HOME_PATH_ENV_VAR: str(self.temp_dir)}): + ENV_VAR_AICORE_HOME_PATH: str(self.temp_dir)}): conf = init_conf() self.assertDictEqual(conf, self.profile_config) # overwrite env variable with explicit profile @@ -168,7 +168,7 @@ def test_init_conf(self, mock_logger): self.assertDictEqual(conf, self.default_config) mock_logger.debug.assert_called_with('Config file path %s', self.temp_dir / 'config.json') - @patch.dict(os.environ, {VCAP_SERVICES_ENV_VAR: VCAP_SERVICE_ENV_VALUE}) + @patch.dict(os.environ, {ENV_VAR_VCAP_SERVICES: VCAP_SERVICE_ENV_VALUE}) @patch('ai_core_sdk.credentials.logger') def test_fetch_credentials_from_vcap_services(self, mock_logger): mock_logger.debug = MagicMock() @@ -183,7 +183,7 @@ def test_fetch_credentials_from_vcap_services(self, mock_logger): mock_logger.debug.assert_any_call("Using credentials from: VCAP service") mock_logger.debug.assert_any_call("No resource_group found in any source") - @patch.dict(os.environ, {VCAP_SERVICES_ENV_VAR: VCAP_SERVICE_X509_ENV_VALUE}) + @patch.dict(os.environ, {ENV_VAR_VCAP_SERVICES: VCAP_SERVICE_X509_ENV_VALUE}) @patch('ai_core_sdk.credentials.logger') def test_fetch_credentials_from_vcap_services_with_x509_env_var(self, mock_logger): mock_logger.debug = MagicMock() @@ -224,7 +224,7 @@ def test_fetch_credentials_from_env(self, mock_logger): @patch('ai_core_sdk.credentials.logger') def test_fetch_credentials_from_config_file(self, mock_logger): mock_logger.debug = MagicMock() - with patch.dict(os.environ, {CONFIG_FILE_ENV_VAR: str(self.temp_dir / 'config.json')}): + with patch.dict(os.environ, {ENV_VAR_AICORE_CONFIG_FILE: str(self.temp_dir / 'config.json')}): fetch_credentials() mock_logger.debug.assert_any_call("Using credentials from: config file") @@ -276,7 +276,7 @@ def test_init_conf_permission_error(self, mock_logger): config_file.chmod(0o000) try: - with patch.dict(os.environ, {CONFIG_FILE_ENV_VAR: str(config_file)}): + with patch.dict(os.environ, {ENV_VAR_AICORE_CONFIG_FILE: str(config_file)}): conf = init_conf() # Should return empty config when permission is denied self.assertDictEqual(conf, {}) @@ -300,14 +300,14 @@ def test_fetch_credentials_from_service_key(self, mock_logger): 'url': 'https://sk-auth-url', 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, } - with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: json.dumps(service_key)}): + with patch.dict(os.environ, {ENV_VAR_AICORE_SERVICE_KEY: json.dumps(service_key)}): credentials = fetch_credentials() self.assertEqual(credentials['client_id'], 'sk-client-id') self.assertEqual(credentials['client_secret'], 'sk-client-secret') self.assertEqual(credentials['auth_url'], 'https://sk-auth-url/oauth/token') self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') - mock_logger.debug.assert_any_call(f"Using credentials from: {SERVICE_KEY_ENV_VAR}") + mock_logger.debug.assert_any_call(f"Using credentials from: {ENV_VAR_AICORE_SERVICE_KEY}") @patch('ai_core_sdk.credentials.logger') def test_fetch_credentials_from_service_key_x509(self, mock_logger): @@ -320,7 +320,7 @@ def test_fetch_credentials_from_service_key_x509(self, mock_logger): 'key': 'sk-key-content', 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, } - with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: json.dumps(service_key)}): + with patch.dict(os.environ, {ENV_VAR_AICORE_SERVICE_KEY: json.dumps(service_key)}): credentials = fetch_credentials() self.assertEqual(credentials['client_id'], 'sk-client-id') @@ -328,7 +328,7 @@ def test_fetch_credentials_from_service_key_x509(self, mock_logger): self.assertEqual(credentials['key_str'], 'sk-key-content') self.assertEqual(credentials['auth_url'], 'https://sk-cert-url/oauth/token') self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') - mock_logger.debug.assert_any_call(f"Using credentials from: {SERVICE_KEY_ENV_VAR}") + mock_logger.debug.assert_any_call(f"Using credentials from: {ENV_VAR_AICORE_SERVICE_KEY}") @patch('ai_core_sdk.credentials.logger') def test_service_key_lower_precedence_than_env_vars(self, mock_logger): @@ -341,7 +341,7 @@ def test_service_key_lower_precedence_than_env_vars(self, mock_logger): 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, } with patch.dict(os.environ, { - SERVICE_KEY_ENV_VAR: json.dumps(service_key), + ENV_VAR_AICORE_SERVICE_KEY: json.dumps(service_key), f'{AI_CORE_PREFIX}_CLIENT_ID': 'env-client-id', f'{AI_CORE_PREFIX}_CLIENT_SECRET': 'env-client-secret', f'{AI_CORE_PREFIX}_AUTH_URL': 'https://env-auth-url', @@ -355,10 +355,10 @@ def test_service_key_lower_precedence_than_env_vars(self, mock_logger): mock_logger.debug.assert_any_call("Using credentials from: environment variables") def test_service_key_invalid_json_raises(self): - with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: 'not-valid-json'}): + with patch.dict(os.environ, {ENV_VAR_AICORE_SERVICE_KEY: 'not-valid-json'}): with self.assertRaises(ValueError) as ctx: fetch_credentials() - self.assertIn(SERVICE_KEY_ENV_VAR, str(ctx.exception)) + self.assertIn(ENV_VAR_AICORE_SERVICE_KEY, str(ctx.exception)) self.assertIn('invalid JSON', str(ctx.exception)) @patch('ai_core_sdk.credentials.logger') diff --git a/packages/gen/gen_ai_hub/evaluations/constants.py b/packages/gen/gen_ai_hub/evaluations/constants.py index 89f3903..9398eb9 100644 --- a/packages/gen/gen_ai_hub/evaluations/constants.py +++ b/packages/gen/gen_ai_hub/evaluations/constants.py @@ -199,13 +199,13 @@ AI_CORE_PREFIX = "AICORE" AUTH_ENDPOINT_SUFFIX = "/oauth/token" -CONFIG_FILE_ENV_VAR = f"{AI_CORE_PREFIX}_CONFIG" +ENV_VAR_AICORE_CONFIG_FILE = f"{AI_CORE_PREFIX}_CONFIG" DEBUG_ENV_VAR_NAME = "DEBUG" DEFAULT_HOME_PATH = os.path.join(os.path.expanduser("~"), ".aicore") -HOME_PATH_ENV_VAR = f"{AI_CORE_PREFIX}_HOME" -PROFILE_ENV_VAR = f"{AI_CORE_PREFIX}_PROFILE" +ENV_VAR_AICORE_HOME_PATH = f"{AI_CORE_PREFIX}_HOME" +ENV_VAR_AICORE_PROFILE = f"{AI_CORE_PREFIX}_PROFILE" VCAP_AICORE_SERVICE_NAME = "aicore" -VCAP_SERVICES_ENV_VAR = "VCAP_SERVICES" +ENV_VAR_VCAP_SERVICES = "VCAP_SERVICES" INPUT_SECRET_SETUP_KEY = "input_secret" DEFAULT_SECRET_SETUP_KEY = "default_secret" ORCHESTRATION_URL_SETUP_KEY = "orchestration_url" diff --git a/packages/gen/gen_ai_hub/evaluations/credentials.py b/packages/gen/gen_ai_hub/evaluations/credentials.py index 074afda..9c60a0d 100644 --- a/packages/gen/gen_ai_hub/evaluations/credentials.py +++ b/packages/gen/gen_ai_hub/evaluations/credentials.py @@ -10,11 +10,11 @@ from gen_ai_hub.evaluations.constants import ( AI_CORE_PREFIX, AUTH_ENDPOINT_SUFFIX, - CONFIG_FILE_ENV_VAR, - PROFILE_ENV_VAR, + ENV_VAR_AICORE_CONFIG_FILE, + ENV_VAR_AICORE_PROFILE, VCAP_AICORE_SERVICE_NAME, - VCAP_SERVICES_ENV_VAR, - HOME_PATH_ENV_VAR, + ENV_VAR_VCAP_SERVICES, + ENV_VAR_AICORE_HOME_PATH, DEFAULT_HOME_PATH, ) from gen_ai_hub.evaluations.helpers.logging import get_logger @@ -23,7 +23,7 @@ def get_home() -> str: - return os.environ.get(HOME_PATH_ENV_VAR, DEFAULT_HOME_PATH) + return os.environ.get(ENV_VAR_AICORE_HOME_PATH, DEFAULT_HOME_PATH) def get_nested_value(data_dict, keys: List[str]): @@ -46,7 +46,7 @@ class VCAPEnvironment: @classmethod def from_env(cls, env_var: Optional[str] = None): - env_var = env_var or VCAP_SERVICES_ENV_VAR + env_var = env_var or ENV_VAR_VCAP_SERVICES env = json.loads(os.environ.get(env_var, '{}')) return cls.from_dict(env) @@ -155,9 +155,9 @@ class Source: def init_conf(profile: str = None): # Read configuration from ${AICORE_HOME}/config_.json. home = pathlib.Path(get_home()) - profile = profile or os.environ.get(PROFILE_ENV_VAR) + profile = profile or os.environ.get(ENV_VAR_AICORE_PROFILE) profile_config_file = f'config_{profile}.json' - direct_config_file = pathlib.Path(os.getenv(CONFIG_FILE_ENV_VAR)) if os.getenv(CONFIG_FILE_ENV_VAR) else None + direct_config_file = pathlib.Path(os.getenv(ENV_VAR_AICORE_CONFIG_FILE)) if os.getenv(ENV_VAR_AICORE_CONFIG_FILE) else None path_to_config = (direct_config_file or (home / ('config.json' if profile in ('default', '', None) else profile_config_file))) config = {} From 41a890ff3d6dd80093e8f6809e6ff72ae8132e78 Mon Sep 17 00:00:00 2001 From: "sap-ai-sdk-bot[bot]" <272306433+sap-ai-sdk-bot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:51:17 +0000 Subject: [PATCH 06/10] docs: update pydoc3 documentation [skip ci] --- packages/core/docs/ai_core_sdk.helpers.constants.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/docs/ai_core_sdk.helpers.constants.html b/packages/core/docs/ai_core_sdk.helpers.constants.html index d027da9..76642bb 100644 --- a/packages/core/docs/ai_core_sdk.helpers.constants.html +++ b/packages/core/docs/ai_core_sdk.helpers.constants.html @@ -91,12 +91,12 @@         AI_CORE_PREFIX = 'AICORE'
AUTH_ENDPOINT_SUFFIX = '/oauth/token'
-ENV_VAR_AICORE_CONFIG_FILE = 'AICORE_CONFIG'
DEBUG_ENV_VAR_NAME = 'DEBUG'
DEFAULT_HOME_PATH = '/home/runner/.aicore'
+ENV_VAR_AICORE_CONFIG_FILE = 'AICORE_CONFIG'
ENV_VAR_AICORE_HOME_PATH = 'AICORE_HOME'
ENV_VAR_AICORE_PROFILE = 'AICORE_PROFILE'
ENV_VAR_AICORE_SERVICE_KEY = 'AICORE_SERVICE_KEY'
-VCAP_AICORE_SERVICE_NAME = 'aicore'
-ENV_VAR_VCAP_SERVICES = 'VCAP_SERVICES' +ENV_VAR_VCAP_SERVICES = 'VCAP_SERVICES'
+VCAP_AICORE_SERVICE_NAME = 'aicore' \ No newline at end of file From 20b293a3fb0ad1cb6aacc4f644c8a76770ebe749 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Fri, 14 Aug 2026 10:18:48 +0300 Subject: [PATCH 07/10] fix: cleanup unused code, make service key parsing inline, improve test --- packages/core/ai_core_sdk/credentials.py | 43 +++++++------------ packages/core/pyproject.toml | 4 -- .../tests/ai_core_client/test_credentials.py | 6 ++- uv.lock | 9 ---- 4 files changed, 20 insertions(+), 42 deletions(-) diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index 7bbacee..fe3c8bb 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -241,38 +241,29 @@ def _str_or_none(value) -> Optional[str]: return str(value) if value else None -def _parse_service_key(credential_values: List[CredentialsValue]) -> Optional[Callable[[CredentialsValue], Optional[str]]]: - """Return a source getter for AICORE_SERVICE_KEY if the env var is set and valid JSON, else None. +def _get_nested_safe(data: Dict, keys) -> Optional[Any]: + try: + return get_nested_value(data, keys) + except KeyError: + return None + + +def _load_service_key() -> Dict[str, Any]: + """Read and parse AICORE_SERVICE_KEY from the environment. - AICORE_SERVICE_KEY is expected to be the raw JSON string of a BTP service key, i.e. the - ``credentials`` object from a VCAP_SERVICES binding without the outer envelope. Credential - fields are extracted using the ``vcap_key`` paths already defined on each ``CredentialsValue``, - but with the leading ``'credentials'`` segment stripped (same as the CLI's load_service_key). + :return: Parsed service key dict, or an empty dict if the env var is not set. + :raises ValueError: If the env var is set but contains invalid JSON. """ raw = os.environ.get(ENV_VAR_AICORE_SERVICE_KEY) if not raw: - return None + return {} try: - service_key = json.loads(raw) + return json.loads(raw) except json.JSONDecodeError as exc: raise ValueError( f"{ENV_VAR_AICORE_SERVICE_KEY} is set but contains invalid JSON: {exc}" ) from exc - def _get(cv: CredentialsValue) -> Optional[str]: - if not cv.vcap_key: - return None - # `vcap_key` is a tuple representing the access path to properties such as - # `clientid` and `clientsecret` in the `aicore` service binding, e.g. - # `('credentials', 'clientid')`. Skip the leading path element `credentials` to - # access the nested value in the JSON object. - key_path = cv.vcap_key[1:] - try: - return _str_or_none(get_nested_value(service_key, key_path)) - except KeyError: - return None - - return _get def fetch_credentials(profile: str = None, credential_values: List[CredentialsValue] = CORE_CREDENTIAL_VALUES, @@ -295,17 +286,15 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa except KeyError: vcap_service = None - service_key_getter = _parse_service_key(credential_values) + service_key = _load_service_key() sources = [ Source("kwargs", lambda cv: _str_or_none(kwargs.get(cv.name))), Source("environment variables", lambda cv: _str_or_none(os.environ.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), - *( - [Source(ENV_VAR_AICORE_SERVICE_KEY, service_key_getter)] - if service_key_getter is not None else [] - ), + Source("service key", + lambda cv: _str_or_none(_get_nested_safe(service_key, cv.vcap_key[1:])) if cv.vcap_key else None), Source("config file", lambda cv: _str_or_none(config.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), Source("VCAP service", diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 9fd364e..41a6c91 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -37,10 +37,6 @@ dev = [ "pyhamcrest==2.1.0", "pytest-dotenv>=0.5.2", ] -docs = [ - "sphinx<9.0.0", - "sphinxawesome-theme", -] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/packages/core/tests/ai_core_client/test_credentials.py b/packages/core/tests/ai_core_client/test_credentials.py index 8915295..58e0f26 100644 --- a/packages/core/tests/ai_core_client/test_credentials.py +++ b/packages/core/tests/ai_core_client/test_credentials.py @@ -307,7 +307,7 @@ def test_fetch_credentials_from_service_key(self, mock_logger): self.assertEqual(credentials['client_secret'], 'sk-client-secret') self.assertEqual(credentials['auth_url'], 'https://sk-auth-url/oauth/token') self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') - mock_logger.debug.assert_any_call(f"Using credentials from: {ENV_VAR_AICORE_SERVICE_KEY}") + mock_logger.debug.assert_any_call("Using credentials from: service key") @patch('ai_core_sdk.credentials.logger') def test_fetch_credentials_from_service_key_x509(self, mock_logger): @@ -328,7 +328,7 @@ def test_fetch_credentials_from_service_key_x509(self, mock_logger): self.assertEqual(credentials['key_str'], 'sk-key-content') self.assertEqual(credentials['auth_url'], 'https://sk-cert-url/oauth/token') self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') - mock_logger.debug.assert_any_call(f"Using credentials from: {ENV_VAR_AICORE_SERVICE_KEY}") + mock_logger.debug.assert_any_call("Using credentials from: service key") @patch('ai_core_sdk.credentials.logger') def test_service_key_lower_precedence_than_env_vars(self, mock_logger): @@ -352,6 +352,8 @@ def test_service_key_lower_precedence_than_env_vars(self, mock_logger): # env vars win self.assertEqual(credentials['client_id'], 'env-client-id') self.assertEqual(credentials['client_secret'], 'env-client-secret') + self.assertEqual(credentials['auth_url'], 'https://env-auth-url/oauth/token') + self.assertEqual(credentials['base_url'], 'https://env-base-url/v2') mock_logger.debug.assert_any_call("Using credentials from: environment variables") def test_service_key_invalid_json_raises(self): diff --git a/uv.lock b/uv.lock index af1934a..a0d4be4 100644 --- a/uv.lock +++ b/uv.lock @@ -4154,11 +4154,6 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-dotenv" }, ] -docs = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinxawesome-theme" }, -] [package.metadata] requires-dist = [ @@ -4174,10 +4169,6 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, ] -docs = [ - { name = "sphinx", specifier = "<9.0.0" }, - { name = "sphinxawesome-theme" }, -] [[package]] name = "sap-ai-sdk-gen" From 9717d47a70d2f37e9f752bd70241900e4c0787b7 Mon Sep 17 00:00:00 2001 From: Yamac Eren Ay <46201716+yamaceay@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:14:46 +0300 Subject: [PATCH 08/10] Update packages/core/ai_core_sdk/credentials.py Co-authored-by: Zhongpin Wang --- packages/core/ai_core_sdk/credentials.py | 27 ++++++++++++------------ packages/core/pyproject.toml | 3 --- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index fe3c8bb..c6c6158 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -14,7 +14,6 @@ logger = get_logger() - def get_nested_value(data_dict, keys: List[str]): """ Retrieve a nested value from a dictionary using a list of strings. @@ -28,6 +27,12 @@ def get_nested_value(data_dict, keys: List[str]): current_value = current_value[key] return current_value +def _get_nested_value_safe(data: Dict, keys) -> Optional[Any]: + try: + return get_nested_value(data, keys) + except KeyError: + logger.debug("Key path %s not found in service key.", keys) + return None @dataclass class VCAPEnvironment: @@ -241,24 +246,17 @@ def _str_or_none(value) -> Optional[str]: return str(value) if value else None -def _get_nested_safe(data: Dict, keys) -> Optional[Any]: - try: - return get_nested_value(data, keys) - except KeyError: - return None - - def _load_service_key() -> Dict[str, Any]: """Read and parse AICORE_SERVICE_KEY from the environment. :return: Parsed service key dict, or an empty dict if the env var is not set. :raises ValueError: If the env var is set but contains invalid JSON. """ - raw = os.environ.get(ENV_VAR_AICORE_SERVICE_KEY) - if not raw: + service_key_json_string = os.environ.get(ENV_VAR_AICORE_SERVICE_KEY) + if not service_key_json_string: return {} try: - return json.loads(raw) + return json.loads(service_key_json_string) except json.JSONDecodeError as exc: raise ValueError( f"{ENV_VAR_AICORE_SERVICE_KEY} is set but contains invalid JSON: {exc}" @@ -271,7 +269,7 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa """ Fetch credentials from a single source based on precedence. - Precedence order: kwargs > environment variables > config file > VCAP service + Precedence order: kwargs > environment variables > service key > config file > VCAP service Once a source is selected (first one with any credential), all credentials come from that source only. Resource group is an exception and follows @@ -288,13 +286,16 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa service_key = _load_service_key() + # `cv.vcap_key` describes the full path inside a VCAP_SERVICES entry, starting with + # `credentials` (e.g. `('credentials', 'clientid')`) sources = [ Source("kwargs", lambda cv: _str_or_none(kwargs.get(cv.name))), Source("environment variables", lambda cv: _str_or_none(os.environ.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), + # A service key is already the inner credentials object, so the leading `credentials` segment is stripped. Source("service key", - lambda cv: _str_or_none(_get_nested_safe(service_key, cv.vcap_key[1:])) if cv.vcap_key else None), + lambda cv: _str_or_none(_get_nested_value_safe(service_key, cv.vcap_key[1:])) if cv.vcap_key else None), Source("config file", lambda cv: _str_or_none(config.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), Source("VCAP service", diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 41a6c91..968555e 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -41,9 +41,6 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] norecursedirs = ["integration_tests"] -# Prevent pytest-dotenv from loading the repo-root .env (which contains real credentials) -# into unit tests. Integration tests load it explicitly via conftest. -env_files = [] [project.scripts] aicore = "ai_core_sdk.cli:cli" From b51e30736e14a15b242e65aa5de0032677df40ab Mon Sep 17 00:00:00 2001 From: Yamac Eren Ay <46201716+yamaceay@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:05:28 +0300 Subject: [PATCH 09/10] Update packages/core/ai_core_sdk/credentials.py Co-authored-by: Zhongpin Wang --- packages/core/ai_core_sdk/credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index c6c6158..8e3730c 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -269,7 +269,7 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa """ Fetch credentials from a single source based on precedence. - Precedence order: kwargs > environment variables > service key > config file > VCAP service + Precedence order: kwargs > separate environment variables > service key > config file > VCAP service Once a source is selected (first one with any credential), all credentials come from that source only. Resource group is an exception and follows From 95a64f20e5b061d7c78d54dc049e31c1edee2ca9 Mon Sep 17 00:00:00 2001 From: Yamac Eren Ay <46201716+yamaceay@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:36:47 +0300 Subject: [PATCH 10/10] Delete packages/core/docs/ai_core_sdk.helpers.constants.html --- .../docs/ai_core_sdk.helpers.constants.html | 102 ------------------ 1 file changed, 102 deletions(-) delete mode 100644 packages/core/docs/ai_core_sdk.helpers.constants.html diff --git a/packages/core/docs/ai_core_sdk.helpers.constants.html b/packages/core/docs/ai_core_sdk.helpers.constants.html deleted file mode 100644 index 76642bb..0000000 --- a/packages/core/docs/ai_core_sdk.helpers.constants.html +++ /dev/null @@ -1,102 +0,0 @@ - - - - -Python: module ai_core_sdk.helpers.constants - - - - - -
 
ai_core_sdk.helpers.constants
index
/home/runner/work/ai-sdk-python/ai-sdk-python/packages/core/ai_core_sdk/helpers/constants.py
-

-

- - - - - -
 
Modules
       
os
-

- - - - - -
 
Classes
       
-
enum.Enum(builtins.object) -
-
-
Timeouts -
-
-
-

- - - - - - - -
 
class Timeouts(enum.Enum)
   Timeouts(*values)

-
 
 
Method resolution order:
-
Timeouts
-
enum.Enum
-
builtins.object
-
-
-Data and other attributes defined here:
-
NUM_REQUEST_RETRIES = <Timeouts.NUM_REQUEST_RETRIES: 3>
- -
READ_TIMEOUT = <Timeouts.READ_TIMEOUT: 60>
- -
-Data descriptors inherited from enum.Enum:
-
name
-
The name of the Enum member.
-
-
value
-
The value of the Enum member.
-
-
-Static methods inherited from enum.EnumType:
-
__contains__(value)
Return True if `value` is in `cls`.

-`value` is in `cls` if:
-1) `value` is a member of `cls`, or
-2) `value` is the value of one of the `cls`'s members.
-3) `value` is a pseudo-member (flags)
- -
__getitem__(name)
Return the member matching `name`.
- -
__iter__()
Return members in definition order.
- -
__len__()
Return the number of members (no aliases)
- -
-Readonly properties inherited from enum.EnumType:
-
__members__
-
Returns a mapping of member name->value.

-This mapping lists all enum members, including aliases.  Note that
-this is a read-only view of the internal mapping.
-
-

- - - - - -
 
Data
       AI_CORE_PREFIX = 'AICORE'
-AUTH_ENDPOINT_SUFFIX = '/oauth/token'
-DEBUG_ENV_VAR_NAME = 'DEBUG'
-DEFAULT_HOME_PATH = '/home/runner/.aicore'
-ENV_VAR_AICORE_CONFIG_FILE = 'AICORE_CONFIG'
-ENV_VAR_AICORE_HOME_PATH = 'AICORE_HOME'
-ENV_VAR_AICORE_PROFILE = 'AICORE_PROFILE'
-ENV_VAR_AICORE_SERVICE_KEY = 'AICORE_SERVICE_KEY'
-ENV_VAR_VCAP_SERVICES = 'VCAP_SERVICES'
-VCAP_AICORE_SERVICE_NAME = 'aicore'
- \ No newline at end of file