Skip to content

Commit 8720002

Browse files
abossardCopilot
andcommitted
feat: add LiteLLM fallback, Playwright tests, and remove OpenAI hard dependency
- LiteLLM is now the default LLM backend (no .env or API key needed) - Multistage model fallback chain: claude-sonnet-4 → gpt-4o → gpt-4o-mini - OpenAI SDK still used when OPENAI_API_KEY is explicitly set - agents.py and workbench service use ChatLiteLLM when no OpenAI key - Added csv_ticket_stats and csv_sla_breach_tickets to agent tools - Added KBA Drafter to Playwright nav tests and menu screenshots - Added e2e tests: publish, delete, status filter, ticket viewer - 32 unit tests + 5 live integration tests for LLM service - Updated .env.example with LiteLLM-first documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c48612d commit 8720002

10 files changed

Lines changed: 1093 additions & 222 deletions

File tree

.env.example

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
# OpenAI Configuration
2-
# Get your API key from: https://platform.openai.com/api-keys
1+
# LiteLLM Configuration (Default LLM backend)
2+
# Works out of the box with GitHub Copilot — no API keys needed.
3+
# Supports 100+ providers: GitHub Copilot, Ollama, Anthropic, etc.
4+
# Docs: https://docs.litellm.ai/docs/providers
35

4-
OPENAI_API_KEY=your-openai-api-key-here
5-
OPENAI_MODEL=gpt-4o-mini
6-
# Optional override
7-
# OPENAI_BASE_URL=https://api.openai.com/v1
6+
# Primary model (default: github_copilot/gpt-4o)
7+
# LITELLM_MODEL=github_copilot/gpt-4o
88

9-
# Ollama Configuration (for KBA Drafter)
10-
# Install Ollama: curl -fsSL https://ollama.com/install.sh | sh
11-
# Pull model: ollama pull llama3.2:1b
12-
# Start server: ollama serve
9+
# Fallback chain (comma-separated, tried in order if primary fails)
10+
# LITELLM_FALLBACK_MODELS=github_copilot/claude-sonnet-4,github_copilot/gpt-4o,github_copilot/gpt-4o-mini
1311

14-
OLLAMA_BASE_URL=http://localhost:11434
15-
OLLAMA_MODEL=llama3.2:1b
16-
OLLAMA_TIMEOUT=60
12+
# OpenAI Configuration (Optional — overrides LiteLLM when set)
13+
# Get your API key from: https://platform.openai.com/api-keys
14+
# If set, uses OpenAI SDK directly with beta.chat.completions.parse()
15+
16+
# OPENAI_API_KEY=your-openai-api-key-here
17+
# OPENAI_MODEL=gpt-4o-mini
18+
# OPENAI_BASE_URL=https://api.openai.com/v1
1719

1820
# Knowledge Base Publishing Configuration (for KBA Drafter)
1921
# FileSystem Adapter (MVP - writes markdown files)

backend/agent_workbench/service.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,21 @@ def on_tool_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) ->
7575
# ============================================================================
7676

7777
def _build_llm(model: str, api_key: str, base_url: str = "") -> Any:
78-
from langchain_openai import ChatOpenAI
79-
return ChatOpenAI(
80-
model=model,
81-
api_key=api_key,
82-
base_url=base_url or None,
83-
temperature=0.0,
84-
)
78+
if api_key:
79+
from langchain_openai import ChatOpenAI
80+
return ChatOpenAI(
81+
model=model,
82+
api_key=api_key,
83+
base_url=base_url or None,
84+
temperature=0.0,
85+
)
86+
else:
87+
from langchain_litellm import ChatLiteLLM
88+
litellm_model = os.getenv("LITELLM_MODEL", "github_copilot/gpt-4o")
89+
return ChatLiteLLM(
90+
model=litellm_model,
91+
temperature=0.0,
92+
)
8593

8694

8795
def _build_react_agent(llm: Any, tools: list[Any], system_prompt: str) -> Any:
@@ -147,11 +155,6 @@ def __init__(
147155
@property
148156
def llm(self) -> Any:
149157
if self._llm is None:
150-
if not self._api_key:
151-
raise ValueError(
152-
"OPENAI_API_KEY is required to run agents. "
153-
"Set it via environment variable or pass openai_api_key."
154-
)
155158
self._llm = _build_llm(self._model, self._api_key, self._base_url)
156159
return self._llm
157160

backend/agents.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,7 @@ def _env_int(name: str, default: int) -> int:
8484
from langchain_core.callbacks import BaseCallbackHandler
8585
from langchain_core.tools import StructuredTool
8686

87-
# Third-party - LangChain and LangGraph
88-
from langchain_openai import ChatOpenAI
87+
# Third-party - LangGraph
8988
from langgraph.prebuilt import create_react_agent
9089

9190
# Third-party - Pydantic for validation
@@ -173,12 +172,13 @@ class AgentResponse(BaseModel):
173172

174173

175174
# ============================================================================
176-
# CONFIGURATION - OpenAI settings
175+
# CONFIGURATION - LLM settings (LiteLLM default, OpenAI optional)
177176
# ============================================================================
178177

179178
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
180179
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
181180
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "") # optional override
181+
LITELLM_MODEL = os.getenv("LITELLM_MODEL", "github_copilot/gpt-4o")
182182
OPENAI_CALL_LOGGING_ENABLED = _env_flag("OPENAI_CALL_LOGGING_ENABLED", "true")
183183
AGENT_EFFICIENCY_MODE = _env_flag("AGENT_EFFICIENCY_MODE", "true")
184184
AGENT_TRACE_ENABLED = _env_flag("AGENT_TRACE_ENABLED", "false")
@@ -409,28 +409,27 @@ class AgentService:
409409

410410
def __init__(self):
411411
"""
412-
Initialize the agent service with OpenAI.
412+
Initialize the agent service.
413413
414-
Validates that required environment variables are set and creates
415-
the LLM client for agent execution.
416-
417-
Raises:
418-
ValueError: If OpenAI configuration is incomplete
414+
Uses OpenAI when OPENAI_API_KEY is set, otherwise falls back
415+
to LiteLLM (supports GitHub Copilot, Ollama, etc.).
419416
"""
420-
# Validate configuration
421-
if not OPENAI_API_KEY:
422-
raise ValueError(
423-
"OpenAI API key not set. "
424-
"Please set OPENAI_API_KEY environment variable."
417+
if OPENAI_API_KEY:
418+
from langchain_openai import ChatOpenAI
419+
self.llm = ChatOpenAI(
420+
model=OPENAI_MODEL,
421+
api_key=OPENAI_API_KEY,
422+
base_url=OPENAI_BASE_URL or None,
423+
temperature=0.0,
425424
)
426-
427-
# Initialize ChatOpenAI
428-
self.llm = ChatOpenAI(
429-
model=OPENAI_MODEL,
430-
api_key=OPENAI_API_KEY,
431-
base_url=OPENAI_BASE_URL or None,
432-
temperature=0.0,
433-
)
425+
logger.info(f"AgentService using OpenAI: {OPENAI_MODEL}")
426+
else:
427+
from langchain_litellm import ChatLiteLLM
428+
self.llm = ChatLiteLLM(
429+
model=LITELLM_MODEL,
430+
temperature=0.0,
431+
)
432+
logger.info(f"AgentService using LiteLLM: {LITELLM_MODEL}")
434433

435434
# CSV tools only (do not expose operations or external MCP)
436435
self.tools = self._build_csv_tools()
@@ -561,6 +560,27 @@ def _csv_ticket_fields() -> str:
561560
from tickets import Ticket
562561
return json.dumps(list(Ticket.model_fields.keys()))
563562

563+
def _csv_ticket_stats() -> str:
564+
"""Get aggregated statistics for CSV tickets."""
565+
from collections import Counter
566+
tickets = service.list_tickets()
567+
by_status = Counter(t.status.value for t in tickets)
568+
by_priority = Counter(t.priority.value for t in tickets)
569+
by_group = Counter(t.assigned_group for t in tickets if t.assigned_group)
570+
unassigned = sum(1 for t in tickets if t.assignee is None and t.assigned_group is not None)
571+
return json.dumps({
572+
"total": len(tickets), "unassigned": unassigned,
573+
"by_status": dict(by_status), "by_priority": dict(by_priority),
574+
"by_group": dict(by_group.most_common(10)),
575+
}, default=str)
576+
577+
def _csv_sla_breach_tickets(unassigned_only: bool = True, include_ok: bool = False) -> str:
578+
"""Return tickets at SLA breach risk with pre-computed age and status."""
579+
from tickets import get_sla_breach_report
580+
tickets = service.list_tickets(has_assignee=False if unassigned_only else None)
581+
report = get_sla_breach_report(tickets, reference_time=None, include_ok=include_ok)
582+
return json.dumps(report.model_dump() if hasattr(report, 'model_dump') else report, default=str)
583+
564584
return [
565585
StructuredTool.from_function(
566586
func=_csv_list_tickets,
@@ -604,6 +624,21 @@ def _csv_ticket_fields() -> str:
604624
name="csv_ticket_fields",
605625
description="List available ticket fields (schema) as JSON array of field names.",
606626
),
627+
StructuredTool.from_function(
628+
func=_csv_ticket_stats,
629+
name="csv_ticket_stats",
630+
description="Get aggregated statistics: total, unassigned, by_status, by_priority, by_group. Returns JSON.",
631+
),
632+
StructuredTool.from_function(
633+
func=_csv_sla_breach_tickets,
634+
name="csv_sla_breach_tickets",
635+
description=(
636+
"Return tickets at SLA breach risk. Pre-computed age_hours, sla_threshold_hours, breach_status. "
637+
"SLA thresholds: critical=4h, high=24h, medium=72h, low=120h. "
638+
"unassigned_only (default true) filters to group-assigned but no individual. "
639+
"include_ok (default false) adds tickets within SLA window. Returns JSON."
640+
),
641+
),
607642
]
608643

609644
async def run_agent(self, request: AgentRequest) -> AgentResponse:

0 commit comments

Comments
 (0)