Skip to content

Commit 5537f7f

Browse files
DeepfreezechillBrian KrafftCopilot
authored
feat(4.5): extract LLMFactory from OpenSpace (#34)
* feat(4.5): extract LLMFactory from OpenSpace New module: openspace/llm_factory.py (66 lines) - LLMFactory class: centralized LLM client creation from config - create_main(): primary LLM client with full config params - create_tool_retrieval(): optional tool retrieval LLM (shares credentials) tool_layer.py: replaced 18 lines of inline LLM construction with 6 lines delegating to LLMFactory (-12 net lines) Tests: 12 tests in test_llm_factory.py - Init state and config storage - create_main: full config passthrough, defaults, idempotency - create_tool_retrieval: enabled/disabled, config passthrough, kwargs inheritance - OpenSpace backward compatibility All 1,356 existing tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(4.5): address review findings from /8eyes + /collab Round 1 - Add docstring note on intentional param asymmetry in create_tool_retrieval() - Add empty-string model test (M1: treats '' same as None) - Add create_tool_retrieval twice test (M2: idempotency) - Add constructor exception test (C2: propagates, client stays None) - Fix fragile call_args unpacking (use .kwargs) Tests: 12 → 16 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b95b863 commit 5537f7f

3 files changed

Lines changed: 290 additions & 18 deletions

File tree

openspace/llm_factory.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""LLMFactory — centralized LLM client creation from config.
2+
3+
Extracted from OpenSpace.initialize() in Epic 4.5. Owns:
4+
• Main LLM client creation with full config
5+
• Optional tool retrieval LLM creation (shares credentials via llm_kwargs)
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import TYPE_CHECKING, Optional
11+
12+
from openspace.llm import LLMClient
13+
from openspace.utils.logging import Logger
14+
15+
if TYPE_CHECKING:
16+
from openspace.tool_layer import OpenSpaceConfig
17+
18+
logger = Logger.get_logger(__name__)
19+
20+
21+
class LLMFactory:
22+
"""Creates LLM clients from OpenSpaceConfig."""
23+
24+
def __init__(self, *, config: OpenSpaceConfig) -> None:
25+
self._config = config
26+
self._llm_client: Optional[LLMClient] = None
27+
self._tool_retrieval_llm: Optional[LLMClient] = None
28+
29+
@property
30+
def llm_client(self) -> Optional[LLMClient]:
31+
return self._llm_client
32+
33+
@property
34+
def tool_retrieval_llm(self) -> Optional[LLMClient]:
35+
return self._tool_retrieval_llm
36+
37+
def create_main(self) -> LLMClient:
38+
"""Create the primary LLM client from config."""
39+
self._llm_client = LLMClient(
40+
model=self._config.llm_model,
41+
enable_thinking=self._config.llm_enable_thinking,
42+
rate_limit_delay=self._config.llm_rate_limit_delay,
43+
max_retries=self._config.llm_max_retries,
44+
timeout=self._config.llm_timeout,
45+
**self._config.llm_kwargs,
46+
)
47+
return self._llm_client
48+
49+
def create_tool_retrieval(self) -> Optional[LLMClient]:
50+
"""Create optional tool retrieval LLM. Returns None if not configured.
51+
52+
Inherits llm_kwargs (api_key, api_base, etc.) so credentials
53+
from the host agent are shared across all internal LLM clients.
54+
55+
Note: intentionally omits ``enable_thinking`` and ``rate_limit_delay``
56+
since tool retrieval is a simple selection task, not a reasoning task.
57+
"""
58+
if not self._config.tool_retrieval_model:
59+
return None
60+
61+
self._tool_retrieval_llm = LLMClient(
62+
model=self._config.tool_retrieval_model,
63+
timeout=self._config.llm_timeout,
64+
max_retries=self._config.llm_max_retries,
65+
**self._config.llm_kwargs,
66+
)
67+
return self._tool_retrieval_llm

openspace/tool_layer.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from openspace.grounding.core.grounding_client import GroundingClient
1414
from openspace.execution_engine import ExecutionEngine
1515
from openspace.llm import LLMClient
16+
from openspace.llm_factory import LLMFactory
1617
from openspace.recording_service import RecordingService
1718
from openspace.skill_engine import ExecutionAnalyzer, SkillRegistry, SkillStore
1819
from openspace.skill_engine.evolver import SkillEvolver
@@ -125,6 +126,7 @@ def __init__(
125126
self._container = container or AppContainer()
126127

127128
self._llm_client: Optional[LLMClient] = None
129+
self._llm_factory: Optional[LLMFactory] = None
128130
self._grounding_client: Optional[GroundingClient] = None
129131
self._grounding_config = None # GroundingConfig reference for skill settings
130132
self._grounding_agent: Optional[GroundingAgent] = None
@@ -205,14 +207,8 @@ async def initialize(self) -> None:
205207
logger.info("Initializing OpenSpace...")
206208

207209
try:
208-
self._llm_client = LLMClient(
209-
model=self.config.llm_model,
210-
enable_thinking=self.config.llm_enable_thinking,
211-
rate_limit_delay=self.config.llm_rate_limit_delay,
212-
max_retries=self.config.llm_max_retries,
213-
timeout=self.config.llm_timeout,
214-
**self.config.llm_kwargs,
215-
)
210+
self._llm_factory = LLMFactory(config=self.config)
211+
self._llm_client = self._llm_factory.create_main()
216212
logger.info(f"✓ LLM Client: {self.config.llm_model}")
217213

218214
# Load grounding config
@@ -300,16 +296,8 @@ async def initialize(self) -> None:
300296
logger.info(f"✓ Recording enabled: {len(self._recording_manager.backends or [])} backends")
301297

302298
# Create separate LLM client for tool retrieval if configured
303-
# Inherits llm_kwargs (api_key, api_base, etc.) so credentials
304-
# from the host agent are shared across all internal LLM clients.
305-
tool_retrieval_llm = None
306-
if self.config.tool_retrieval_model:
307-
tool_retrieval_llm = LLMClient(
308-
model=self.config.tool_retrieval_model,
309-
timeout=self.config.llm_timeout,
310-
max_retries=self.config.llm_max_retries,
311-
**self.config.llm_kwargs,
312-
)
299+
tool_retrieval_llm = self._llm_factory.create_tool_retrieval()
300+
if tool_retrieval_llm:
313301
logger.info(f"✓ Tool retrieval LLM: {self.config.tool_retrieval_model}")
314302

315303
self._grounding_agent = GroundingAgent(

tests/test_llm_factory.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Tests for LLMFactory — extracted LLM client creation from OpenSpace."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from unittest.mock import MagicMock, patch, call
7+
8+
try:
9+
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
10+
from openspace.llm_factory import LLMFactory
11+
12+
_HAS_TOOL_LAYER = True
13+
except Exception:
14+
_HAS_TOOL_LAYER = False
15+
16+
pytestmark = pytest.mark.skipif(not _HAS_TOOL_LAYER, reason="tool_layer deps unavailable")
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Fixtures
21+
# ---------------------------------------------------------------------------
22+
23+
@pytest.fixture
24+
def config():
25+
return OpenSpaceConfig(
26+
llm_model="openrouter/anthropic/claude-sonnet-4.5",
27+
llm_enable_thinking=True,
28+
llm_timeout=60.0,
29+
llm_max_retries=5,
30+
llm_rate_limit_delay=0.5,
31+
llm_kwargs={"api_key": "sk-test"},
32+
tool_retrieval_model="openrouter/openai/gpt-4o",
33+
)
34+
35+
36+
@pytest.fixture
37+
def minimal_config():
38+
return OpenSpaceConfig()
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# LLMFactory.__init__
43+
# ---------------------------------------------------------------------------
44+
45+
class TestLLMFactoryInit:
46+
47+
def test_initial_state(self, config):
48+
factory = LLMFactory(config=config)
49+
assert factory.llm_client is None
50+
assert factory.tool_retrieval_llm is None
51+
52+
def test_stores_config(self, config):
53+
factory = LLMFactory(config=config)
54+
assert factory._config is config
55+
56+
57+
# ---------------------------------------------------------------------------
58+
# LLMFactory.create_main()
59+
# ---------------------------------------------------------------------------
60+
61+
class TestCreateMain:
62+
63+
def test_creates_llm_client(self, config):
64+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
65+
mock_client = MagicMock()
66+
MockLLM.return_value = mock_client
67+
68+
factory = LLMFactory(config=config)
69+
result = factory.create_main()
70+
71+
assert result is mock_client
72+
assert factory.llm_client is mock_client
73+
74+
def test_passes_all_config_fields(self, config):
75+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
76+
MockLLM.return_value = MagicMock()
77+
78+
factory = LLMFactory(config=config)
79+
factory.create_main()
80+
81+
MockLLM.assert_called_once_with(
82+
model="openrouter/anthropic/claude-sonnet-4.5",
83+
enable_thinking=True,
84+
rate_limit_delay=0.5,
85+
max_retries=5,
86+
timeout=60.0,
87+
api_key="sk-test",
88+
)
89+
90+
def test_uses_defaults_when_minimal_config(self, minimal_config):
91+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
92+
MockLLM.return_value = MagicMock()
93+
94+
factory = LLMFactory(config=minimal_config)
95+
factory.create_main()
96+
97+
MockLLM.assert_called_once_with(
98+
model="openrouter/anthropic/claude-sonnet-4.5",
99+
enable_thinking=False,
100+
rate_limit_delay=0.0,
101+
max_retries=3,
102+
timeout=120.0,
103+
)
104+
105+
def test_create_main_twice_replaces_client(self, config):
106+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
107+
first = MagicMock()
108+
second = MagicMock()
109+
MockLLM.side_effect = [first, second]
110+
111+
factory = LLMFactory(config=config)
112+
factory.create_main()
113+
assert factory.llm_client is first
114+
factory.create_main()
115+
assert factory.llm_client is second
116+
117+
118+
# ---------------------------------------------------------------------------
119+
# LLMFactory.create_tool_retrieval()
120+
# ---------------------------------------------------------------------------
121+
122+
class TestCreateToolRetrieval:
123+
124+
def test_creates_when_model_configured(self, config):
125+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
126+
mock_client = MagicMock()
127+
MockLLM.return_value = mock_client
128+
129+
factory = LLMFactory(config=config)
130+
result = factory.create_tool_retrieval()
131+
132+
assert result is mock_client
133+
assert factory.tool_retrieval_llm is mock_client
134+
135+
def test_returns_none_when_no_model(self, minimal_config):
136+
factory = LLMFactory(config=minimal_config)
137+
result = factory.create_tool_retrieval()
138+
assert result is None
139+
assert factory.tool_retrieval_llm is None
140+
141+
def test_passes_correct_config(self, config):
142+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
143+
MockLLM.return_value = MagicMock()
144+
145+
factory = LLMFactory(config=config)
146+
factory.create_tool_retrieval()
147+
148+
MockLLM.assert_called_once_with(
149+
model="openrouter/openai/gpt-4o",
150+
timeout=60.0,
151+
max_retries=5,
152+
api_key="sk-test",
153+
)
154+
155+
def test_inherits_llm_kwargs(self, config):
156+
"""Tool retrieval LLM inherits credentials from llm_kwargs."""
157+
config.llm_kwargs = {"api_key": "sk-shared", "api_base": "https://custom"}
158+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
159+
MockLLM.return_value = MagicMock()
160+
161+
factory = LLMFactory(config=config)
162+
factory.create_tool_retrieval()
163+
164+
kwargs = MockLLM.call_args.kwargs
165+
assert kwargs["api_key"] == "sk-shared"
166+
assert kwargs["api_base"] == "https://custom"
167+
168+
def test_returns_none_when_empty_string_model(self, config):
169+
"""Empty string model treated as unconfigured."""
170+
config.tool_retrieval_model = ""
171+
factory = LLMFactory(config=config)
172+
result = factory.create_tool_retrieval()
173+
assert result is None
174+
assert factory.tool_retrieval_llm is None
175+
176+
def test_create_tool_retrieval_twice_replaces(self, config):
177+
"""Second call replaces the tool retrieval client."""
178+
with patch("openspace.llm_factory.LLMClient") as MockLLM:
179+
first, second = MagicMock(), MagicMock()
180+
MockLLM.side_effect = [first, second]
181+
182+
factory = LLMFactory(config=config)
183+
factory.create_tool_retrieval()
184+
assert factory.tool_retrieval_llm is first
185+
factory.create_tool_retrieval()
186+
assert factory.tool_retrieval_llm is second
187+
188+
189+
# ---------------------------------------------------------------------------
190+
# Error handling
191+
# ---------------------------------------------------------------------------
192+
193+
class TestErrorHandling:
194+
195+
def test_create_main_exception_propagates(self, config):
196+
"""LLMClient constructor failure propagates, client stays None."""
197+
with patch("openspace.llm_factory.LLMClient", side_effect=RuntimeError("boom")):
198+
factory = LLMFactory(config=config)
199+
with pytest.raises(RuntimeError, match="boom"):
200+
factory.create_main()
201+
assert factory.llm_client is None
202+
203+
204+
# ---------------------------------------------------------------------------
205+
# OpenSpace backward compatibility
206+
# ---------------------------------------------------------------------------
207+
208+
class TestOpenSpaceDelegation:
209+
210+
def test_openspace_has_llm_factory_attr(self):
211+
os_instance = OpenSpace()
212+
assert hasattr(os_instance, "_llm_factory")
213+
214+
def test_llm_client_still_accessible(self):
215+
os_instance = OpenSpace()
216+
assert hasattr(os_instance, "_llm_client")
217+
assert os_instance._llm_client is None

0 commit comments

Comments
 (0)