Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ logs/
csv/*.csv
screenshots/
*.png
.mcp.json
207 changes: 0 additions & 207 deletions CSV/data.csv

This file was deleted.

26 changes: 21 additions & 5 deletions backend/agent_builder/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@
Action layer: marshals HTTP ↔ service, no business logic.
"""

from pydantic import ValidationError
from quart import Blueprint, jsonify, request

import asyncio
import json

from pydantic import ValidationError
from quart import Blueprint, jsonify, request

from .engine.event_bus import agent_event_bus
from .engine.prompt_builder import DEFAULT_OUTPUT_SCHEMA
from .models import (
AgentDefinitionCreate,
AgentDefinitionUpdate,
AgentRunCreate,
CriteriaType,
RunStatus,
)
from .engine.prompt_builder import DEFAULT_OUTPUT_SCHEMA
from .engine.event_bus import agent_event_bus

agent_builder_bp = Blueprint("agent_builder", __name__)

Expand Down Expand Up @@ -119,6 +119,21 @@ async def workbench_ui_config():
"input_schema": op.get_mcp_input_schema(),
})

llm_catalog = {
"backend": "unknown",
"provider": None,
"default_model": "",
"fallback_models": [],
"available_models": [],
"source": "unavailable",
}
try:
from llm_service import get_llm_service

llm_catalog = get_llm_service().get_model_catalog()
except Exception:
pass

return jsonify({
"module": "agent_fabric",
"version": "2",
Expand All @@ -132,6 +147,7 @@ async def workbench_ui_config():
"max_tokens": 4096,
},
"llm_config_fields": ["model", "temperature", "recursion_limit", "max_tokens", "output_instructions", "output_schema"],
"llm": llm_catalog,
"default_output_schema": DEFAULT_OUTPUT_SCHEMA,
"endpoints": endpoints,
})
Expand Down
5 changes: 4 additions & 1 deletion backend/agent_builder/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))

import app as backend_app_module
from workbench_integration import _tool_registry

from agent_builder import WorkbenchService
from agent_builder.routes import configure_blueprint
from workbench_integration import _tool_registry


class _ToolCallMessage:
Expand Down Expand Up @@ -105,6 +106,8 @@ async def test_create_run_and_evaluate_agent(self) -> None:
data = await resp.get_json()
self.assertIn("tool_called", data["criteria_types"])
self.assertIn("completed", data["run_statuses"])
self.assertIn("llm", data)
self.assertIn("available_models", data["llm"])

# List tools
resp = await client.get("/api/workbench/tools")
Expand Down
43 changes: 43 additions & 0 deletions backend/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,49 @@ async def health_check(self) -> bool:
except Exception as e:
logger.warning(f"LLM health check failed ({self._backend}): {e}")
return False

def get_model_catalog(self) -> dict[str, Any]:
"""Return backend/model metadata for UI model selection."""
fallback_models = list(getattr(self, '_fallback_models', []))
configured_models = [self.model, *fallback_models]
available_models: list[str] = []
source = "configured"
provider = None

if self._backend == "litellm":
provider = self.model.split("/", 1)[0] if "/" in self.model else None
try:
import litellm

discovered = litellm.get_valid_models(
custom_llm_provider=provider,
check_provider_endpoint=False,
)
if discovered:
available_models = [m for m in discovered if isinstance(m, str) and m.strip()]
source = "litellm"
except Exception as exc:
logger.warning("Failed to discover LiteLLM models: %s", exc)

merged_models: list[str] = []
seen: set[str] = set()
for model_name in [*configured_models, *available_models]:
if not isinstance(model_name, str):
continue
normalized = model_name.strip()
if not normalized or normalized in seen:
continue
merged_models.append(normalized)
seen.add(normalized)

return {
"backend": self._backend,
"provider": provider,
"default_model": self.model,
"fallback_models": fallback_models,
"available_models": merged_models,
"source": source,
}

async def structured_chat(
self,
Expand Down
52 changes: 45 additions & 7 deletions backend/tests/test_llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
"""

import os
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from pydantic import BaseModel, Field

from llm_service import LLMService, get_llm_service
import pytest
from kba_exceptions import (
LLMUnavailableError,
LLMTimeoutError,
LLMRateLimitError,
LLMAuthenticationError,
LLMRateLimitError,
LLMTimeoutError,
LLMUnavailableError,
)
from kba_output_models import KBAOutputSchema
from llm_service import LLMService, get_llm_service
from pydantic import BaseModel, Field


# Test Pydantic Schema
Expand Down Expand Up @@ -137,6 +137,43 @@ async def test_health_check_litellm(self):
assert result is True


class TestLLMServiceModelCatalog:
"""Test model catalog exposure for UI selection."""

def test_model_catalog_uses_litellm_discovery(self):
service = LLMService(model='github_copilot/gpt-4o', backend='litellm')
service._fallback_models = ['github_copilot/gpt-4o-mini']

with patch('litellm.get_valid_models') as mock_get_valid_models:
mock_get_valid_models.return_value = ['github_copilot/gpt-4o', 'github_copilot/claude-sonnet-4']

catalog = service.get_model_catalog()

assert catalog['backend'] == 'litellm'
assert catalog['provider'] == 'github_copilot'
assert catalog['default_model'] == 'github_copilot/gpt-4o'
assert catalog['fallback_models'] == ['github_copilot/gpt-4o-mini']
assert catalog['available_models'] == [
'github_copilot/gpt-4o',
'github_copilot/gpt-4o-mini',
'github_copilot/claude-sonnet-4',
]
assert catalog['source'] == 'litellm'

def test_model_catalog_falls_back_to_configured_models(self):
service = LLMService(model='github_copilot/gpt-4o', backend='litellm')
service._fallback_models = ['github_copilot/gpt-4o-mini']

with patch('litellm.get_valid_models', side_effect=Exception('boom')):
catalog = service.get_model_catalog()

assert catalog['available_models'] == [
'github_copilot/gpt-4o',
'github_copilot/gpt-4o-mini',
]
assert catalog['source'] == 'configured'


class TestLLMServiceStructuredOutputOpenAI:
"""Test structured output generation with OpenAI backend"""

Expand Down Expand Up @@ -505,8 +542,9 @@ async def mock_acompletion(**kwargs):
def test_fallback_chain_deduplication(self):
"""Test that fallback chain deduplicates the primary model"""
with patch.dict(os.environ, {"LITELLM_FALLBACK_MODELS": "github_copilot/gpt-4o,github_copilot/claude-sonnet-4,github_copilot/gpt-4o"}):
import llm_service
from importlib import reload

import llm_service
reload(llm_service)

service = llm_service.LLMService(model='github_copilot/gpt-4o', backend='litellm')
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/test_workbench_integration_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ async def test_create_run_and_evaluate_agent_with_csv_tool(self) -> None:
self.assertIn("/api/workbench/runs/{run_id}/evaluate", endpoint_paths)
self.assertIn("tool_called", ui_config_data["criteria_types"])
self.assertIn("completed", ui_config_data["run_statuses"])
self.assertIn("llm", ui_config_data)
self.assertIn("available_models", ui_config_data["llm"])

tools_resp = await client.get("/api/workbench/tools")
self.assertEqual(tools_resp.status_code, 200)
Expand Down
61 changes: 45 additions & 16 deletions frontend/src/features/workbench/AgentCreateForm.jsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import {
Button,
Card,
Checkbox,
Combobox,
Field,
Input,
Option,
Text,
Textarea,
makeStyles,
tokens,
Button,
Card,
Checkbox,
Combobox,
Dropdown,
Field,
Input,
Option,
Text,
Textarea,
makeStyles,
tokens,
} from '@fluentui/react-components'
import { useEffect, useState } from 'react'
import {
createWorkbenchAgent,
improvePrompt,
suggestOutputSchema,
updateWorkbenchAgent,
createWorkbenchAgent,
improvePrompt,
suggestOutputSchema,
updateWorkbenchAgent,
} from '../../services/api'
import { buildModelOptions } from './modelOptions'
import SchemaEditor from './SchemaEditor'

const useStyles = makeStyles({
Expand Down Expand Up @@ -120,6 +122,7 @@ const EMPTY_FORM = {
name: '',
description: '',
systemPrompt: '',
model: '',
requiresInput: false,
requiredInputDescription: '',
showInMenu: false,
Expand All @@ -137,13 +140,14 @@ function formDataFromAgent(agent) {
name: agent.name || '',
description: agent.description || '',
systemPrompt: agent.system_prompt || '',
model: agent.model || '',
requiresInput: Boolean(agent.requires_input),
requiredInputDescription: agent.required_input_description || '',
showInMenu: Boolean(agent.show_in_menu),
}
}

export default function AgentCreateForm({ tools, onAgentCreated, initialData }) {
export default function AgentCreateForm({ tools, onAgentCreated, initialData, modelOptions = [], serviceDefaultModel = '' }) {
const styles = useStyles()
const isEditing = Boolean(initialData?.id)

Expand Down Expand Up @@ -175,6 +179,7 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
name: tpl.name,
description: tpl.description,
systemPrompt: tpl.system_prompt,
model: '',
requiresInput: Boolean(tpl.requires_input),
requiredInputDescription: tpl.required_input_description || '',
showInMenu: Boolean(tpl.show_in_menu),
Expand Down Expand Up @@ -286,6 +291,7 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
name: formData.name.trim(),
description: formData.description.trim(),
system_prompt: formData.systemPrompt.trim(),
model: formData.model.trim(),
requires_input: formData.requiresInput,
required_input_description: formData.requiresInput
? formData.requiredInputDescription.trim()
Expand Down Expand Up @@ -325,6 +331,9 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
const submitLabel = isEditing
? (submitting ? 'Saving...' : 'Save Agent')
: (submitting ? 'Creating...' : 'Create Agent')
const resolvedModelOptions = buildModelOptions(modelOptions, formData.model)
const selectedModelOption = formData.model || '__service_default__'
const modelDisplayValue = formData.model || (serviceDefaultModel ? `Service default (${serviceDefaultModel})` : 'Service default')

return (
<div className={styles.grid}>
Expand Down Expand Up @@ -427,6 +436,26 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
/>
</Field>
{fieldErrors.systemPrompt && <Text>{fieldErrors.systemPrompt}</Text>}
<Field label="Model">
<Dropdown
data-testid="workbench-agent-model-select"
value={modelDisplayValue}
selectedOptions={[selectedModelOption]}
onOptionSelect={(_, data) => {
const nextModel = data.optionValue === '__service_default__' ? '' : (data.optionValue || '')
setFormData((prev) => ({ ...prev, model: nextModel }))
}}
>
<Option value="__service_default__">
{serviceDefaultModel ? `Service default (${serviceDefaultModel})` : 'Service default'}
</Option>
{resolvedModelOptions.map((modelName) => (
<Option key={modelName} value={modelName}>
{modelName}
</Option>
))}
</Dropdown>
</Field>
<Button
data-testid="workbench-improve-prompt-button"
disabled={improvingPrompt || !formData.systemPrompt.trim()}
Expand Down
Loading