Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 15 additions & 0 deletions codeframe/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ class EnvironmentConfig:
# LLM provider config (workspace-level default; overridden by env vars and CLI flags)
llm: Optional[LLMConfig] = None

# Settings page (issue #554) — UI-managed agent settings
max_cost_usd: Optional[float] = None
agent_type_models: dict[str, str] = dataclass_field(default_factory=dict)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def validate(self) -> list[str]:
"""Validate configuration values.

Expand Down Expand Up @@ -217,6 +221,17 @@ def validate(self) -> list[str]:
if budget.stall_timeout_s < 0:
errors.append("agent_budget.stall_timeout_s must be >= 0 (0 = disabled)")

if self.max_cost_usd is not None and self.max_cost_usd < 0:
errors.append("max_cost_usd must be >= 0")

valid_agent_types = {"claude_code", "codex", "opencode", "react"}
invalid_agent_types = sorted(set(self.agent_type_models) - valid_agent_types)
if invalid_agent_types:
errors.append(
"agent_type_models contains unsupported agent types: "
+ ", ".join(invalid_agent_types)
)

return errors

def get_install_command(self, package: str) -> str:
Expand Down
49 changes: 48 additions & 1 deletion codeframe/ui/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from enum import Enum
from pydantic import BaseModel, Field, model_validator, ConfigDict
from typing import Optional, List
from typing import Literal, Optional, List


class SourceType(str, Enum):
Expand Down Expand Up @@ -901,3 +901,50 @@ class ErrorResponse(BaseModel):
)

detail: str = Field(..., description="Error message describing what went wrong")


# ============================================================================
# Settings (issue #554)
# ============================================================================


AgentType = Literal["claude_code", "codex", "opencode", "react"]
AGENT_TYPES: tuple[AgentType, ...] = ("claude_code", "codex", "opencode", "react")


class AgentTypeModelConfig(BaseModel):
"""Default model for a single agent type."""

agent_type: AgentType = Field(
..., description="One of: claude_code, codex, opencode, react"
)
default_model: str = Field(
default="",
description="Model identifier (e.g. 'claude-opus-4', 'gpt-4o'); empty string means unset",
)


class AgentSettings(BaseModel):
"""Agent settings shared by GET response and PUT request.

Defaults match `EnvironmentConfig.agent_budget.max_iterations` so that a
fresh workspace round-trips its real defaults through GET.
"""

agent_models: List[AgentTypeModelConfig] = Field(
..., description="Default model per agent type"
)
max_turns: int = Field(
default=100, gt=0, description="Maximum turns per task (must be > 0)"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
max_cost_usd: Optional[float] = Field(
default=None, ge=0, description="Maximum cost per task in USD"
)


class AgentSettingsResponse(AgentSettings):
"""Response shape for GET /api/v2/settings."""


class UpdateAgentSettingsRequest(AgentSettings):
"""Request shape for PUT /api/v2/settings."""
116 changes: 116 additions & 0 deletions codeframe/ui/routers/settings_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""V2 Settings router — agent settings managed via the web UI.

Reads/writes a flat AgentSettings shape persisted in
.codeframe/config.yaml via load_environment_config / save_environment_config.

Routes:
GET /api/v2/settings - Load agent settings (returns defaults if missing)
PUT /api/v2/settings - Save agent settings (merges into existing config)
"""

import logging

from fastapi import APIRouter, Depends, HTTPException, Request

from codeframe.core.config import (
AgentBudgetConfig,
EnvironmentConfig,
load_environment_config,
save_environment_config,
)
from codeframe.core.workspace import Workspace
from codeframe.lib.rate_limiter import rate_limit_standard
from codeframe.ui.dependencies import get_v2_workspace
from codeframe.ui.models import (
AGENT_TYPES,
AgentSettingsResponse,
AgentTypeModelConfig,
UpdateAgentSettingsRequest,
)
from codeframe.ui.response_models import ErrorCodes, api_error

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v2/settings", tags=["settings"])


def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse:
"""Map an EnvironmentConfig to the flat AgentSettings response shape."""
saved_models = config.agent_type_models or {}
agent_models = [
AgentTypeModelConfig(
agent_type=agent_type,
default_model=saved_models.get(agent_type, ""),
)
for agent_type in AGENT_TYPES
]
# Guard against legacy YAML where agent_budget may have been removed/nulled.
budget = config.agent_budget or AgentBudgetConfig()
return AgentSettingsResponse(
agent_models=agent_models,
max_turns=budget.max_iterations,
max_cost_usd=config.max_cost_usd,
)


@router.get("", response_model=AgentSettingsResponse)
@rate_limit_standard()
async def get_settings(
request: Request,
workspace: Workspace = Depends(get_v2_workspace),
) -> AgentSettingsResponse:
"""Load agent settings for the workspace.

Returns defaults if no .codeframe/config.yaml exists.
"""
try:
config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
return _config_to_response(config)
except Exception as e:
logger.error(f"Failed to load settings: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error(
"Failed to load settings", ErrorCodes.EXECUTION_FAILED, str(e)
),
)


@router.put("", response_model=AgentSettingsResponse)
@rate_limit_standard()
async def update_settings(
request: Request,
body: UpdateAgentSettingsRequest,
workspace: Workspace = Depends(get_v2_workspace),
) -> AgentSettingsResponse:
"""Save agent settings.

Merges into existing EnvironmentConfig so unrelated fields
(package_manager, test_framework, etc.) are preserved.
"""
try:
config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
if config.agent_budget is None:
config.agent_budget = AgentBudgetConfig()

config.agent_budget.max_iterations = body.max_turns
config.max_cost_usd = body.max_cost_usd
# Skip empty model strings — they're equivalent to "key not present"
# in _config_to_response, so persisting them just adds yaml noise.
# AgentType Literal in the model already rejects unknown agent_type values.
config.agent_type_models = {
entry.agent_type: entry.default_model
for entry in body.agent_models
if entry.default_model
}

save_environment_config(workspace.repo_path, config)
return _config_to_response(config)
except Exception as e:
logger.error(f"Failed to save settings: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error(
"Failed to save settings", ErrorCodes.EXECUTION_FAILED, str(e)
),
)
2 changes: 2 additions & 0 deletions codeframe/ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
review_v2,
schedule_v2,
session_chat_ws,
settings_v2,
terminal_ws,
streaming_v2,
tasks_v2,
Expand Down Expand Up @@ -496,6 +497,7 @@ async def test_broadcast(message: dict, project_id: int = None):
app.include_router(proof_v2.router) # /api/v2/proof
app.include_router(review_v2.router) # /api/v2/review
app.include_router(schedule_v2.router) # /api/v2/schedule
app.include_router(settings_v2.router) # /api/v2/settings
app.include_router(streaming_v2.router) # /api/v2/tasks/{id}/stream (SSE)
app.include_router(tasks_v2.router) # /api/v2/tasks
app.include_router(templates_v2.router) # /api/v2/templates
Expand Down
14 changes: 14 additions & 0 deletions tests/core/test_environment_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ def test_multiple_validation_errors(self):
errors = config.validate()
assert len(errors) == 3

def test_invalid_max_cost_usd(self):
"""Negative max_cost_usd is rejected."""
config = EnvironmentConfig(max_cost_usd=-1.0)
errors = config.validate()
assert any("max_cost_usd" in e for e in errors)

def test_invalid_agent_type_in_models(self):
"""Unknown agent_type keys in agent_type_models are rejected."""
config = EnvironmentConfig(
agent_type_models={"claude_code": "claude-opus-4", "evil_bot": "x"}
)
errors = config.validate()
assert any("evil_bot" in e for e in errors)


class TestEnvironmentConfigCommands:
"""Tests for command generation."""
Expand Down
Loading
Loading