Skip to content

Commit 21785ae

Browse files
authored
feat(adapters): multi-provider LLM support — #545–549 (#552)
Completes the remaining subissues of #542 (multi-provider LLM): - #546/#547: worker agents use LLMProvider abstraction (no AsyncAnthropic) - #545: --llm-provider / --llm-model CLI flags on cf work start and batch run - #549: llm: block in .codeframe/config.yaml with priority chain: config < env < CLI - Common exception hierarchy (LLMAuthError, LLMRateLimitError, LLMConnectionError) - async_complete() on all providers; CRITICAL-1 timeout restored via asyncio.wait_for Closes #545, #546, #547, #549. Part of #542.
1 parent bdb6686 commit 21785ae

23 files changed

Lines changed: 826 additions & 305 deletions

.github/workflows/test.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,6 @@ jobs:
222222
223223
- name: Run pytest (v2 suite) with coverage
224224
timeout-minutes: 15
225-
env:
226-
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
227225
run: |
228226
uv run pytest tests/ \
229227
--ignore=tests/e2e \

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ cf tasks show <id>
158158
# Work — single task
159159
cf work start <task-id> [--execute] [--engine react|plan] [--verbose] [--dry-run]
160160
cf work start <task-id> --execute --stall-timeout 120 --stall-action retry|blocker|fail
161+
cf work start <task-id> --execute --llm-provider openai --llm-model gpt-4o
161162
cf work stop <task-id>
162163
cf work resume <task-id>
163164
cf work follow <task-id> [--tail 50]
@@ -166,6 +167,7 @@ cf work diagnose <task-id>
166167
# Work — batch
167168
cf work batch run [<id>...] [--all-ready] [--engine react|plan]
168169
cf work batch run --strategy serial|parallel|auto [--max-parallel 4] [--retry 3]
170+
cf work batch run --all-ready --llm-provider openai --llm-model qwen2.5-coder:7b
169171
cf work batch status|cancel|resume [batch_id]
170172

171173
# Blockers
@@ -241,10 +243,16 @@ E2B_API_KEY=e2b_... # Required for --engine cloud
241243
DATABASE_PATH=./codeframe.db # Optional
242244

243245
# LLM Provider selection (multi-provider support)
246+
# Priority: CLI flag > env var > .codeframe/config.yaml > default (anthropic)
244247
CODEFRAME_LLM_PROVIDER=anthropic # Provider: anthropic (default), openai, ollama, vllm, compatible
245248
CODEFRAME_LLM_MODEL=gpt-4o # Model override (used with openai/ollama/vllm/compatible)
246249
OPENAI_API_KEY=sk-... # Required for openai provider; not needed for local providers
247250
OPENAI_BASE_URL=http://localhost:11434/v1 # Base URL override (for ollama, vllm, or custom endpoints)
251+
# Per-workspace config: .codeframe/config.yaml supports llm: block
252+
# llm:
253+
# provider: openai
254+
# model: qwen2.5-coder:7b
255+
# base_url: http://localhost:11434/v1 # optional, for local models
248256

249257
# Optional — Rate limiting
250258
RATE_LIMIT_ENABLED=true

codeframe/adapters/llm/anthropic.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def __init__(
6464
"or configure via 'codeframe auth setup --provider anthropic'."
6565
)
6666
self._client = None
67+
self._async_client = None
6768

6869
@property
6970
def client(self):
@@ -120,6 +121,57 @@ def complete(
120121
# Parse response
121122
return self._parse_response(response)
122123

124+
async def async_complete(
125+
self,
126+
messages: list[dict],
127+
purpose: Purpose = Purpose.EXECUTION,
128+
tools: Optional[list[Tool]] = None,
129+
max_tokens: int = 4096,
130+
temperature: float = 0.0,
131+
system: Optional[str] = None,
132+
) -> LLMResponse:
133+
"""True async completion via AsyncAnthropic.
134+
135+
Raises LLMAuthError / LLMRateLimitError / LLMConnectionError on failure.
136+
"""
137+
from anthropic import AsyncAnthropic
138+
from anthropic import (
139+
AuthenticationError,
140+
RateLimitError,
141+
APIConnectionError,
142+
)
143+
from codeframe.adapters.llm.base import (
144+
LLMAuthError,
145+
LLMRateLimitError,
146+
LLMConnectionError,
147+
)
148+
149+
if self._async_client is None:
150+
self._async_client = AsyncAnthropic(api_key=self.api_key)
151+
152+
model = self.get_model(purpose)
153+
kwargs: dict = {
154+
"model": model,
155+
"max_tokens": max_tokens,
156+
"messages": self._convert_messages(messages),
157+
}
158+
if temperature > 0:
159+
kwargs["temperature"] = temperature
160+
if system:
161+
kwargs["system"] = system
162+
if tools:
163+
kwargs["tools"] = self._convert_tools(tools)
164+
165+
try:
166+
response = await self._async_client.messages.create(**kwargs)
167+
return self._parse_response(response)
168+
except AuthenticationError as exc:
169+
raise LLMAuthError(str(exc)) from exc
170+
except RateLimitError as exc:
171+
raise LLMRateLimitError(str(exc)) from exc
172+
except APIConnectionError as exc:
173+
raise LLMConnectionError(str(exc)) from exc
174+
123175
def stream(
124176
self,
125177
messages: list[dict],

codeframe/adapters/llm/base.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,35 @@
44
along with shared data structures for requests and responses.
55
"""
66

7+
import asyncio
78
import os
89
from abc import ABC, abstractmethod
910
from dataclasses import dataclass, field
1011
from enum import Enum
1112
from typing import Iterator, Optional
1213

1314

15+
# ---------------------------------------------------------------------------
16+
# Common exception hierarchy
17+
# ---------------------------------------------------------------------------
18+
19+
20+
class LLMError(Exception):
21+
"""Base exception for LLM provider errors."""
22+
23+
24+
class LLMAuthError(LLMError):
25+
"""Authentication failure (bad key, expired token, etc.)."""
26+
27+
28+
class LLMRateLimitError(LLMError):
29+
"""Rate limit exceeded — caller may retry after a backoff."""
30+
31+
32+
class LLMConnectionError(LLMError):
33+
"""Network or connection error."""
34+
35+
1436
class Purpose(str, Enum):
1537
"""Purpose of an LLM call, used for model selection."""
1638

@@ -277,6 +299,39 @@ def stream(
277299
)
278300
yield response.content
279301

302+
async def async_complete(
303+
self,
304+
messages: list[dict],
305+
purpose: Purpose = Purpose.EXECUTION,
306+
tools: Optional[list["Tool"]] = None,
307+
max_tokens: int = 4096,
308+
temperature: float = 0.0,
309+
system: Optional[str] = None,
310+
) -> "LLMResponse":
311+
"""Async completion.
312+
313+
Default implementation wraps the synchronous :meth:`complete` in a
314+
thread-pool executor so it never blocks the event loop. Subclasses
315+
should override this with a truly async implementation when the
316+
underlying SDK supports it.
317+
318+
Args:
319+
messages: Conversation messages
320+
purpose: Purpose of call (for model selection)
321+
tools: Available tools for the model to use
322+
max_tokens: Maximum tokens to generate
323+
temperature: Sampling temperature
324+
system: System prompt
325+
326+
Returns:
327+
LLMResponse with content and/or tool calls
328+
"""
329+
loop = asyncio.get_running_loop()
330+
return await loop.run_in_executor(
331+
None,
332+
lambda: self.complete(messages, purpose, tools, max_tokens, temperature, system),
333+
)
334+
280335
def get_model(self, purpose: Purpose) -> str:
281336
"""Get the model for a given purpose.
282337

codeframe/adapters/llm/openai.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def __init__(
7878
)
7979

8080
self._client = None
81+
self._async_client = None
8182

8283
def get_model(self, purpose: Purpose) -> str:
8384
"""Return the model for a given purpose.
@@ -146,6 +147,55 @@ def complete(
146147

147148
return self._parse_response(response)
148149

150+
async def async_complete(
151+
self,
152+
messages: list[dict],
153+
purpose: Purpose = Purpose.EXECUTION,
154+
tools: Optional[list[Tool]] = None,
155+
max_tokens: int = 4096,
156+
temperature: float = 0.0,
157+
system: Optional[str] = None,
158+
) -> LLMResponse:
159+
"""True async completion via openai.AsyncOpenAI.
160+
161+
Raises LLMAuthError / LLMRateLimitError / LLMConnectionError on failure.
162+
"""
163+
import openai as _openai
164+
from codeframe.adapters.llm.base import (
165+
LLMAuthError,
166+
LLMRateLimitError,
167+
LLMConnectionError,
168+
)
169+
170+
if self._async_client is None:
171+
self._async_client = _openai.AsyncOpenAI(
172+
api_key=self.api_key, base_url=self.base_url
173+
)
174+
175+
converted = self._convert_messages(messages)
176+
if system:
177+
converted = [{"role": "system", "content": system}] + converted
178+
179+
kwargs: dict = {
180+
"model": self.get_model(purpose),
181+
"max_tokens": max_tokens,
182+
"messages": converted,
183+
"temperature": temperature,
184+
}
185+
if tools:
186+
kwargs["tools"] = self._convert_tools(tools)
187+
kwargs["tool_choice"] = "auto"
188+
189+
try:
190+
response = await self._async_client.chat.completions.create(**kwargs)
191+
return self._parse_response(response)
192+
except _openai.AuthenticationError as exc:
193+
raise LLMAuthError(str(exc)) from exc
194+
except _openai.RateLimitError as exc:
195+
raise LLMRateLimitError(str(exc)) from exc
196+
except _openai.APIConnectionError as exc:
197+
raise LLMConnectionError(str(exc)) from exc
198+
149199
def stream(
150200
self,
151201
messages: list[dict],

codeframe/agents/frontend_worker_agent.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
following project conventions (Tailwind CSS, functional components).
66
"""
77

8-
import os
98
import json
109
import logging
1110
import asyncio
1211
from pathlib import Path
1312
from typing import Dict, Any, Optional
14-
from anthropic import AsyncAnthropic
1513

14+
from codeframe.adapters.llm.base import Purpose
1615
from codeframe.core.models import Task, AgentMaturity
1716
from codeframe.agents.worker_agent import WorkerAgent
1817

@@ -59,8 +58,8 @@ def __init__(
5958
system_prompt=self._build_system_prompt(),
6059
db=db,
6160
)
62-
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
63-
self.client = AsyncAnthropic(api_key=self.api_key) if self.api_key else None
61+
# api_key kept for backwards compatibility; LLM calls use self.llm_provider
62+
self.api_key = api_key
6463
self.websocket_manager = websocket_manager
6564
self.project_root = Path(__file__).parent.parent.parent # codeframe/
6665
self.web_ui_root = self.project_root / "web-ui"
@@ -293,10 +292,6 @@ async def _generate_react_component(self, spec: Dict[str, Any]) -> str:
293292
Returns:
294293
Component code as string
295294
"""
296-
if not self.client:
297-
# Fallback: generate basic component template
298-
return self._generate_basic_component_template(spec)
299-
300295
prompt = f"""Generate a React functional component with the following specification:
301296
302297
Component Name: {spec['name']}
@@ -312,14 +307,14 @@ async def _generate_react_component(self, spec: Dict[str, Any]) -> str:
312307
Provide ONLY the component code, no explanations."""
313308

314309
try:
315-
response = await self.client.messages.create(
316-
model="claude-3-5-sonnet-20241022",
317-
max_tokens=2000,
310+
response = await self.llm_provider.async_complete(
318311
messages=[{"role": "user", "content": prompt}],
312+
purpose=Purpose.GENERATION,
313+
max_tokens=2000,
319314
)
320315

321316
# Extract code from response
322-
code = response.content[0].text
317+
code = response.content
323318

324319
# Remove markdown code blocks if present
325320
if "```" in code:

codeframe/agents/test_worker_agent.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
analyzing code for test requirements, and self-correcting failing tests.
66
"""
77

8-
import os
98
import sys
109
import json
1110
import logging
@@ -14,8 +13,8 @@
1413
import re
1514
from pathlib import Path
1615
from typing import Dict, Any, Optional, Tuple
17-
from anthropic import AsyncAnthropic
1816

17+
from codeframe.adapters.llm.base import Purpose
1918
from codeframe.core.models import Task, AgentMaturity
2019
from codeframe.agents.worker_agent import WorkerAgent
2120

@@ -67,8 +66,8 @@ def __init__(
6766
system_prompt=self._build_system_prompt(),
6867
db=db,
6968
)
70-
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
71-
self.client = AsyncAnthropic(api_key=self.api_key) if self.api_key else None
69+
# api_key kept for backwards compatibility; LLM calls use self.llm_provider
70+
self.api_key = api_key
7271
self.websocket_manager = websocket_manager
7372
self.max_correction_attempts = max_correction_attempts
7473
self.project_root = Path(__file__).parent.parent.parent
@@ -321,9 +320,6 @@ async def _generate_pytest_tests(
321320
Returns:
322321
Generated test code
323322
"""
324-
if not self.client:
325-
return self._generate_basic_test_template(spec, code_analysis)
326-
327323
# Build context from code analysis
328324
context = ""
329325
if code_analysis.get("functions"):
@@ -351,13 +347,13 @@ async def _generate_pytest_tests(
351347
Provide ONLY the test code, no explanations."""
352348

353349
try:
354-
response = await self.client.messages.create(
355-
model="claude-3-5-sonnet-20241022",
356-
max_tokens=3000,
350+
response = await self.llm_provider.async_complete(
357351
messages=[{"role": "user", "content": prompt}],
352+
purpose=Purpose.GENERATION,
353+
max_tokens=3000,
358354
)
359355

360-
code = response.content[0].text
356+
code = response.content
361357

362358
# Remove markdown code blocks
363359
if "```" in code:
@@ -671,9 +667,6 @@ async def _correct_failing_tests(
671667
Returns:
672668
Corrected test code or None
673669
"""
674-
if not self.client:
675-
return None
676-
677670
prompt = f"""Fix the following failing pytest tests:
678671
679672
Original Test Code:
@@ -696,13 +689,13 @@ async def _correct_failing_tests(
696689
Provide ONLY the corrected test code, no explanations."""
697690

698691
try:
699-
response = await self.client.messages.create(
700-
model="claude-3-5-sonnet-20241022",
701-
max_tokens=3000,
692+
response = await self.llm_provider.async_complete(
702693
messages=[{"role": "user", "content": prompt}],
694+
purpose=Purpose.CORRECTION,
695+
max_tokens=3000,
703696
)
704697

705-
code = response.content[0].text
698+
code = response.content
706699

707700
# Remove markdown code blocks
708701
if "```" in code:

0 commit comments

Comments
 (0)