Skip to content

Commit b521d71

Browse files
authored
feat(core): route 4 remaining provider sites through llm_resolution chain (#861) (#874)
* feat(core): route 4 remaining provider sites through llm_resolution chain (#861) Follow-up to #768 (PR #860) which introduced codeframe/core/llm_resolution.py as the single source of truth for the provider chain (CODEFRAME_LLM_PROVIDER -> .codeframe/config.yaml -> anthropic). Four code paths still constructed providers directly, bypassing the chain. Sites migrated: - codeframe/core/conductor.py:SupervisorResolver.llm -- was get_provider() (bare -> anthropic). Concrete failure fixed: cf work batch run --strategy auto --llm-provider ollama now uses ollama for blocker auto-resolution instead of demanding an Anthropic key downstream of a passing pre-flight. - codeframe/core/dependency_analyzer.py:analyze_dependencies -- the private _get_default_provider() helper (hardcoded AnthropicProvider + ValueError on missing key) is dropped; the call site now inlines create_provider(resolve_llm_settings(workspace.repo_path)) when provider=None. - codeframe/core/prd_discovery.py:PrdDiscoverySession.__post_init__ -- api_key remains as a backward-compat field (constructs AnthropicProvider when set, preserving every existing caller). When api_key is unset the chain is used. The missing-key check is generalized via LLMSettings.required_key_env so the existing NoApiKeyError contract is preserved AND extended to any keyed provider (not just Anthropic). - codeframe/core/adapters/streaming_chat.py:StreamingChatAdapter -- the no-provider fallback (used only by tests/external callers; production session_chat_ws.py already passes provider= explicitly) now uses the chain. The vestigial api_key parameter is kept for backward compat. Regression coverage: tests/core/test_provider_resolution_chain.py adds one test class per site (9 tests total) mirroring the reference pattern in tests/ui/test_discovery_generate_tasks.py -- set CODEFRAME_LLM_PROVIDER=ollama, delete ANTHROPIC_API_KEY, mock create_provider, assert the chain is invoked and the resulting provider is the one used. All existing tests in the 4 affected areas pass unchanged (67+17=84 tests). * fix(streaming-chat): preserve api_key= legacy contract + tighten regression tests Addresses cross-family review (opencode/GLM) on PR for #861: Major 1 + Major 2: streaming_chat docstring lied about api_key behavior, and the api_key= legacy contract was asymmetric with PrdDiscoverySession (which preserves it). Restore the legacy branch: when api_key is set AND provider is None, construct AnthropicProvider(api_key=...) — mirroring prd_discovery. Update docstring to describe the new contract accurately. Suggestion 1: test_explicit_provider_skips_chain for dependency_analyzer previously passed task_ids=[] which hit the early-return at line ~63 before the provider was ever consulted. Seed a task and assert explicit_provider.complete was actually called, so the test fails if explicit-provider handling breaks. Nitpick 2: lock in model_flag= propagation in streaming_chat (the only site that threads model into resolve_llm_settings) via an assertion on settings.model. New test_legacy_api_key_skips_chain locks in the Major 2 fix. Total tests: 10 (was 9). * chore: gitignore .opencode-tmp scratch dir (review artifacts) * fix(tests): patch llm_resolution.create_provider in prd_generate CLI tests The 8 CLI tests in tests/cli/test_prd_generate.py patched codeframe.core.prd_discovery.AnthropicProvider to substitute a mock LLM. After #861 migrated PrdDiscoverySession to use the shared llm_resolution chain, the construction path moved to create_provider -> get_provider -> codeframe.adapters.llm.anthropic.AnthropicProvider, which the existing patch target didn't intercept. CI surfaced this as 401 'invalid x-api-key' errors reaching the real Anthropic API. Fix: retarget the patch to codeframe.core.llm_resolution.create_provider across all 8 affected test methods. The mock setup pattern is unchanged (mock.return_value = mock_llm_provider); only the patch site moved. This is a contract change documented in issue #861 (PrdDiscoverySession now resolves via the shared chain) — test patches are updated to reflect the new contract, per the anti-mutation rule's allowance for documented contract changes. * docs(prd-discovery): mark api_key as optional in class docstring Per claude[bot] review on PR #874: the PrdDiscoverySession class docstring still claimed api_key is 'required' even though #861 made it optional (when unset, the provider is resolved via the llm_resolution chain). Aligns with the streaming_chat docstring fix from the same migration.
1 parent a46f413 commit b521d71

7 files changed

Lines changed: 356 additions & 50 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,4 @@ tests/e2e/.e2e-state.db*
9898
test_audit_report.md
9999
tests/integration/.env.integration
100100
web-ui/test-results/
101+
.opencode-tmp/

codeframe/core/adapters/streaming_chat.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -169,24 +169,40 @@ def __init__(
169169
db_repo: ``InteractiveSessionRepository`` instance.
170170
workspace_path: Absolute path used to scope file-system tool calls.
171171
model: Model identifier passed to the provider's ``async_stream()``.
172-
api_key: API key used when constructing the default
173-
``AnthropicProvider`` (when ``provider`` is ``None``).
174-
provider: LLM provider to use for streaming. When ``None``,
175-
an ``AnthropicProvider`` is constructed automatically using
176-
``api_key`` or the ``ANTHROPIC_API_KEY`` environment variable.
172+
Also threaded into ``resolve_llm_settings`` as ``model_flag``
173+
when the provider must be resolved (#861).
174+
api_key: Legacy construct — when set AND ``provider`` is ``None``,
175+
an ``AnthropicProvider`` is constructed with this key
176+
(matches the pre-#861 contract). Ignored if ``provider`` is
177+
given. Prefer passing ``provider`` directly.
178+
provider: LLM provider to use for streaming. When ``None`` and
179+
``api_key`` is also ``None``, the provider is resolved via the
180+
shared ``llm_resolution`` chain (#861):
181+
``CODEFRAME_LLM_PROVIDER`` → ``.codeframe/config.yaml`` →
182+
anthropic.
177183
178184
Raises:
179-
ValueError: If no provider is given and no Anthropic API key is
180-
available.
185+
ValueError: If no provider is given, no ``api_key`` is given, and
186+
the chain resolves to a provider whose required API key is
187+
missing from the environment.
181188
"""
182189
if provider is None:
183-
# Backward-compatibility fallback: callers that haven't been
184-
# updated to pass an explicit provider (e.g. tests using the old
185-
# api_key= constructor argument) still get an AnthropicProvider.
186-
# New callers should construct the provider themselves and pass it
187-
# in — see session_chat_ws.py for the recommended pattern.
188-
from codeframe.adapters.llm.anthropic import AnthropicProvider
189-
provider = AnthropicProvider(api_key=api_key)
190+
if api_key is not None:
191+
# Legacy path: explicit api_key always means Anthropic.
192+
# Preserves the pre-#861 contract (mirrors PrdDiscoverySession).
193+
from codeframe.adapters.llm.anthropic import AnthropicProvider
194+
195+
provider = AnthropicProvider(api_key=api_key)
196+
else:
197+
# New: resolve via the shared chain
198+
# (CODEFRAME_LLM_PROVIDER → .codeframe/config.yaml → anthropic,
199+
# per #861). New callers should construct the provider
200+
# themselves and pass it in — see session_chat_ws.py for the
201+
# recommended pattern.
202+
from codeframe.core.llm_resolution import create_provider, resolve_llm_settings
203+
204+
settings = resolve_llm_settings(workspace_path, model_flag=model)
205+
provider = create_provider(settings)
190206

191207
self._session_id = session_id
192208
self._db_repo = db_repo

codeframe/core/conductor.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,15 @@ def __init__(self, workspace: Workspace):
119119

120120
@property
121121
def llm(self):
122-
"""Lazy-load LLM provider."""
122+
"""Lazy-load LLM provider.
123+
124+
Resolves via the shared ``llm_resolution`` chain (#861):
125+
``CODEFRAME_LLM_PROVIDER`` → ``.codeframe/config.yaml`` → anthropic.
126+
"""
123127
if self._llm is None:
124-
from codeframe.adapters.llm import get_provider
125-
self._llm = get_provider()
128+
from codeframe.core.llm_resolution import create_provider, resolve_llm_settings
129+
130+
self._llm = create_provider(resolve_llm_settings(self.workspace.repo_path))
126131
return self._llm
127132

128133
def try_resolve_blocked_task(self, task_id: str) -> bool:

codeframe/core/dependency_analyzer.py

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"""
1212

1313
import json
14-
import os
1514
import re
1615
from typing import Optional
1716

@@ -81,7 +80,11 @@ def analyze_dependencies(
8180

8281
# Get or create LLM provider
8382
if provider is None:
84-
provider = _get_default_provider()
83+
# Resolve via the shared chain (#861):
84+
# CODEFRAME_LLM_PROVIDER → .codeframe/config.yaml → anthropic.
85+
from codeframe.core.llm_resolution import create_provider, resolve_llm_settings
86+
87+
provider = create_provider(resolve_llm_settings(workspace.repo_path))
8588

8689
# Call LLM
8790
response = provider.complete(
@@ -211,19 +214,3 @@ def apply_inferred_dependencies(
211214
if deps:
212215
task_module.update_depends_on(workspace, task_id, deps)
213216

214-
215-
def _get_default_provider():
216-
"""Get the default Anthropic LLM provider.
217-
218-
Returns:
219-
AnthropicProvider instance
220-
221-
Raises:
222-
ValueError: If ANTHROPIC_API_KEY not set
223-
"""
224-
api_key = os.getenv("ANTHROPIC_API_KEY")
225-
if not api_key:
226-
raise ValueError("ANTHROPIC_API_KEY environment variable not set")
227-
228-
from codeframe.adapters.llm.anthropic import AnthropicProvider
229-
return AnthropicProvider(api_key=api_key)

codeframe/core/prd_discovery.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,10 @@ class PrdDiscoverySession:
222222
223223
Attributes:
224224
workspace: The workspace this session belongs to
225-
api_key: API key for LLM provider (required)
225+
api_key: Optional; when set, constructs an ``AnthropicProvider``
226+
with this key (legacy contract). When unset, the provider is
227+
resolved via the ``llm_resolution`` chain (#861):
228+
``CODEFRAME_LLM_PROVIDER`` → ``.codeframe/config.yaml`` → anthropic.
226229
session_id: Unique session identifier
227230
state: Current session state
228231
"""
@@ -239,15 +242,33 @@ class PrdDiscoverySession:
239242
_is_complete: bool = field(default=False, init=False)
240243

241244
def __post_init__(self):
242-
"""Initialize the LLM provider."""
243-
key = self.api_key or os.getenv("ANTHROPIC_API_KEY")
244-
if not key:
245+
"""Initialize the LLM provider.
246+
247+
Resolution order (#861):
248+
1. ``api_key`` set → ``AnthropicProvider(api_key=...)`` (legacy contract;
249+
preserves existing callers passing an explicit key).
250+
2. Otherwise → shared ``llm_resolution`` chain
251+
(``CODEFRAME_LLM_PROVIDER`` → ``.codeframe/config.yaml`` → anthropic).
252+
When the resolved provider requires an API key (see
253+
``LLMSettings.required_key_env``) and it is missing from the
254+
environment, ``NoApiKeyError`` is raised — generalizes the previous
255+
Anthropic-only check to all keyed providers.
256+
"""
257+
if self.api_key:
258+
self._llm_provider = AnthropicProvider(api_key=self.api_key)
259+
return
260+
261+
from codeframe.core.llm_resolution import create_provider, resolve_llm_settings
262+
263+
settings = resolve_llm_settings(self.workspace.repo_path)
264+
required_env = settings.required_key_env
265+
if required_env and not os.getenv(required_env, ""):
245266
raise NoApiKeyError(
246-
"ANTHROPIC_API_KEY is required for AI-driven discovery. "
267+
f"{required_env} is required for AI-driven discovery "
268+
f"(resolved provider: {settings.provider_type}). "
247269
"Set the environment variable or pass api_key parameter."
248270
)
249-
250-
self._llm_provider = AnthropicProvider(api_key=key)
271+
self._llm_provider = create_provider(settings)
251272

252273
@property
253274
def answered_count(self) -> int:

tests/cli/test_prd_generate.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def test_no_api_key_shows_error(self, workspace_dir: Path, monkeypatch):
128128
assert result.exit_code == 1
129129
assert "ANTHROPIC_API_KEY" in result.output
130130

131-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
131+
@patch("codeframe.core.llm_resolution.create_provider")
132132
def test_command_starts_discovery(
133133
self, mock_provider_class, workspace_dir: Path, monkeypatch, mock_llm_provider
134134
):
@@ -153,7 +153,7 @@ def test_command_starts_discovery(
153153
# Should start discovery
154154
assert "AI-driven" in result.output or "discovery" in result.output.lower()
155155

156-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
156+
@patch("codeframe.core.llm_resolution.create_provider")
157157
def test_existing_prd_prompts_confirmation(
158158
self, mock_provider_class, workspace_dir: Path, monkeypatch, mock_llm_provider
159159
):
@@ -179,7 +179,7 @@ def test_existing_prd_prompts_confirmation(
179179

180180
assert "Cancelled" in result.output or "existing" in result.output.lower()
181181

182-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
182+
@patch("codeframe.core.llm_resolution.create_provider")
183183
def test_help_command_shows_commands(
184184
self, mock_provider_class, workspace_dir: Path, monkeypatch, mock_llm_provider
185185
):
@@ -203,7 +203,7 @@ def test_help_command_shows_commands(
203203
assert "/pause" in result.output
204204
assert "/quit" in result.output
205205

206-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
206+
@patch("codeframe.core.llm_resolution.create_provider")
207207
def test_pause_saves_progress(
208208
self, mock_provider_class, workspace_dir: Path, monkeypatch, mock_llm_provider
209209
):
@@ -231,7 +231,7 @@ def test_pause_saves_progress(
231231
class TestPrdGenerateResume:
232232
"""Tests for resuming paused discovery sessions."""
233233

234-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
234+
@patch("codeframe.core.llm_resolution.create_provider")
235235
def test_resume_with_invalid_blocker(
236236
self, mock_provider_class, workspace_dir: Path, monkeypatch, mock_llm_provider
237237
):
@@ -252,7 +252,7 @@ def test_resume_with_invalid_blocker(
252252
class TestPrdGenerateComplete:
253253
"""Tests for completing discovery and generating PRD."""
254254

255-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
255+
@patch("codeframe.core.llm_resolution.create_provider")
256256
def test_complete_discovery_generates_prd(
257257
self, mock_provider_class, workspace_dir: Path, monkeypatch
258258
):
@@ -330,7 +330,7 @@ def complete_side_effect(messages, **kwargs):
330330
prd_record = prd.get_latest(workspace)
331331
assert prd_record is not None
332332

333-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
333+
@patch("codeframe.core.llm_resolution.create_provider")
334334
def test_generated_prd_shows_preview(
335335
self, mock_provider_class, workspace_dir: Path, monkeypatch
336336
):
@@ -402,7 +402,7 @@ def complete_side_effect(messages, **kwargs):
402402
class TestPrdGenerateValidation:
403403
"""Tests for AI validation during discovery."""
404404

405-
@patch("codeframe.core.prd_discovery.AnthropicProvider")
405+
@patch("codeframe.core.llm_resolution.create_provider")
406406
def test_ai_rejects_vague_answer(
407407
self, mock_provider_class, workspace_dir: Path, monkeypatch
408408
):

0 commit comments

Comments
 (0)