Skip to content

Commit 7e10fff

Browse files
committed
fix(aicore/fallback): broadcast primary prompt template to fallback module entries
litellm's transform_request only builds the primary module's template from `messages`; fallback entries get whatever was popped from their dict's "messages" key (transformation.py:371), which is `[]` for FallbackModel.to_dict(). The orchestration server then rejected with "config.modules[N].prompt_templating.prompt.template should be non-empty". Mirrors the existing filtering broadcast in the same transform_request. Adds a realistic unit test (and helper) that would have caught this before integration — the previous list-modules fixture hardcoded an empty template on both the primary AND fallback entries, normalising the bug away.
1 parent 4990896 commit 7e10fff

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

src/sap_cloud_sdk/aicore/filtering/filters.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,27 @@ def transform_request(
438438
headers=headers,
439439
)
440440

441+
# Broadcast the primary's prompt template to every fallback entry.
442+
# litellm only builds the primary module's template from ``messages``;
443+
# fallback entries get whatever was popped from their dict's
444+
# ``"messages"`` key (litellm transformation.py L371), which is ``[]``
445+
# for ``FallbackModel.to_dict()``. Without this copy, the server
446+
# rejects with "config.modules[N].prompt_templating.prompt.template
447+
# should be non-empty".
448+
modules = body["config"]["modules"]
449+
if isinstance(modules, list) and len(modules) > 1:
450+
primary_template = (
451+
modules[0]
452+
.get("prompt_templating", {})
453+
.get("prompt", {})
454+
.get("template")
455+
)
456+
if primary_template:
457+
for entry in modules[1:]:
458+
entry.setdefault("prompt_templating", {}).setdefault("prompt", {})[
459+
"template"
460+
] = primary_template
461+
441462
if _active_cfg is None:
442463
return body
443464

tests/aicore/fallback/unit/test_patch.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,44 @@ def _dict_modules_body() -> dict:
107107
}
108108
}
109109

110+
@staticmethod
111+
def _realistic_list_modules_body() -> dict:
112+
"""Body shape litellm actually produces — primary has a real template,
113+
fallback entries have ``template: []`` because litellm only converts
114+
the top-level ``messages`` for the primary module. The SDK is
115+
responsible for broadcasting the primary's template to every
116+
fallback entry; without that the orchestration server rejects with
117+
``config.modules[N].prompt_templating.prompt.template should be
118+
non-empty`` (the exact failure that reached integration tests).
119+
"""
120+
primary_template = [{"role": "user", "content": "Reply with 'ok'."}]
121+
return {
122+
"config": {
123+
"modules": [
124+
{
125+
"prompt_templating": {
126+
"prompt": {"template": primary_template},
127+
"model": {
128+
"name": "anthropic--claude-4.5-sonnet",
129+
"params": {},
130+
"version": "latest",
131+
},
132+
}
133+
},
134+
{
135+
"prompt_templating": {
136+
"prompt": {"template": []},
137+
"model": {
138+
"name": "mistral-small",
139+
"params": {},
140+
"version": "latest",
141+
},
142+
}
143+
},
144+
]
145+
}
146+
}
147+
110148
def test_fallback_injected_into_optional_params_before_super(self):
111149
_install_fallback(FallbackConfig([FallbackModel(model="sap/mistral-small")]))
112150
optional_params: dict = {}
@@ -217,6 +255,75 @@ def test_fallback_only_no_filtering_keys(self):
217255
for entry in body["config"]["modules"]:
218256
assert "filtering" not in entry
219257

258+
def test_primary_template_broadcasts_to_fallback_entries(self):
259+
# Regression: previously fallback entries went out with
260+
# ``prompt.template == []`` and the orchestration server rejected with
261+
# ``config.modules[1].prompt_templating.prompt.template should be
262+
# non-empty``. The patch now copies the primary's template across.
263+
_install_fallback(FallbackConfig([FallbackModel(model="sap/mistral-small")]))
264+
265+
with patch(
266+
"sap_cloud_sdk.aicore.filtering.filters."
267+
"GenAIHubOrchestrationConfig.transform_request",
268+
return_value=self._realistic_list_modules_body(),
269+
):
270+
body = OrchestrationPatchConfig().transform_request(
271+
model="sap/anthropic--claude-4.5-sonnet",
272+
messages=[{"role": "user", "content": "Reply with 'ok'."}],
273+
optional_params={},
274+
litellm_params={},
275+
headers={},
276+
)
277+
278+
modules = body["config"]["modules"]
279+
primary_template = modules[0]["prompt_templating"]["prompt"]["template"]
280+
assert primary_template, "primary template should be non-empty"
281+
for entry in modules[1:]:
282+
assert entry["prompt_templating"]["prompt"]["template"] == primary_template
283+
284+
def test_template_broadcast_noop_for_single_module_body(self):
285+
# No fallback installed → litellm emits a single dict (not a list);
286+
# the broadcast must not touch it. (Also: nothing to broadcast to.)
287+
with patch(
288+
"sap_cloud_sdk.aicore.filtering.filters."
289+
"GenAIHubOrchestrationConfig.transform_request",
290+
return_value=self._dict_modules_body(),
291+
):
292+
body = OrchestrationPatchConfig().transform_request(
293+
model="sap/anthropic--claude-4.5-sonnet",
294+
messages=[],
295+
optional_params={},
296+
litellm_params={},
297+
headers={},
298+
)
299+
300+
modules = body["config"]["modules"]
301+
assert isinstance(modules, dict)
302+
# Untouched — same empty template the fixture started with.
303+
assert modules["prompt_templating"]["prompt"]["template"] == []
304+
305+
def test_template_broadcast_skipped_when_primary_template_empty(self):
306+
# Defensive: if the primary itself somehow has no template, do not
307+
# propagate the empty value (no point) — leave fallback entries alone.
308+
_install_fallback(FallbackConfig([FallbackModel(model="sap/mistral-small")]))
309+
310+
with patch(
311+
"sap_cloud_sdk.aicore.filtering.filters."
312+
"GenAIHubOrchestrationConfig.transform_request",
313+
return_value=self._list_modules_body(),
314+
):
315+
body = OrchestrationPatchConfig().transform_request(
316+
model="sap/anthropic--claude-4.5-sonnet",
317+
messages=[],
318+
optional_params={},
319+
litellm_params={},
320+
headers={},
321+
)
322+
323+
modules = body["config"]["modules"]
324+
for entry in modules:
325+
assert entry["prompt_templating"]["prompt"]["template"] == []
326+
220327

221328
# ---------------------------------------------------------------------------
222329
# transform_response — intermediate_failures attachment

0 commit comments

Comments
 (0)