Skip to content

Commit 47992d8

Browse files
authored
feat(config): let multi-LLM members configure litellmrouter config + Vertex SA key (#2401)
Follow-up to #2384. That PR let an indexed multi-LLM member carry its own Vertex AI project/region, but two parity gaps remained vs the primary provider: - A `litellmrouter` member had no per-member router config, so it silently fell back to the global `HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG` — a chain could not fail over between differently-routed LiteLLM routers (same bug class #2384 fixed for Vertex). - A `vertexai` member used only the global service-account key, so cross-project failover with distinct credentials was impossible (project/region alone weren't enough). Adds `litellmrouter_config` and `vertexai_service_account_key` to `LLMMemberConfig`, reads `{prefix}LLM_{n}_LITELLMROUTER_CONFIG` / `_VERTEXAI_SERVICE_ACCOUNT_KEY` in `_parse_llm_members`, threads both through `_member_to_llm`, and lets `LLMProvider.__init__` take a per-instance Vertex SA key (explicit wins, else global fallback). Single-LLM/global behavior unchanged. Tests: parse (incl. per-op prefix + invalid-JSON), and build-path proving the member's own values reach `litellm.Router` and the Vertex SDK client. Docs table updated with the new per-member keys.
1 parent 93100ed commit 47992d8

6 files changed

Lines changed: 159 additions & 2 deletions

File tree

hindsight-api-slim/hindsight_api/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,8 @@ class LLMMemberConfig:
13221322
gemini_service_tier: str | None
13231323
vertexai_project_id: str | None = None
13241324
vertexai_region: str | None = None
1325+
vertexai_service_account_key: str | None = None
1326+
litellmrouter_config: dict | None = None
13251327

13261328

13271329
# Valid multi-LLM strategy modes.
@@ -1417,6 +1419,8 @@ def _parse_llm_members(prefix: str) -> list[LLMMemberConfig]:
14171419
),
14181420
vertexai_project_id=os.getenv(base + "VERTEXAI_PROJECT_ID") or None,
14191421
vertexai_region=os.getenv(base + "VERTEXAI_REGION") or None,
1422+
vertexai_service_account_key=os.getenv(base + "VERTEXAI_SERVICE_ACCOUNT_KEY") or None,
1423+
litellmrouter_config=_parse_llm_router_config(base + "LITELLMROUTER_CONFIG"),
14201424
)
14211425
)
14221426
index += 1

hindsight-api-slim/hindsight_api/engine/llm_wrapper.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ def __init__(
508508
gemini_service_tier: str | None = None,
509509
vertexai_project_id: str | None = None,
510510
vertexai_region: str | None = None,
511+
vertexai_service_account_key: str | None = None,
511512
):
512513
"""
513514
Initialize LLM provider.
@@ -539,6 +540,10 @@ def __init__(
539540
chain). When None, falls back to ``HindsightConfig.llm_vertexai_project_id``.
540541
vertexai_region: Vertex AI region for ``provider="vertexai"``. When None, falls back to
541542
``HindsightConfig.llm_vertexai_region`` (then ``"us-central1"``).
543+
vertexai_service_account_key: Path to a Vertex AI service-account key file for
544+
``provider="vertexai"``. Lets a member target a project with its own credentials
545+
(e.g. cross-project failover). When None, falls back to
546+
``HindsightConfig.llm_vertexai_service_account_key`` (then ADC).
542547
"""
543548
self.provider = provider.lower()
544549
self.api_key = api_key
@@ -646,7 +651,7 @@ def __init__(
646651
)
647652

648653
vertexai_region = vertexai_region or config.llm_vertexai_region or "us-central1"
649-
service_account_key = config.llm_vertexai_service_account_key
654+
service_account_key = vertexai_service_account_key or config.llm_vertexai_service_account_key
650655

651656
# Load explicit service account credentials if provided
652657
if service_account_key:

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,8 @@ def _member_to_llm(member: "LLMMemberConfig", config: HindsightConfig) -> LLMCon
390390
"""Build an LLMProvider from one indexed multi-LLM member.
391391

392392
Member fields are self-contained; reasoning_effort falls back to the global
393-
setting and default_headers falls back inside ``LLMProvider`` when unset.
393+
setting and default_headers / litellmrouter_config / vertexai credentials fall
394+
back inside ``LLMProvider`` when unset.
394395
"""
395396
return LLMConfig(
396397
provider=member.provider,
@@ -404,6 +405,8 @@ def _member_to_llm(member: "LLMMemberConfig", config: HindsightConfig) -> LLMCon
404405
gemini_service_tier=member.gemini_service_tier,
405406
vertexai_project_id=member.vertexai_project_id,
406407
vertexai_region=member.vertexai_region,
408+
vertexai_service_account_key=member.vertexai_service_account_key,
409+
litellmrouter_config=member.litellmrouter_config,
407410
)
408411

409412

hindsight-api-slim/tests/test_multi_llm_config.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,49 @@ def test_parse_members_without_vertexai_fields_default_none(clean_llm_env):
113113
members = _parse_llm_members("")
114114
assert members[0].vertexai_project_id is None
115115
assert members[0].vertexai_region is None
116+
assert members[0].vertexai_service_account_key is None
117+
assert members[0].litellmrouter_config is None
118+
119+
120+
def test_parse_members_vertexai_service_account_key(clean_llm_env):
121+
# A vertexai member can carry its own service-account key for cross-project
122+
# failover (credentials are otherwise global).
123+
clean_llm_env.setenv("HINDSIGHT_API_LLM_1_PROVIDER", "vertexai")
124+
clean_llm_env.setenv("HINDSIGHT_API_LLM_1_VERTEXAI_PROJECT_ID", "p")
125+
clean_llm_env.setenv("HINDSIGHT_API_LLM_1_VERTEXAI_SERVICE_ACCOUNT_KEY", "/keys/sa.json")
126+
members = _parse_llm_members("")
127+
assert members[0].vertexai_service_account_key == "/keys/sa.json"
128+
129+
130+
def test_parse_members_litellmrouter_config(clean_llm_env):
131+
# A litellmrouter member can carry its own router config so a chain can fail
132+
# over between differently-routed LiteLLM routers.
133+
clean_llm_env.setenv("HINDSIGHT_API_LLM_1_PROVIDER", "litellmrouter")
134+
clean_llm_env.setenv(
135+
"HINDSIGHT_API_LLM_1_LITELLMROUTER_CONFIG",
136+
'{"model_list": [{"model_name": "m"}]}',
137+
)
138+
members = _parse_llm_members("")
139+
assert members[0].litellmrouter_config == {"model_list": [{"model_name": "m"}]}
140+
141+
142+
def test_parse_members_litellmrouter_config_per_op_prefix(clean_llm_env):
143+
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_PROVIDER", "litellmrouter")
144+
clean_llm_env.setenv(
145+
"HINDSIGHT_API_REFLECT_LLM_1_LITELLMROUTER_CONFIG",
146+
'{"model_list": [{"model_name": "reflect-m"}]}',
147+
)
148+
reflect = _parse_llm_members("REFLECT_")
149+
assert reflect[0].litellmrouter_config == {"model_list": [{"model_name": "reflect-m"}]}
150+
# Scoped to the prefix; the global chain is unaffected.
151+
assert _parse_llm_members("") == []
152+
153+
154+
def test_parse_members_litellmrouter_config_invalid_json_raises(clean_llm_env):
155+
clean_llm_env.setenv("HINDSIGHT_API_LLM_1_PROVIDER", "litellmrouter")
156+
clean_llm_env.setenv("HINDSIGHT_API_LLM_1_LITELLMROUTER_CONFIG", "{not json")
157+
with pytest.raises(ValueError, match="invalid JSON"):
158+
_parse_llm_members("")
116159

117160

118161
# ── strategy parsing ───────────────────────────────────────────────────────────
@@ -296,3 +339,101 @@ def __init__(self, **kwargs):
296339
# Project/region flowed all the way to the Vertex AI SDK client.
297340
assert captured["project"] == "member-proj"
298341
assert captured["location"] == "europe-west1"
342+
343+
344+
def test_member_to_llm_passes_vertexai_service_account_key(clean_llm_env, monkeypatch):
345+
"""A vertexai member's own service-account key reaches credential loading.
346+
347+
The key path flows to ``service_account.Credentials.from_service_account_file``
348+
and the resulting credentials reach the Vertex AI SDK client — proving the
349+
member value is used rather than the (unset) global one. Both the credential
350+
loader and the SDK client are patched so no file or network is touched.
351+
"""
352+
import hindsight_api.engine.llm_wrapper as llm_wrapper
353+
import hindsight_api.engine.providers.gemini_llm as gemini_llm
354+
from hindsight_api.config import clear_config_cache
355+
from hindsight_api.engine.memory_engine import _member_to_llm
356+
357+
captured: dict = {}
358+
sentinel_creds = object()
359+
360+
class _FakeServiceAccount:
361+
class Credentials:
362+
@staticmethod
363+
def from_service_account_file(path, scopes=None):
364+
captured["key_path"] = path
365+
return sentinel_creds
366+
367+
class _FakeClient:
368+
def __init__(self, **kwargs):
369+
captured.update(kwargs)
370+
371+
monkeypatch.setattr(llm_wrapper, "VERTEXAI_AVAILABLE", True)
372+
monkeypatch.setattr(llm_wrapper, "service_account", _FakeServiceAccount)
373+
monkeypatch.setattr(gemini_llm.genai, "Client", _FakeClient)
374+
clear_config_cache()
375+
376+
member = LLMMemberConfig(
377+
provider="vertexai",
378+
api_key=None,
379+
model="gemini-2.0-flash",
380+
base_url=None,
381+
reasoning_effort=None,
382+
extra_body=None,
383+
default_headers=None,
384+
bedrock_service_tier=None,
385+
gemini_service_tier=None,
386+
vertexai_project_id="member-proj",
387+
vertexai_service_account_key="/keys/member-sa.json",
388+
)
389+
provider = _member_to_llm(member, _empty_config())
390+
391+
assert provider.provider == "vertexai"
392+
# The member's key path was loaded, and the credentials reached the SDK client.
393+
assert captured["key_path"] == "/keys/member-sa.json"
394+
assert captured["credentials"] is sentinel_creds
395+
396+
397+
# ── litellmrouter member build path (_member_to_llm) ─────────────────────────────
398+
399+
400+
def test_member_to_llm_passes_litellmrouter_config(clean_llm_env, monkeypatch):
401+
"""A litellmrouter member's own router config reaches the LiteLLM router build.
402+
403+
The global config has no router config, so this also proves the member's
404+
config is used (no "requires a config object" error) instead of the global
405+
fallback. The LiteLLM router provider is patched so no router is constructed.
406+
"""
407+
import hindsight_api.engine.providers as providers
408+
from hindsight_api.config import clear_config_cache
409+
from hindsight_api.engine.memory_engine import _member_to_llm
410+
411+
captured: dict = {}
412+
413+
class _FakeRouterLLM:
414+
provider = "litellmrouter"
415+
416+
def __init__(self, **kwargs):
417+
captured.update(kwargs)
418+
419+
monkeypatch.setattr(providers, "LiteLLMRouterLLM", _FakeRouterLLM)
420+
clear_config_cache()
421+
422+
router_cfg = {"model_list": [{"model_name": "m"}]}
423+
member = LLMMemberConfig(
424+
provider="litellmrouter",
425+
api_key=None,
426+
model="m",
427+
base_url=None,
428+
reasoning_effort=None,
429+
extra_body=None,
430+
default_headers=None,
431+
bedrock_service_tier=None,
432+
gemini_service_tier=None,
433+
litellmrouter_config=router_cfg,
434+
)
435+
provider = _member_to_llm(member, _empty_config())
436+
437+
assert provider.provider == "litellmrouter"
438+
# The member's own router config flowed to the LiteLLM router build.
439+
assert captured["config"] == router_cfg

hindsight-docs/docs/developer/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ The unindexed `HINDSIGHT_API_LLM_*` config is the **primary** (member 1). Extra
370370
| `HINDSIGHT_API_LLM_<n>_REASONING_EFFORT` | Reasoning effort for member `n`. | `HINDSIGHT_API_LLM_REASONING_EFFORT` |
371371
| `HINDSIGHT_API_LLM_<n>_EXTRA_BODY` / `_DEFAULT_HEADERS` | Per-member JSON overrides. | - |
372372
| `HINDSIGHT_API_LLM_<n>_BEDROCK_SERVICE_TIER` / `_GEMINI_SERVICE_TIER` | Per-member service tier. | - |
373+
| `HINDSIGHT_API_LLM_<n>_VERTEXAI_PROJECT_ID` / `_VERTEXAI_REGION` / `_VERTEXAI_SERVICE_ACCOUNT_KEY` | Per-member Vertex AI project, region, and service-account key path (for a `vertexai` member). Each falls back to the global `HINDSIGHT_API_LLM_VERTEXAI_*` when unset. | Global / `us-central1` / ADC |
374+
| `HINDSIGHT_API_LLM_<n>_LITELLMROUTER_CONFIG` | Per-member LiteLLM Router config JSON (for a `litellmrouter` member). Falls back to the global `HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG` when unset. | - |
373375
| `HINDSIGHT_API_LLM_STRATEGY` | JSON routing strategy across the chain. Unset = single primary LLM (no change). | - |
374376

375377
The strategy JSON supports two modes:

skills/hindsight-docs/references/developer/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ The unindexed `HINDSIGHT_API_LLM_*` config is the **primary** (member 1). Extra
370370
| `HINDSIGHT_API_LLM_<n>_REASONING_EFFORT` | Reasoning effort for member `n`. | `HINDSIGHT_API_LLM_REASONING_EFFORT` |
371371
| `HINDSIGHT_API_LLM_<n>_EXTRA_BODY` / `_DEFAULT_HEADERS` | Per-member JSON overrides. | - |
372372
| `HINDSIGHT_API_LLM_<n>_BEDROCK_SERVICE_TIER` / `_GEMINI_SERVICE_TIER` | Per-member service tier. | - |
373+
| `HINDSIGHT_API_LLM_<n>_VERTEXAI_PROJECT_ID` / `_VERTEXAI_REGION` / `_VERTEXAI_SERVICE_ACCOUNT_KEY` | Per-member Vertex AI project, region, and service-account key path (for a `vertexai` member). Each falls back to the global `HINDSIGHT_API_LLM_VERTEXAI_*` when unset. | Global / `us-central1` / ADC |
374+
| `HINDSIGHT_API_LLM_<n>_LITELLMROUTER_CONFIG` | Per-member LiteLLM Router config JSON (for a `litellmrouter` member). Falls back to the global `HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG` when unset. | - |
373375
| `HINDSIGHT_API_LLM_STRATEGY` | JSON routing strategy across the chain. Unset = single primary LLM (no change). | - |
374376

375377
The strategy JSON supports two modes:

0 commit comments

Comments
 (0)