Feature/litellm proxy (#27)

* feat: seed governance config and responses routing

* Add env-configurable timeout for proxy providers

* Integrate LiteLLM OTEL collector and update docs

* Make .env.litellm optional for LiteLLM proxy

* Add LiteLLM proxy integration with model-agnostic virtual keys

Changes:
- Bootstrap generates 3 virtual keys with individual budgets (CLI: $100, Task-Agent: $25, Cognee: $50)
- Task-agent loads config at runtime via entrypoint script to wait for bootstrap completion
- All keys are model-agnostic by default (no LITELLM_DEFAULT_MODELS restrictions)
- Bootstrap handles database/env mismatch after docker prune by deleting stale aliases
- CLI and Cognee configured to use LiteLLM proxy with virtual keys
- Added comprehensive documentation in volumes/env/README.md

Technical details:
- task-agent entrypoint waits for keys in .env file before starting uvicorn
- Bootstrap creates/updates TASK_AGENT_API_KEY, COGNEE_API_KEY, and OPENAI_API_KEY
- Removed hardcoded API keys from docker-compose.yml
- All services route through http://localhost:10999 proxy

* Fix CLI not loading virtual keys from global .env

Project .env files with empty OPENAI_API_KEY values were overriding
the global virtual keys. Updated _load_env_file_if_exists to only
override with non-empty values.

* Fix agent executor not passing API key to LiteLLM

The agent was initializing LiteLlm without api_key or api_base,
causing authentication errors when using the LiteLLM proxy. Now
reads from OPENAI_API_KEY/LLM_API_KEY and LLM_ENDPOINT environment
variables and passes them to LiteLlm constructor.

* Auto-populate project .env with virtual key from global config

When running 'ff init', the command now checks for a global
volumes/env/.env file and automatically uses the OPENAI_API_KEY
virtual key if found. This ensures projects work with LiteLLM
proxy out of the box without manual key configuration.

* docs: Update README with LiteLLM configuration instructions

Add note about LITELLM_GEMINI_API_KEY configuration and clarify that OPENAI_API_KEY default value should not be changed as it's used for the LLM proxy.

* Refactor workflow parameters to use JSON Schema defaults

Consolidates parameter defaults into JSON Schema format, removing the separate default_parameters field. Adds extract_defaults_from_json_schema() helper to extract defaults from the standard schema structure. Updates LiteLLM proxy config to use LITELLM_OPENAI_API_KEY environment variable.

* Remove .env.example from task_agent

* Fix MDX syntax error in llm-proxy.md

* fix: apply default parameters from metadata.yaml automatically

Fixed TemporalManager.run_workflow() to correctly apply default parameter
values from workflow metadata.yaml files when parameters are not provided
by the caller.

Previous behavior:
- When workflow_params was empty {}, the condition
  `if workflow_params and 'parameters' in metadata` would fail
- Parameters would not be extracted from schema, resulting in workflows
  receiving only target_id with no other parameters

New behavior:
- Removed the `workflow_params and` requirement from the condition
- Now explicitly checks for defaults in parameter spec
- Applies defaults from metadata.yaml automatically when param not provided
- Workflows receive all parameters with proper fallback:
  provided value > metadata default > None

This makes metadata.yaml the single source of truth for parameter defaults,
removing the need for workflows to implement defensive default handling.

Affected workflows:
- llm_secret_detection (was failing with KeyError)
- All other workflows now benefit from automatic default application

Co-authored-by: tduhamel42 <tduhamel@fuzzinglabs.com>
This commit is contained in:
Songbird99
2025-10-26 12:51:53 +01:00
committed by GitHub
parent 3b25edef19
commit a2c760ea2b
29 changed files with 1869 additions and 106 deletions
+41 -8
View File
@@ -43,6 +43,42 @@ ALLOWED_CONTENT_TYPES = [
router = APIRouter(prefix="/workflows", tags=["workflows"])
def extract_defaults_from_json_schema(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract default parameter values from JSON Schema format.
Converts from:
parameters:
properties:
param_name:
default: value
To:
{param_name: value}
Args:
metadata: Workflow metadata dictionary
Returns:
Dictionary of parameter defaults
"""
defaults = {}
# Check if there's a legacy default_parameters field
if "default_parameters" in metadata:
defaults.update(metadata["default_parameters"])
# Extract defaults from JSON Schema parameters
parameters = metadata.get("parameters", {})
properties = parameters.get("properties", {})
for param_name, param_spec in properties.items():
if "default" in param_spec:
defaults[param_name] = param_spec["default"]
return defaults
def create_structured_error_response(
error_type: str,
message: str,
@@ -164,7 +200,7 @@ async def get_workflow_metadata(
author=metadata.get("author"),
tags=metadata.get("tags", []),
parameters=metadata.get("parameters", {}),
default_parameters=metadata.get("default_parameters", {}),
default_parameters=extract_defaults_from_json_schema(metadata),
required_modules=metadata.get("required_modules", [])
)
@@ -221,7 +257,7 @@ async def submit_workflow(
# Merge default parameters with user parameters
workflow_info = temporal_mgr.workflows[workflow_name]
metadata = workflow_info.metadata or {}
defaults = metadata.get("default_parameters", {})
defaults = extract_defaults_from_json_schema(metadata)
user_params = submission.parameters or {}
workflow_params = {**defaults, **user_params}
@@ -450,7 +486,7 @@ async def upload_and_submit_workflow(
# Merge default parameters with user parameters
workflow_info = temporal_mgr.workflows.get(workflow_name)
metadata = workflow_info.metadata or {}
defaults = metadata.get("default_parameters", {})
defaults = extract_defaults_from_json_schema(metadata)
workflow_params = {**defaults, **workflow_params}
# Start workflow execution
@@ -617,11 +653,8 @@ async def get_workflow_parameters(
else:
param_definitions = parameters_schema
# Add default values to the schema
default_params = metadata.get("default_parameters", {})
for param_name, param_schema in param_definitions.items():
if isinstance(param_schema, dict) and param_name in default_params:
param_schema["default"] = default_params[param_name]
# Extract default values from JSON Schema
default_params = extract_defaults_from_json_schema(metadata)
return {
"workflow": workflow_name,
+18 -2
View File
@@ -187,12 +187,28 @@ class TemporalManager:
# Add parameters in order based on metadata schema
# This ensures parameters match the workflow signature order
if workflow_params and 'parameters' in workflow_info.metadata:
# Apply defaults from metadata.yaml if parameter not provided
if 'parameters' in workflow_info.metadata:
param_schema = workflow_info.metadata['parameters'].get('properties', {})
logger.debug(f"Found {len(param_schema)} parameters in schema")
# Iterate parameters in schema order and add values
for param_name in param_schema.keys():
param_value = workflow_params.get(param_name)
param_spec = param_schema[param_name]
# Use provided param, or fall back to default from metadata
if workflow_params and param_name in workflow_params:
param_value = workflow_params[param_name]
logger.debug(f"Using provided value for {param_name}: {param_value}")
elif 'default' in param_spec:
param_value = param_spec['default']
logger.debug(f"Using default for {param_name}: {param_value}")
else:
param_value = None
logger.debug(f"No value or default for {param_name}, using None")
workflow_args.append(param_value)
else:
logger.debug("No 'parameters' section found in workflow metadata")
# Determine task queue from workflow vertical
vertical = workflow_info.metadata.get("vertical", "default")
@@ -107,7 +107,8 @@ class LLMSecretDetectorModule(BaseModule):
)
agent_url = config.get("agent_url")
if not agent_url or not isinstance(agent_url, str):
# agent_url is optional - will have default from metadata.yaml
if agent_url is not None and not isinstance(agent_url, str):
raise ValueError("agent_url must be a valid URL string")
max_files = config.get("max_files", 20)
@@ -131,14 +132,14 @@ class LLMSecretDetectorModule(BaseModule):
logger.info(f"Starting LLM secret detection in workspace: {workspace}")
# Extract configuration
agent_url = config.get("agent_url", "http://fuzzforge-task-agent:8000/a2a/litellm_agent")
llm_model = config.get("llm_model", "gpt-4o-mini")
llm_provider = config.get("llm_provider", "openai")
file_patterns = config.get("file_patterns", ["*.py", "*.js", "*.ts", "*.java", "*.go", "*.env", "*.yaml", "*.yml", "*.json", "*.xml", "*.ini", "*.sql", "*.properties", "*.sh", "*.bat", "*.config", "*.conf", "*.toml", "*id_rsa*", "*.txt"])
max_files = config.get("max_files", 20)
max_file_size = config.get("max_file_size", 30000)
timeout = config.get("timeout", 30) # Reduced from 45s
# Extract configuration (defaults come from metadata.yaml via API)
agent_url = config["agent_url"]
llm_model = config["llm_model"]
llm_provider = config["llm_provider"]
file_patterns = config["file_patterns"]
max_files = config["max_files"]
max_file_size = config["max_file_size"]
timeout = config["timeout"]
# Find files to analyze
# Skip files that are unlikely to contain secrets
@@ -30,5 +30,42 @@ parameters:
type: integer
default: 20
max_file_size:
type: integer
default: 30000
description: "Maximum file size in bytes"
timeout:
type: integer
default: 30
description: "Timeout per file in seconds"
file_patterns:
type: array
items:
type: string
default:
- "*.py"
- "*.js"
- "*.ts"
- "*.java"
- "*.go"
- "*.env"
- "*.yaml"
- "*.yml"
- "*.json"
- "*.xml"
- "*.ini"
- "*.sql"
- "*.properties"
- "*.sh"
- "*.bat"
- "*.config"
- "*.conf"
- "*.toml"
- "*id_rsa*"
- "*.txt"
description: "File patterns to scan for secrets"
required_modules:
- "llm_secret_detector"
@@ -17,6 +17,7 @@ class LlmSecretDetectionWorkflow:
llm_model: Optional[str] = None,
llm_provider: Optional[str] = None,
max_files: Optional[int] = None,
max_file_size: Optional[int] = None,
timeout: Optional[int] = None,
file_patterns: Optional[list] = None
) -> Dict[str, Any]:
@@ -67,6 +68,8 @@ class LlmSecretDetectionWorkflow:
config["llm_provider"] = llm_provider
if max_files:
config["max_files"] = max_files
if max_file_size:
config["max_file_size"] = max_file_size
if timeout:
config["timeout"] = timeout
if file_patterns: