feat(sap): support multiple orchestration deployments via name filter

When a BTP tenant has more than one orchestration deployment, the previous
code silently picked the newest by creation date with no warning and no way
to override it. This led to the wrong deployment (e.g. one with Grounding
pre-configured) being used.

Three changes:
- Warn via verbose_logger when >1 valid deployment is found, naming the
  chosen deployment and listing the others.
- Honour SAP_ORCHESTRATION_DEPLOYMENT_NAME env var to filter by
  configuration name globally.
- Accept deployment_name in litellm_params for per-call selection;
  takes priority over the env var.

Also raises GenAIHubOrchestrationError with a clear message when no
deployment is found, or when the requested name matches nothing (listing
available names in the error).
This commit is contained in:
Yamac Ay 2026-06-25 10:56:01 +02:00
parent fa09cde3c0
commit 79b1dd7e0b
No known key found for this signature in database
GPG key ID: D113B438819CA628
2 changed files with 185 additions and 28 deletions

View file

@ -2,23 +2,25 @@
Translate from OpenAI's `/v1/chat/completions` to SAP Generative AI Hub's Orchestration Service`v2/completion`
"""
import os
from functools import cached_property
from typing import (
Any,
AsyncIterator,
Dict,
FrozenSet,
Iterator,
List,
Optional,
Union,
Dict,
Tuple,
Any,
TYPE_CHECKING,
Iterator,
AsyncIterator,
FrozenSet,
Union,
)
from functools import cached_property
import litellm
import httpx
import litellm
from litellm._logging import verbose_logger
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
@ -32,6 +34,11 @@ else:
LiteLLMLoggingObj = Any
from ..credentials import get_token_creator
from .handler import (
AsyncSAPStreamIterator,
GenAIHubOrchestrationError,
SAPStreamIterator,
)
from .models import (
ChatCompletionTool,
OrchestrationRequest,
@ -42,11 +49,6 @@ from .models import (
SAPToolChatMessage,
SAPUserMessage,
)
from .handler import (
GenAIHubOrchestrationError,
AsyncSAPStreamIterator,
SAPStreamIterator,
)
# Keys routed outside SAP orchestration `model.params` (prompt, stream, fallbacks, etc.)
_SAP_MODEL_PARAMS_EXCLUDED_KEYS: FrozenSet[str] = frozenset(
@ -177,20 +179,58 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
@cached_property
def deployment_url(self) -> str:
# Keep a short, tight client lifecycle here to avoid fd leaks
return self._resolve_deployment_url(deployment_name=None)
def _resolve_deployment_url(self, deployment_name: Optional[str]) -> str:
client = litellm.module_level_client
# with httpx.Client(timeout=30) as client:
deployments = client.get(f"{self.base_url}/lm/deployments", headers=self.headers).json()
valid: List[Tuple[str, str]] = []
deployments = client.get(
f"{self.base_url}/lm/deployments", headers=self.headers
).json()
valid: List[Tuple[str, str, str]] = []
for dep in deployments.get("resources", []):
if dep.get("scenarioId") == "orchestration":
cfg = client.get(
f"{self.base_url}/lm/configurations/{dep['configurationId']}",
headers=self.headers,
).json()
if cfg.get("executableId") == "orchestration":
valid.append((dep["deploymentUrl"], dep["createdAt"]))
# newest first
if dep.get("scenarioId") != "orchestration":
continue
cfg = client.get(
f'{self.base_url}/lm/configurations/{dep["configurationId"]}',
headers=self.headers,
).json()
if cfg.get("executableId") == "orchestration":
valid.append((dep["deploymentUrl"], dep["createdAt"], cfg.get("name", "")))
if not valid:
raise GenAIHubOrchestrationError(
status_code=404,
message="No orchestration deployment found in SAP AI Core.",
)
name_filter = deployment_name or os.environ.get("SAP_ORCHESTRATION_DEPLOYMENT_NAME")
if name_filter:
filtered = [v for v in valid if v[2] == name_filter]
if not filtered:
available = [v[2] for v in valid]
raise GenAIHubOrchestrationError(
status_code=404,
message=(
f"No orchestration deployment named {name_filter!r} found. "
f"Available: {available}"
),
)
valid = filtered
if len(valid) > 1:
sorted_valid = sorted(valid, key=lambda x: x[1], reverse=True)
chosen = sorted_valid[0]
others = [v[2] or v[0] for v in sorted_valid[1:]]
verbose_logger.warning(
f"SAP: found {len(valid)} orchestration deployments; using the newest one "
f"(name={chosen[2]!r}, url={chosen[0]!r}). "
f"Others ignored: {others}. "
"Set SAP_ORCHESTRATION_DEPLOYMENT_NAME to select a specific one, "
"or use the deployment_name per-call parameter."
)
return chosen[0]
return sorted(valid, key=lambda x: x[1], reverse=True)[0][0]
@classmethod
@ -258,8 +298,12 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
litellm_params: dict,
stream: Optional[bool] = None,
):
api_base_ = f"{self.deployment_url}/v2/completion"
return api_base_
deployment_name = litellm_params.get("deployment_name")
if deployment_name:
base = self._resolve_deployment_url(deployment_name=deployment_name)
else:
base = self.deployment_url
return f"{base}/v2/completion"
def _build_prompt_module(
self,

View file

@ -1,5 +1,6 @@
import warnings
import pytest
from unittest.mock import patch, MagicMock
from pydantic import ValidationError
@ -639,3 +640,115 @@ class TestSAPTransformationIntegration:
config["config"]["modules"][1]["translation"]["input"]["type"]
== "sap_document_translation"
)
def _make_config():
from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig
config = GenAIHubOrchestrationConfig()
config.token_creator = lambda: "Bearer TEST_TOKEN"
config._base_url = "https://api.test-sap.com"
config._resource_group = "test-group"
return config
def _mock_deployments(*names: str):
"""Return a mock httpx client whose GET responses simulate AI Core deployment/config APIs."""
deployments_payload = {
"resources": [
{"scenarioId": "orchestration", "configurationId": f"cfg-{n}", "deploymentUrl": f"https://deploy-{n}.sap.com", "createdAt": f"2024-01-0{i+1}T00:00:00Z"}
for i, n in enumerate(names)
]
}
configs = {
f"cfg-{n}": {"executableId": "orchestration", "name": n}
for n in names
}
def fake_get(url, headers=None):
resp = MagicMock()
if "/lm/deployments" in url:
resp.json.return_value = deployments_payload
else:
cfg_id = url.split("/")[-1]
resp.json.return_value = configs.get(cfg_id, {})
return resp
client = MagicMock()
client.get.side_effect = fake_get
return client
class TestDeploymentSelection:
def test_single_deployment_returns_its_url(self):
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("my-orch")):
url = config._resolve_deployment_url(deployment_name=None)
assert url == "https://deploy-my-orch.sap.com"
def test_multiple_deployments_picks_newest(self):
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("older", "newer")):
url = config._resolve_deployment_url(deployment_name=None)
assert url == "https://deploy-newer.sap.com"
def test_multiple_deployments_emits_warning(self):
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("older", "newer")):
with patch("litellm.llms.sap.chat.transformation.verbose_logger") as mock_log:
config._resolve_deployment_url(deployment_name=None)
mock_log.warning.assert_called_once()
msg = mock_log.warning.call_args[0][0]
assert "2 orchestration deployments" in msg
assert "SAP_ORCHESTRATION_DEPLOYMENT_NAME" in msg
def test_deployment_name_arg_selects_by_name(self):
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("grounding-orch", "plain-orch")):
url = config._resolve_deployment_url(deployment_name="grounding-orch")
assert url == "https://deploy-grounding-orch.sap.com"
def test_env_var_selects_by_name(self, monkeypatch):
monkeypatch.setenv("SAP_ORCHESTRATION_DEPLOYMENT_NAME", "plain-orch")
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("grounding-orch", "plain-orch")):
url = config._resolve_deployment_url(deployment_name=None)
assert url == "https://deploy-plain-orch.sap.com"
def test_deployment_name_arg_takes_priority_over_env_var(self, monkeypatch):
monkeypatch.setenv("SAP_ORCHESTRATION_DEPLOYMENT_NAME", "plain-orch")
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("grounding-orch", "plain-orch")):
url = config._resolve_deployment_url(deployment_name="grounding-orch")
assert url == "https://deploy-grounding-orch.sap.com"
def test_unknown_name_raises_with_available_list(self):
from litellm.llms.sap.chat.handler import GenAIHubOrchestrationError
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("alpha", "beta")):
with pytest.raises(GenAIHubOrchestrationError) as exc_info:
config._resolve_deployment_url(deployment_name="gamma")
assert "gamma" in str(exc_info.value)
assert "alpha" in str(exc_info.value) or "beta" in str(exc_info.value)
def test_no_deployments_raises(self):
from litellm.llms.sap.chat.handler import GenAIHubOrchestrationError
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments()):
with pytest.raises(GenAIHubOrchestrationError) as exc_info:
config._resolve_deployment_url(deployment_name=None)
assert "No orchestration deployment found" in str(exc_info.value)
def test_get_complete_url_uses_deployment_name_from_litellm_params(self):
config = _make_config()
with patch("litellm.module_level_client", _mock_deployments("grounding-orch", "plain-orch")):
url = config.get_complete_url(
api_base=None,
api_key=None,
model="gpt-4o",
optional_params={},
litellm_params={"deployment_name": "plain-orch"},
)
assert url == "https://deploy-plain-orch.sap.com/v2/completion"