fix(prompter): use the local Ollama LLM instead of the Anthropic cloud API

The Prompter service called AsyncAnthropic, which needs an ANTHROPIC_API_KEY
and 500'd in production ("Anthropic API key not configured"). Align it with the
rest of the system (RAG / HyDE): call the local LLM over the OpenAI-compatible
/chat/completions endpoint (settings.local_llm_*) via httpx — no external key.

- Replace the Anthropic client and _extract_text with a single _create_message
  seam that POSTs to the local LLM and returns the reply text.
- Move the system prompt into an OpenAI-style system message; drop the
  hard-coded Claude model and the per-call model parameter.
- Strip a wrapping markdown code fence before parsing the draft JSON (local
  models often wrap it).
- Repoint the prompter unit and route tests at the new seam (return strings).
This commit is contained in:
Renn F
2026-06-08 17:04:35 +02:00
parent f2df7fc30b
commit ae65bff883
3 changed files with 128 additions and 203 deletions
+48 -42
View File
@@ -2,8 +2,9 @@
Prompter Service Prompter Service
Conversational LLM assistant that helps users draft tasks. Conversational LLM assistant that helps users draft tasks.
Uses Anthropic Claude for natural-language interaction and Uses the project's local LLM (Ollama, OpenAI-compatible) for
structured JSON draft generation. natural-language interaction and structured JSON draft generation
the same engine as RAG/HyDE, so no external API key is required.
Provides both a session-based approach (DB-persisted) and a Provides both a session-based approach (DB-persisted) and a
legacy stateless interface for backward compatibility. legacy stateless interface for backward compatibility.
@@ -18,8 +19,8 @@ from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import httpx
import structlog import structlog
from anthropic import AsyncAnthropic
from sqlalchemy import select from sqlalchemy import select
from roboco.config import settings from roboco.config import settings
@@ -124,26 +125,31 @@ class PrompterService:
def __init__(self, db: AsyncSession | None = None) -> None: def __init__(self, db: AsyncSession | None = None) -> None:
self.log = logger.bind(component="prompter_service") self.log = logger.bind(component="prompter_service")
self._client: AsyncAnthropic | None = None
self._db = db self._db = db
def _get_client(self) -> AsyncAnthropic: async def _create_message(
"""Lazy-init Anthropic client.""" self, *, messages: list[dict[str, str]], max_tokens: int
if self._client is None: ) -> str:
api_key = settings.anthropic_api_key """Call the local LLM and return the reply text.
if not api_key:
raise ServiceError("Anthropic API key not configured")
self._client = AsyncAnthropic(api_key=api_key)
return self._client
async def _create_message(self, **kwargs: Any) -> Any: Uses the project's local LLM — the same OpenAI-compatible Ollama
"""Single seam for the Anthropic ``messages.create`` call. endpoint as RAG/HyDE (``settings.local_llm_*``), so no external API key
is required. ``messages`` is an OpenAI-style list (system + turns). This
The SDK exposes ``messages`` as a cached_property, so it can't be is the single seam the prompter tests substitute.
patched at the client-class level; tests substitute this method.
""" """
client = self._get_client() async with httpx.AsyncClient(timeout=120.0) as client:
return await client.messages.create(**kwargs) resp = await client.post(
f"{settings.local_llm_base_url}/chat/completions",
json={
"model": settings.local_llm_model,
"messages": messages,
"max_tokens": max_tokens,
"options": {"num_ctx": 8192},
},
)
resp.raise_for_status()
data = resp.json()
return str(data["choices"][0]["message"]["content"] or "").strip()
@property @property
def _session(self) -> AsyncSession: def _session(self) -> AsyncSession:
@@ -438,23 +444,22 @@ class PrompterService:
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
context: dict[str, Any] | None = None, context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 2048, max_tokens: int = 2048,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call the LLM for a chat response. Returns {message, draft_ready}.""" """Call the LLM for a chat response. Returns {message, draft_ready}."""
user_prompt = _build_chat_prompt(messages, context) user_prompt = _build_chat_prompt(messages, context)
try: try:
response = await self._create_message( content = await self._create_message(
model=model, messages=[
{"role": "system", "content": _PROMPTER_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
max_tokens=max_tokens, max_tokens=max_tokens,
system=_PROMPTER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
) )
except Exception as e: except Exception as e:
self.log.error("Prompter chat LLM call failed", error=str(e)) self.log.error("Prompter chat LLM call failed", error=str(e))
raise ServiceError(f"LLM chat failed: {e}") from e raise ServiceError(f"LLM chat failed: {e}") from e
content = _extract_text(response)
if not content: if not content:
raise ServiceError("LLM returned empty content") raise ServiceError("LLM returned empty content")
@@ -467,28 +472,27 @@ class PrompterService:
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
context: dict[str, Any] | None = None, context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 4096, max_tokens: int = 4096,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call the LLM to generate a structured draft. Returns {draft, reasoning}.""" """Call the LLM to generate a structured draft. Returns {draft, reasoning}."""
user_prompt = _build_draft_prompt(messages, context) user_prompt = _build_draft_prompt(messages, context)
try: try:
response = await self._create_message( content = await self._create_message(
model=model, messages=[
{"role": "system", "content": _DRAFT_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
max_tokens=max_tokens, max_tokens=max_tokens,
system=_DRAFT_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
) )
except Exception as e: except Exception as e:
self.log.error("Prompter draft LLM call failed", error=str(e)) self.log.error("Prompter draft LLM call failed", error=str(e))
raise ServiceError(f"LLM draft generation failed: {e}") from e raise ServiceError(f"LLM draft generation failed: {e}") from e
content = _extract_text(response)
if not content: if not content:
raise ServiceError("LLM returned empty content for draft") raise ServiceError("LLM returned empty content for draft")
try: try:
draft_data = json.loads(content) draft_data = json.loads(_strip_code_fences(content))
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
self.log.warning("Draft JSON parse failed", content_preview=content[:200]) self.log.warning("Draft JSON parse failed", content_preview=content[:200])
raise ValidationError( raise ValidationError(
@@ -511,14 +515,12 @@ class PrompterService:
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
context: dict[str, Any] | None = None, context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 2048, max_tokens: int = 2048,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Continue a Prompter conversation (stateless).""" """Continue a Prompter conversation (stateless)."""
return await self._llm_chat( return await self._llm_chat(
messages=messages, messages=messages,
context=context, context=context,
model=model,
max_tokens=max_tokens, max_tokens=max_tokens,
) )
@@ -526,14 +528,12 @@ class PrompterService:
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
context: dict[str, Any] | None = None, context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 4096, max_tokens: int = 4096,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Generate a structured task draft from conversation context (stateless).""" """Generate a structured task draft from conversation context (stateless)."""
return await self._llm_draft( return await self._llm_draft(
messages=messages, messages=messages,
context=context, context=context,
model=model,
max_tokens=max_tokens, max_tokens=max_tokens,
) )
@@ -590,12 +590,18 @@ def _build_draft_prompt(
return "\n".join(lines) return "\n".join(lines)
def _extract_text(response: Any) -> str: def _strip_code_fences(content: str) -> str:
text_parts: list[str] = [] """Strip a wrapping markdown code fence (```json ... ```) if present.
for block in getattr(response, "content", []):
if hasattr(block, "text"): Local models often wrap JSON output in a fenced block; drop the opening
text_parts.append(block.text) fence line and the closing fence so the body parses cleanly as JSON.
return "\n".join(text_parts).strip() """
text = content.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
return text.strip()
def _detect_draft_ready(content: str) -> bool: def _detect_draft_ready(content: str) -> bool:
+22 -54
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -163,8 +163,7 @@ async def test_send_message_success(prompter_client: dict) -> None:
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"] session_id = session_resp.json()["id"]
mock_response = MagicMock() mock_response = "Great! Let's gather requirements."
mock_response.content = [MagicMock(text="Great! Let's gather requirements.")]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -194,15 +193,9 @@ async def test_send_message_marks_draft_ready(prompter_client: dict) -> None:
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"] session_id = session_resp.json()["id"]
mock_response = MagicMock() mock_response = (
mock_response.content = [ "I have enough information to draft a task now. Ready to draft when you are."
MagicMock(
text=(
"I have enough information to draft a task now. "
"Ready to draft when you are."
) )
)
]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -254,11 +247,9 @@ async def test_get_draft_generates_from_conversation(prompter_client: dict) -> N
"priority": 2, "priority": 2,
} }
chat_response = MagicMock() chat_response = "Tell me more about the requirements."
chat_response.content = [MagicMock(text="Tell me more about the requirements.")]
draft_response = MagicMock() draft_response = json.dumps(draft_json)
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -308,10 +299,8 @@ async def test_get_draft_cached(prompter_client: dict) -> None:
"estimated_complexity": "medium", "estimated_complexity": "medium",
"priority": 2, "priority": 2,
} }
chat_response = MagicMock() chat_response = "Got it."
chat_response.content = [MagicMock(text="Got it.")] draft_response = json.dumps(draft_json)
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -326,7 +315,7 @@ async def test_get_draft_cached(prompter_client: dict) -> None:
call_count = 0 call_count = 0
async def _mock_create(**_kwargs: Any) -> MagicMock: async def _mock_create(**_kwargs: Any) -> str:
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
return draft_response return draft_response
@@ -382,10 +371,8 @@ async def test_confirm_draft_creates_task(
"priority": 2, "priority": 2,
} }
chat_response = MagicMock() chat_response = "Got it."
chat_response.content = [MagicMock(text="Got it.")] draft_response = json.dumps(draft_json)
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -438,10 +425,8 @@ async def test_confirm_draft_requires_project_or_product(
"priority": 2, "priority": 2,
} }
chat_response = MagicMock() chat_response = "Got it."
chat_response.content = [MagicMock(text="Got it.")] draft_response = json.dumps(draft_json)
draft_response = MagicMock()
draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -489,10 +474,7 @@ async def test_full_happy_path(
session_id = step1.json()["id"] session_id = step1.json()["id"]
# Step 2: Send messages # Step 2: Send messages
chat_mock = MagicMock() chat_mock = "Please describe the acceptance criteria for this feature."
chat_mock.content = [
MagicMock(text="Please describe the acceptance criteria for this feature.")
]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -506,10 +488,7 @@ async def test_full_happy_path(
) )
assert step2a.status_code == HTTPStatus.OK assert step2a.status_code == HTTPStatus.OK
chat_mock2 = MagicMock() chat_mock2 = "I have enough information to draft a task now."
chat_mock2.content = [
MagicMock(text="I have enough information to draft a task now.")
]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
@@ -538,8 +517,7 @@ async def test_full_happy_path(
"estimated_complexity": "low", "estimated_complexity": "low",
"priority": 2, "priority": 2,
} }
draft_mock = MagicMock() draft_mock = json.dumps(draft_json)
draft_mock.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -574,8 +552,7 @@ async def test_full_happy_path(
async def test_prompter_chat_success(prompter_client: dict) -> None: async def test_prompter_chat_success(prompter_client: dict) -> None:
client = prompter_client["client"] client = prompter_client["client"]
mock_response = MagicMock() mock_response = "Great! Let's gather requirements."
mock_response.content = [MagicMock(text="Great! Let's gather requirements.")]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -600,15 +577,9 @@ async def test_prompter_chat_success(prompter_client: dict) -> None:
async def test_prompter_chat_draft_ready(prompter_client: dict) -> None: async def test_prompter_chat_draft_ready(prompter_client: dict) -> None:
client = prompter_client["client"] client = prompter_client["client"]
mock_response = MagicMock() mock_response = (
mock_response.content = [ "I have enough information. draft_ready=true. Ready to generate a draft."
MagicMock(
text=(
"I have enough information. draft_ready=true."
" Ready to generate a draft."
) )
)
]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -672,8 +643,7 @@ async def test_prompter_draft_success(prompter_client: dict) -> None:
"priority": 2, "priority": 2,
} }
mock_response = MagicMock() mock_response = json.dumps(draft_json)
mock_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -702,8 +672,7 @@ async def test_prompter_draft_success(prompter_client: dict) -> None:
async def test_prompter_draft_invalid_json_from_llm(prompter_client: dict) -> None: async def test_prompter_draft_invalid_json_from_llm(prompter_client: dict) -> None:
client = prompter_client["client"] client = prompter_client["client"]
mock_response = MagicMock() mock_response = "not valid json"
mock_response.content = [MagicMock(text="not valid json")]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
@@ -732,8 +701,7 @@ async def test_prompter_draft_schema_mismatch(prompter_client: dict) -> None:
"description": "too short", "description": "too short",
} }
mock_response = MagicMock() mock_response = json.dumps(bad_draft)
mock_response.content = [MagicMock(text=json.dumps(bad_draft))]
with patch( with patch(
"roboco.services.prompter.PrompterService._create_message", "roboco.services.prompter.PrompterService._create_message",
+52 -101
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import json import json
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -21,7 +21,6 @@ from roboco.services.prompter import (
_build_draft_prompt, _build_draft_prompt,
_build_reasoning, _build_reasoning,
_detect_draft_ready, _detect_draft_ready,
_extract_text,
get_prompter_service, get_prompter_service,
) )
@@ -53,30 +52,6 @@ def test_detect_draft_ready_negative() -> None:
assert not _detect_draft_ready(text), f"Expected False for: {text!r}" assert not _detect_draft_ready(text), f"Expected False for: {text!r}"
def test_extract_text_with_blocks() -> None:
block1 = MagicMock()
block1.text = "Hello, "
block2 = MagicMock()
block2.text = "world!"
response = MagicMock()
response.content = [block1, block2]
result = _extract_text(response)
assert result == "Hello, \nworld!"
def test_extract_text_empty_response() -> None:
response = MagicMock()
response.content = []
assert _extract_text(response) == ""
def test_extract_text_no_text_attr() -> None:
block = MagicMock(spec=[]) # no 'text' attribute
response = MagicMock()
response.content = [block]
assert _extract_text(response) == ""
def test_build_chat_prompt_basic() -> None: def test_build_chat_prompt_basic() -> None:
messages = [ messages = [
{"role": "user", "content": "I need a feature"}, {"role": "user", "content": "I need a feature"},
@@ -138,14 +113,12 @@ def test_get_prompter_service_raises_without_db_for_session_methods() -> None:
async def test_chat_success_with_mock_llm() -> None: async def test_chat_success_with_mock_llm() -> None:
service = get_prompter_service() service = get_prompter_service()
mock_response = MagicMock() with patch.object(
mock_response.content = [MagicMock(text="Great, let's continue!")] service,
"_create_message",
with patch.object(service, "_get_client") as mock_get_client: new_callable=AsyncMock,
mock_client = AsyncMock() return_value="Great, let's continue!",
mock_client.messages.create = AsyncMock(return_value=mock_response) ):
mock_get_client.return_value = mock_client
result = await service.chat( result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}] messages=[{"role": "user", "content": "I need a feature"}]
) )
@@ -158,16 +131,12 @@ async def test_chat_success_with_mock_llm() -> None:
async def test_chat_draft_ready_signal() -> None: async def test_chat_draft_ready_signal() -> None:
service = get_prompter_service() service = get_prompter_service()
mock_response = MagicMock() with patch.object(
mock_response.content = [ service,
MagicMock(text="I have enough information. Ready to draft.") "_create_message",
] new_callable=AsyncMock,
return_value="I have enough information. Ready to draft.",
with patch.object(service, "_get_client") as mock_get_client: ):
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.chat( result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}] messages=[{"role": "user", "content": "I need a feature"}]
) )
@@ -179,15 +148,12 @@ async def test_chat_draft_ready_signal() -> None:
async def test_chat_raises_on_empty_response() -> None: async def test_chat_raises_on_empty_response() -> None:
service = get_prompter_service() service = get_prompter_service()
mock_response = MagicMock() with (
mock_response.content = [] # Empty content blocks patch.object(
service, "_create_message", new_callable=AsyncMock, return_value=""
with patch.object(service, "_get_client") as mock_get_client: ),
mock_client = AsyncMock() pytest.raises(ServiceError, match="LLM returned empty content"),
mock_client.messages.create = AsyncMock(return_value=mock_response) ):
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM returned empty content"):
await service.chat(messages=[{"role": "user", "content": "Hello"}]) await service.chat(messages=[{"role": "user", "content": "Hello"}])
@@ -195,14 +161,15 @@ async def test_chat_raises_on_empty_response() -> None:
async def test_chat_raises_on_llm_error() -> None: async def test_chat_raises_on_llm_error() -> None:
service = get_prompter_service() service = get_prompter_service()
with patch.object(service, "_get_client") as mock_get_client: with (
mock_client = AsyncMock() patch.object(
mock_client.messages.create = AsyncMock( service,
side_effect=Exception("API unavailable") "_create_message",
) new_callable=AsyncMock,
mock_get_client.return_value = mock_client side_effect=Exception("API unavailable"),
),
with pytest.raises(ServiceError, match="LLM chat failed"): pytest.raises(ServiceError, match="LLM chat failed"),
):
await service.chat(messages=[{"role": "user", "content": "Hello"}]) await service.chat(messages=[{"role": "user", "content": "Hello"}])
@@ -220,14 +187,12 @@ async def test_draft_success_with_mock_llm() -> None:
"estimated_complexity": "medium", "estimated_complexity": "medium",
} }
mock_response = MagicMock() with patch.object(
mock_response.content = [MagicMock(text=json.dumps(draft_data))] service,
"_create_message",
with patch.object(service, "_get_client") as mock_get_client: new_callable=AsyncMock,
mock_client = AsyncMock() return_value=json.dumps(draft_data),
mock_client.messages.create = AsyncMock(return_value=mock_response) ):
mock_get_client.return_value = mock_client
result = await service.draft( result = await service.draft(
messages=[{"role": "user", "content": "I need a login feature"}] messages=[{"role": "user", "content": "I need a login feature"}]
) )
@@ -242,15 +207,15 @@ async def test_draft_success_with_mock_llm() -> None:
async def test_draft_raises_on_invalid_json() -> None: async def test_draft_raises_on_invalid_json() -> None:
service = get_prompter_service() service = get_prompter_service()
mock_response = MagicMock() with (
mock_response.content = [MagicMock(text="Not JSON at all")] patch.object(
service,
with patch.object(service, "_get_client") as mock_get_client: "_create_message",
mock_client = AsyncMock() new_callable=AsyncMock,
mock_client.messages.create = AsyncMock(return_value=mock_response) return_value="Not JSON at all",
mock_get_client.return_value = mock_client ),
pytest.raises(ValidationError, match="not valid JSON"),
with pytest.raises(ValidationError, match="not valid JSON"): ):
await service.draft(messages=[{"role": "user", "content": "Hello"}]) await service.draft(messages=[{"role": "user", "content": "Hello"}])
@@ -258,32 +223,18 @@ async def test_draft_raises_on_invalid_json() -> None:
async def test_draft_raises_on_llm_error() -> None: async def test_draft_raises_on_llm_error() -> None:
service = get_prompter_service() service = get_prompter_service()
with patch.object(service, "_get_client") as mock_get_client: with (
mock_client = AsyncMock() patch.object(
mock_client.messages.create = AsyncMock( service,
side_effect=Exception("API unavailable") "_create_message",
) new_callable=AsyncMock,
mock_get_client.return_value = mock_client side_effect=Exception("API unavailable"),
),
with pytest.raises(ServiceError, match="LLM draft generation failed"): pytest.raises(ServiceError, match="LLM draft generation failed"),
):
await service.draft(messages=[{"role": "user", "content": "Hello"}]) await service.draft(messages=[{"role": "user", "content": "Hello"}])
# =============================================================================
# API key validation
# =============================================================================
def test_get_client_raises_without_api_key() -> None:
service = get_prompter_service()
service._client = None # Force fresh init
with patch("roboco.services.prompter.settings") as mock_settings:
mock_settings.anthropic_api_key = None
with pytest.raises(ServiceError, match="Anthropic API key not configured"):
service._get_client()
# ============================================================================= # =============================================================================
# Session-based: create_session (DB-backed via conftest) # Session-based: create_session (DB-backed via conftest)
# ============================================================================= # =============================================================================